From 453fe9c51115cfa547f81a81da550526fd4bf0ed Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 00:40:05 +0200 Subject: [PATCH 001/110] Document merge view implementation plan --- design/merge-view-architecture.md | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/design/merge-view-architecture.md b/design/merge-view-architecture.md index f3e2443a..5a48dfc8 100644 --- a/design/merge-view-architecture.md +++ b/design/merge-view-architecture.md @@ -490,6 +490,47 @@ acceptable. Each phase should leave the repository testable and avoid combining broad behavioral changes with mechanical moves. +### Planned commit sequence + +Implementation is organized as twelve reviewable commits. A commit may be split +if its diff becomes difficult to review, but independent phases should not be +squashed together merely to preserve the count. + +1. `test: characterize curation selection contracts` +2. `refactor: add curation selection state model` +3. `refactor: shadow supervisor selection state` +4. `refactor: make curation selection authoritative` +5. `refactor: separate reference and presentation order` +6. `refactor: remove selection state from task history` +7. `refactor: add contextual curation history` +8. `feat: add merge session and mode lifecycle` +9. `feat: add merge candidate interactions` +10. `feat: restore merge sessions through history` +11. `feat: add cluster table drag and drop` +12. `docs: finalize merge view workflow` + +Commits 1-7 form the architectural foundation (Milestone 1). Commits 8-10 +provide the complete manual workflow without drag-and-drop (Milestone 2). +Commits 11-12 add drag-and-drop and final cleanup/documentation (Milestone 3). + +The initial Merge-mode action policy is: + +- disable split, group/metadata changes, Cluster navigation, and Cluster + selection; +- allow Similarity navigation, filtering, sorting, Ctrl+Space, Backspace, `C`, + `G`, and save; +- reject unsafe direct or plugin calls explicitly without partially mutating the + workspace; +- do not let an uncommitted Merge session undo an earlier curation action; +- after undoing a Merge-mode merge, allow redo to reapply it; and +- truncate that redo branch normally if the restored workspace is edited and a + different curation action is committed. + +Cancellation and shutdown restore or persist the Normal-mode entry snapshot, +never the transient empty Cluster selection. The snapshot includes selections, +reference, ordering, filter, sort, and navigation state. Pixel-perfect scroll +restoration is best effort where Qt exposes a reliable value. + ### Phase 0: characterization - Add tests for selection order, effective selection, multi-Cluster Similarity From 5e57de701d8c194f7041c96c08580aea774dd891 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 00:44:14 +0200 Subject: [PATCH 002/110] test: characterize curation selection contracts --- phy/cluster/tests/test_supervisor.py | 75 ++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 40e9f83a..edffeb32 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -18,6 +18,7 @@ from phy.gui.qt import QHeaderView, Qt, qInstallMessageHandler from phy.gui.tests.test_widgets import _assert, _wait_until_table_ready from phy.gui.widgets import Barrier +from phy.utils.color import selected_cluster_color from phy.utils.context import Context from .. import supervisor as _supervisor @@ -233,6 +234,31 @@ def test_cluster_view_1(qtbot, gui, data): assert cv.state == {'current_sort': ('id', 'desc'), 'selected': [2]} +def test_cluster_view_control_right_click_reports_unselected_row_without_selecting_it( + qtbot, gui, data +): + cv = ClusterView(gui, data=data) + _wait_until_table_ready(qtbot, cv) + cv.select([1]) + qtbot.wait(10) + + clicked = [] + + @connect(sender=cv) + def on_row_right_click(sender, cluster_id): + clicked.append(cluster_id) + + index = cv._proxy_index_for_id(2) + pos = cv.table_view.visualRect(index).center() + control_modifier = Qt.MetaModifier if sys.platform == 'darwin' else Qt.ControlModifier + qtbot.mouseClick(cv.table_view.viewport(), Qt.RightButton, control_modifier, pos=pos) + + assert clicked == [2] + assert cv.get_selected_ids() == [1] + + unconnect(on_row_right_click) + + def test_cluster_view_formats_spike_counts(qtbot, gui): cv = ClusterView(gui, data=[{'id': 1, 'n_spikes': 1234567}]) _wait_until_table_ready(qtbot, cv) @@ -442,6 +468,55 @@ def test_supervisor_select_order(qtbot, supervisor): _assert_selected(supervisor, [0, 1]) +def test_supervisor_multi_cluster_similarity_reference_and_positional_colors(supervisor): + requested = [] + + @connect(sender=supervisor.similarity_view) + def on_request_similar_clusters(sender, cluster_id): + requested.append(cluster_id) + + _select(supervisor, [10, 30], [20]) + + # Similarity uses the last Cluster View row as its reference, whereas the + # first selected cluster owns the blue positional color slot. + assert requested == [30] + assert supervisor.selected == [10, 30, 20] + + def rgb(color): + return tuple(channel / 255 for channel in color.getRgb()[:3]) + + def expected_rgb(index): + return tuple(int(channel * 255) / 255 for channel in selected_cluster_color(index)[:3]) + + assert rgb(supervisor.cluster_view._selection_background(10)) == expected_rgb(0) + assert rgb(supervisor.cluster_view._selection_background(30)) == expected_rgb(1) + assert rgb(supervisor.similarity_view._selection_background(20)) == expected_rgb(2) + + unconnect(on_request_similar_clusters) + + +def test_supervisor_select_event_has_legacy_payload_and_suppression(supervisor): + events = [] + + @connect(sender=supervisor) + def on_select(sender, cluster_ids, **kwargs): + events.append((sender, cluster_ids, kwargs)) + + supervisor.cluster_view.select([10, 30], marker='legacy') + supervisor.block() + + assert events == [(supervisor, [10, 30], {'marker': 'legacy'})] + + # ``update_views`` is an internal suppression flag: it neither reaches + # public listeners nor changes the selected rows. + supervisor.cluster_view.select([20], update_views=False) + supervisor.block() + assert events == [(supervisor, [10, 30], {'marker': 'legacy'})] + assert supervisor.selected_clusters == [20] + + unconnect(on_select) + + def test_supervisor_select_first_similar(qtbot, supervisor, gui): _select(supervisor, [30]) similarity_view = supervisor.similarity_view From 5c520a943b3b032e6668098dbe83e5e5dcc0e09c Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 00:44:51 +0200 Subject: [PATCH 003/110] refactor: add curation selection state model --- phy/cluster/_selection.py | 202 ++++++++++++++++++++++++++++ phy/cluster/tests/test_selection.py | 123 +++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 phy/cluster/_selection.py create mode 100644 phy/cluster/tests/test_selection.py diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py new file mode 100644 index 00000000..a1f3ae0b --- /dev/null +++ b/phy/cluster/_selection.py @@ -0,0 +1,202 @@ +"""Immutable selection state used by curation workflows. + +This module intentionally has no Qt or Supervisor dependencies. Views can use +the controller as a synchronous source of selection state, while deciding +separately how and when to render a :class:`SelectionChange`. +""" + +from dataclasses import dataclass +from enum import Enum + + +class WorkflowMode(Enum): + """The active curation workflow.""" + + NORMAL = 'normal' + MERGE = 'merge' + + +def _as_unique_ids(cluster_ids) -> tuple[int, ...]: + """Return *cluster_ids* as a tuple, rejecting duplicates.""" + cluster_ids = tuple(cluster_ids) + if len(cluster_ids) != len(set(cluster_ids)): + raise ValueError('Cluster IDs must be unique.') + return cluster_ids + + +def _ordered_union(*cluster_id_lists) -> tuple[int, ...]: + """Return the ordered union of the supplied cluster-ID sequences.""" + return tuple(dict.fromkeys(cluster_id for ids in cluster_id_lists for cluster_id in ids)) + + +@dataclass(frozen=True) +class CurationSelectionState: + """The authoritative, immutable Normal-mode curation selection. + + ``presentation_order`` is the effective selection in the order delivered + to scientific views. It is independent from the two role-specific orders + so a future role transfer can leave colors and redraw state untouched. + """ + + mode: WorkflowMode = WorkflowMode.NORMAL + cluster_ids: tuple[int, ...] = () + similar_ids: tuple[int, ...] = () + reference_id: int | None = None + presentation_order: tuple[int, ...] | None = None + + def __post_init__(self): + if self.mode is not WorkflowMode.NORMAL: + raise ValueError('Only Normal-mode selection state is supported.') + + cluster_ids = _as_unique_ids(self.cluster_ids) + similar_ids = _as_unique_ids(self.similar_ids) + effective_ids = _ordered_union(cluster_ids, similar_ids) + presentation_order = ( + effective_ids + if self.presentation_order is None + else _as_unique_ids(self.presentation_order) + ) + + if set(presentation_order) != set(effective_ids): + raise ValueError('Presentation order must contain exactly the effective IDs.') + if self.reference_id is not None and self.reference_id not in cluster_ids: + raise ValueError('The reference ID must belong to the cluster selection.') + + object.__setattr__(self, 'cluster_ids', cluster_ids) + object.__setattr__(self, 'similar_ids', similar_ids) + object.__setattr__(self, 'presentation_order', presentation_order) + + @property + def effective_ids(self): + """The ordered unique union of Cluster and Similarity selections.""" + return _ordered_union(self.cluster_ids, self.similar_ids) + + +# A state is itself an immutable and complete snapshot for Normal mode. The +# alias makes the snapshot boundary explicit at controller call sites. +CurationSelectionSnapshot = CurationSelectionState + + +@dataclass(frozen=True) +class SelectionChange: + """The complete before/after diff for one selection transition.""" + + before: CurationSelectionState + after: CurationSelectionState + roles_changed: bool + presentation_changed: bool + reference_changed: bool + mode_changed: bool + + @classmethod + def create(cls, before, after): + """Classify the transition from *before* to *after*.""" + return cls( + before=before, + after=after, + roles_changed=( + before.cluster_ids != after.cluster_ids or before.similar_ids != after.similar_ids + ), + presentation_changed=before.presentation_order != after.presentation_order, + reference_changed=before.reference_id != after.reference_id, + mode_changed=before.mode is not after.mode, + ) + + @property + def changed(self): + """Whether this transition changes any modeled state.""" + return self.before != self.after + + +class CurationSelectionController: + """Apply validated, atomic Normal-mode selection transitions.""" + + def __init__(self, state=None): + self._state = state or CurationSelectionState() + if self._state.mode is not WorkflowMode.NORMAL: + raise ValueError('Only Normal-mode selection is supported.') + + @property + def state(self): + """Return the current immutable selection state.""" + return self._state + + def snapshot(self): + """Return an immutable snapshot of the current Normal-mode state.""" + return self._state + + def restore(self, snapshot): + """Restore a previously captured Normal-mode *snapshot*.""" + if not isinstance(snapshot, CurationSelectionState): + raise TypeError('Expected a CurationSelectionState snapshot.') + return self._apply(snapshot) + + def set_cluster_selection(self, cluster_ids, reference_id=None): + """Set Cluster View IDs, using the final ID as the default reference.""" + cluster_ids = _as_unique_ids(cluster_ids) + if reference_id is None: + reference_id = cluster_ids[-1] if cluster_ids else None + after = CurationSelectionState( + cluster_ids=cluster_ids, + similar_ids=self._state.similar_ids, + reference_id=reference_id, + ) + return self._apply(after) + + def set_similarity_selection(self, similar_ids): + """Set Similarity View IDs without changing the current reference.""" + after = CurationSelectionState( + cluster_ids=self._state.cluster_ids, + similar_ids=_as_unique_ids(similar_ids), + reference_id=self._state.reference_id, + ) + return self._apply(after) + + def clear_similarity_selection(self): + """Clear only the Similarity View selection.""" + return self.set_similarity_selection(()) + + def transfer_cluster_to_similarity(self, cluster_ids): + """Move Cluster View IDs to Similarity View without changing presentation.""" + cluster_ids = _as_unique_ids(cluster_ids) + source_ids = set(cluster_ids) + current = self._state + if not source_ids <= set(current.cluster_ids): + raise ValueError('Transferred IDs must belong to the cluster selection.') + remaining_clusters = tuple(i for i in current.cluster_ids if i not in source_ids) + similar_ids = _ordered_union(current.similar_ids, cluster_ids) + reference_id = ( + current.reference_id + if current.reference_id in remaining_clusters + else (remaining_clusters[-1] if remaining_clusters else None) + ) + after = CurationSelectionState( + cluster_ids=remaining_clusters, + similar_ids=similar_ids, + reference_id=reference_id, + presentation_order=current.presentation_order, + ) + return self._apply(after) + + def transfer_similarity_to_cluster(self, cluster_ids): + """Move Similarity View IDs to Cluster View without changing presentation.""" + cluster_ids = _as_unique_ids(cluster_ids) + source_ids = set(cluster_ids) + current = self._state + if not source_ids <= set(current.similar_ids): + raise ValueError('Transferred IDs must belong to the similarity selection.') + similar_ids = tuple(i for i in current.similar_ids if i not in source_ids) + cluster_selection = _ordered_union(current.cluster_ids, cluster_ids) + reference_id = current.reference_id or (cluster_ids[-1] if cluster_ids else None) + after = CurationSelectionState( + cluster_ids=cluster_selection, + similar_ids=similar_ids, + reference_id=reference_id, + presentation_order=current.presentation_order, + ) + return self._apply(after) + + def _apply(self, after): + before = self._state + self._state = after + return SelectionChange.create(before, after) diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py new file mode 100644 index 00000000..044e6de4 --- /dev/null +++ b/phy/cluster/tests/test_selection.py @@ -0,0 +1,123 @@ +"""Tests for the pure curation selection state.""" + +from dataclasses import FrozenInstanceError + +from pytest import raises + +from .._selection import ( + CurationSelectionController, + CurationSelectionState, + WorkflowMode, +) + + +def test_state_derives_unique_effective_and_presentation_ids(): + state = CurationSelectionState(cluster_ids=(3, 1), similar_ids=(1, 2)) + + assert state.effective_ids == (3, 1, 2) + assert state.presentation_order == (3, 1, 2) + + +def test_state_rejects_invalid_ids_reference_and_presentation(): + with raises(ValueError, match='unique'): + CurationSelectionState(cluster_ids=(1, 1)) + with raises(ValueError, match='reference'): + CurationSelectionState(cluster_ids=(1,), reference_id=2) + with raises(ValueError, match='exactly'): + CurationSelectionState(cluster_ids=(1,), presentation_order=(2,)) + with raises(ValueError, match='Normal-mode'): + CurationSelectionState(mode=WorkflowMode.MERGE) + + +def test_state_is_immutable(): + state = CurationSelectionState(cluster_ids=(1,), reference_id=1) + + with raises(FrozenInstanceError): + state.reference_id = 2 + + +def test_set_cluster_selection_uses_last_id_or_explicit_reference(): + controller = CurationSelectionController() + + change = controller.set_cluster_selection((3, 1)) + assert change.after.reference_id == 1 + assert change.presentation_changed + assert change.reference_changed + + change = controller.set_cluster_selection((1, 2)) + assert change.after.reference_id == 2 + assert change.after.presentation_order == (1, 2) + + change = controller.set_cluster_selection((1, 2), reference_id=1) + assert change.after.reference_id == 1 + + +def test_set_similarity_and_clear_similarity_selection(): + controller = CurationSelectionController( + CurationSelectionState(cluster_ids=(1,), reference_id=1) + ) + + change = controller.set_similarity_selection((3, 2)) + assert change.after.effective_ids == (1, 3, 2) + assert change.after.presentation_order == (1, 3, 2) + assert change.roles_changed + assert change.presentation_changed + assert not change.reference_changed + + change = controller.clear_similarity_selection() + assert change.after.similar_ids == () + assert change.after.presentation_order == (1,) + + +def test_role_transfers_leave_effective_presentation_unchanged(): + controller = CurationSelectionController( + CurationSelectionState( + cluster_ids=(1, 2), + similar_ids=(3,), + reference_id=1, + presentation_order=(1, 2, 3), + ) + ) + + change = controller.transfer_cluster_to_similarity((2,)) + assert change.after.cluster_ids == (1,) + assert change.after.similar_ids == (3, 2) + assert change.after.presentation_order == (1, 2, 3) + assert change.roles_changed + assert not change.presentation_changed + + change = controller.transfer_similarity_to_cluster((3,)) + assert change.after.cluster_ids == (1, 3) + assert change.after.similar_ids == (2,) + assert change.after.presentation_order == (1, 2, 3) + assert change.roles_changed + assert not change.presentation_changed + + +def test_role_transfer_rejects_ids_not_in_the_source_selection(): + controller = CurationSelectionController( + CurationSelectionState(cluster_ids=(1,), similar_ids=(2,), reference_id=1) + ) + + with raises(ValueError, match='cluster selection'): + controller.transfer_cluster_to_similarity((2,)) + with raises(ValueError, match='similarity selection'): + controller.transfer_similarity_to_cluster((1,)) + + +def test_snapshot_restore_and_noop_change_classification(): + controller = CurationSelectionController( + CurationSelectionState(cluster_ids=(1,), similar_ids=(2,), reference_id=1) + ) + snapshot = controller.snapshot() + controller.set_similarity_selection((3,)) + + change = controller.restore(snapshot) + assert change.before.similar_ids == (3,) + assert change.after is snapshot + assert change.presentation_changed + + change = controller.restore(snapshot) + assert not change.changed + assert not change.roles_changed + assert not change.presentation_changed From 2999260f8333377ee40a0cbe35cd8470e5c84cc9 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 00:47:36 +0200 Subject: [PATCH 004/110] refactor: shadow supervisor selection state --- phy/cluster/supervisor.py | 7 +++++++ phy/cluster/tests/test_supervisor.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index e48807f0..14ae5638 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -20,6 +20,7 @@ from phy.gui.widgets import Barrier, Table, _uniq from ._history import GlobalHistory +from ._selection import CurationSelectionController from ._utils import create_cluster_meta from .clustering import Clustering @@ -716,6 +717,9 @@ def __init__( self.actions = None # will be set when attaching the GUI self._is_dirty = None self._sort = sort # Initial sort requested in the constructor + # This is populated alongside the existing TaskLogger-derived selection during the + # migration to an explicit authoritative curation-selection model. + self.selection = CurationSelectionController() self.n_similar_clusters_to_select = self._validate_n_similar_clusters_to_select( n_similar_clusters_to_select if n_similar_clusters_to_select is not None @@ -981,6 +985,8 @@ def _clusters_selected(self, sender, obj, **kwargs): next_cluster = obj['next'] kwargs = obj.get('kwargs', {}) logger.debug('Clusters selected: %s (%s)', cluster_ids, next_cluster) + self.selection.set_cluster_selection(cluster_ids) + self.selection.clear_similarity_selection() self.task_logger.log(self.cluster_view, 'select', cluster_ids, output=obj) # Update the similarity view when the cluster view selection changes. self.similarity_view.reset(cluster_ids) @@ -1003,6 +1009,7 @@ def _similar_selected(self, sender, obj): next_similar = obj['next'] kwargs = obj.get('kwargs', {}) logger.debug('Similar clusters selected: %s (%s)', similar, next_similar) + self.selection.set_similarity_selection(similar) self.task_logger.log(self.similarity_view, 'select', similar, output=obj) emit('select', self, self.selected, **kwargs) if similar: diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index edffeb32..f244fd92 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -373,6 +373,28 @@ def _assert_selected(supervisor, sel): def test_select(qtbot, supervisor): _select(supervisor, [30], [20]) _assert_selected(supervisor, [30, 20]) + assert supervisor.selection.state.cluster_ids == (30,) + assert supervisor.selection.state.similar_ids == (20,) + assert supervisor.selection.state.reference_id == 30 + assert supervisor.selection.state.presentation_order == (30, 20) + + +def test_selection_shadow_tracks_cross_view_transfers(supervisor): + _select(supervisor, [10, 30], [20, 11, 1]) + + supervisor.promote_similar(11) + supervisor.block() + assert supervisor.selection.state.cluster_ids == (10, 11, 30) + assert supervisor.selection.state.similar_ids == (20, 1) + assert supervisor.selection.state.reference_id == 30 + assert supervisor.selection.state.presentation_order == tuple(supervisor.selected) + + supervisor.demote_cluster(10) + supervisor.block() + assert supervisor.selection.state.cluster_ids == (11, 30) + assert supervisor.selection.state.similar_ids == (20, 1, 10) + assert supervisor.selection.state.reference_id == 30 + assert supervisor.selection.state.presentation_order == tuple(supervisor.selected) def test_block_flushes_pending_selections(qtbot, supervisor): From d87a9e0e258458deb07ea3bea8e792af7a2d8a2b Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 00:52:40 +0200 Subject: [PATCH 005/110] refactor: make curation selection authoritative --- phy/cluster/supervisor.py | 33 ++++++++++++++++++++++------ phy/cluster/tests/test_supervisor.py | 10 +++++++++ phy/gui/tests/test_widgets.py | 16 ++++++++++++++ phy/gui/widgets.py | 9 ++++++-- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 14ae5638..86b521b9 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -104,6 +104,8 @@ def _callback(self, task, output): """ # Log the task and its output. self._log(task, output) + if hasattr(self.supervisor, '_selection_task_completed'): + self.supervisor._selection_task_completed(task, output) # Find the post tasks after that task has completed, and enqueue them. self.enqueue_after(task, output) # Loop. @@ -1055,10 +1057,30 @@ def _after_action(self, sender, up): up.description.replace('metadata_', ''), up.metadata_changed, ) + # Table filtering or cluster removal may make projected rows disappear without a + # selection event. Keep the authoritative role state synchronized before applying + # the post-action navigation policy. + cluster_ids = self.cluster_view.get_selected_ids() + similar_ids = self.similarity_view.get_selected_ids() + if tuple(cluster_ids) != self.selection.state.cluster_ids: + self.selection.set_cluster_selection(cluster_ids) + if tuple(similar_ids) != self.selection.state.similar_ids: + self.selection.set_similarity_selection(similar_ids) # After the action has finished, we process the pending actions, # like selection of new clusters in the tables. self.task_logger.process() + def _selection_task_completed(self, task, output): + """Reconcile navigation results that do not emit a table selection event.""" + sender, name, _, _ = task + if output is not None or name not in ('next', 'previous'): + return + if sender == self.cluster_view: + self.selection.set_cluster_selection(()) + self.selection.clear_similarity_selection() + elif sender == self.similarity_view: + self.selection.clear_similarity_selection() + def _set_busy(self, busy): # If busy is the same, do nothing. if busy is self._is_busy: @@ -1209,19 +1231,17 @@ def on_ready(sender): @property def selected_clusters(self): """Selected clusters in the cluster view only.""" - state = self.task_logger.last_state() - return state[0] or [] if state else [] + return list(self.selection.state.cluster_ids) @property def selected_similar(self): """Selected clusters in the similarity view only.""" - state = self.task_logger.last_state() - return state[2] or [] if state else [] + return list(self.selection.state.similar_ids) @property def selected(self): """Selected clusters in the cluster and similarity views.""" - return _uniq(self.selected_clusters + self.selected_similar) + return list(self.selection.state.presentation_order) def n_spikes(self, cluster_id): """Number of spikes in a given cluster.""" @@ -1328,8 +1348,7 @@ def previous_best(self, callback=None): def next(self, callback=None): """Select the next cluster in the similarity view.""" - state = self.task_logger.last_state() - if not state or not state[0]: + if not self.selected_clusters: self.cluster_view.first(callback=callback or partial(emit, 'wizard_done', self)) else: self.similarity_view.next(callback=callback or partial(emit, 'wizard_done', self)) diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index f244fd92..68c9b3f0 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -379,6 +379,16 @@ def test_select(qtbot, supervisor): assert supervisor.selection.state.presentation_order == (30, 20) +def test_supervisor_selection_is_independent_from_task_log(supervisor): + _select(supervisor, [30], [20]) + + supervisor.task_logger._history.clear() + + assert supervisor.selected_clusters == [30] + assert supervisor.selected_similar == [20] + assert supervisor.selected == [30, 20] + + def test_selection_shadow_tracks_cross_view_transfers(supervisor): _select(supervisor, [10, 30], [20, 11, 1]) diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index 533528b1..80755887 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -234,6 +234,22 @@ def test_table_1(qtbot, table): _assert(table.get_selected, [1, 2]) +def test_table_set_selected_ids_is_a_silent_projection(table): + events = [] + + @connect(sender=table) + def on_select(sender, obj): + events.append(obj) + + payload = table.set_selected_ids([2, 1, 2, 999]) + + assert payload['selected'] == [2, 1] + assert table.get_selected_ids() == [2, 1] + assert events == [] + + unconnect(on_select) + + def test_table_batch_update_fits_once(table): fit_calls = [] table._fit_columns = lambda: fit_calls.append(True) diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index 209c79de..12ae278a 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -1026,13 +1026,18 @@ def previous(self, callback=None): return self._async_return(self._move_to_sibling(None, 'previous'), callback) def select(self, ids, callback=None, **kwargs): + self.set_selected_ids(ids) + payload = self._emit_selected(kwargs) + return self._async_return(payload, callback) + + def set_selected_ids(self, ids): + """Project selected row IDs without emitting a selection event.""" ids = _uniq(ids) assert all(_is_integer(_) for _ in ids) visible = set(self._visible_ids()) self._selected_ids = [row_id for row_id in ids if row_id in visible] self._refresh_selection() - payload = self._emit_selected(kwargs) - return self._async_return(payload, callback) + return self._selected_payload() def scroll_to(self, id): index = self._proxy_index_for_id(id) From cd0a37d9daf8c68264982c8418c015e3896edd41 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 00:59:35 +0200 Subject: [PATCH 006/110] refactor: separate reference and presentation order --- phy/cluster/_selection.py | 43 ++++++++-- phy/cluster/supervisor.py | 112 +++++++++++++++++---------- phy/cluster/tests/test_selection.py | 30 +++++-- phy/cluster/tests/test_supervisor.py | 84 ++++++++++++++------ phy/gui/widgets.py | 26 ++++++- 5 files changed, 217 insertions(+), 78 deletions(-) diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index a1f3ae0b..455366b4 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -50,20 +50,35 @@ def __post_init__(self): cluster_ids = _as_unique_ids(self.cluster_ids) similar_ids = _as_unique_ids(self.similar_ids) + reference_id = self.reference_id + if reference_id is None and cluster_ids: + reference_id = cluster_ids[0] + if reference_id is not None and reference_id not in cluster_ids: + raise ValueError('The reference ID must belong to the cluster selection.') effective_ids = _ordered_union(cluster_ids, similar_ids) + default_presentation = _ordered_union( + (reference_id,) if reference_id is not None else (), + cluster_ids, + similar_ids, + ) presentation_order = ( - effective_ids + default_presentation if self.presentation_order is None else _as_unique_ids(self.presentation_order) ) if set(presentation_order) != set(effective_ids): raise ValueError('Presentation order must contain exactly the effective IDs.') - if self.reference_id is not None and self.reference_id not in cluster_ids: - raise ValueError('The reference ID must belong to the cluster selection.') + if ( + presentation_order + and reference_id is not None + and presentation_order[0] != reference_id + ): + raise ValueError('The reference ID must occupy the first presentation slot.') object.__setattr__(self, 'cluster_ids', cluster_ids) object.__setattr__(self, 'similar_ids', similar_ids) + object.__setattr__(self, 'reference_id', reference_id) object.__setattr__(self, 'presentation_order', presentation_order) @property @@ -132,10 +147,10 @@ def restore(self, snapshot): return self._apply(snapshot) def set_cluster_selection(self, cluster_ids, reference_id=None): - """Set Cluster View IDs, using the final ID as the default reference.""" + """Set Cluster View IDs, using the first (blue) ID as the default reference.""" cluster_ids = _as_unique_ids(cluster_ids) if reference_id is None: - reference_id = cluster_ids[-1] if cluster_ids else None + reference_id = cluster_ids[0] if cluster_ids else None after = CurationSelectionState( cluster_ids=cluster_ids, similar_ids=self._state.similar_ids, @@ -143,6 +158,22 @@ def set_cluster_selection(self, cluster_ids, reference_id=None): ) return self._apply(after) + def set_normal_selection( + self, + cluster_ids, + similar_ids=(), + reference_id=None, + presentation_order=None, + ): + """Atomically replace all Normal-mode selection roles and presentation state.""" + after = CurationSelectionState( + cluster_ids=_as_unique_ids(cluster_ids), + similar_ids=_as_unique_ids(similar_ids), + reference_id=reference_id, + presentation_order=presentation_order, + ) + return self._apply(after) + def set_similarity_selection(self, similar_ids): """Set Similarity View IDs without changing the current reference.""" after = CurationSelectionState( @@ -163,6 +194,8 @@ def transfer_cluster_to_similarity(self, cluster_ids): current = self._state if not source_ids <= set(current.cluster_ids): raise ValueError('Transferred IDs must belong to the cluster selection.') + if current.reference_id in source_ids: + raise ValueError('The reference ID cannot move to the similarity selection.') remaining_clusters = tuple(i for i in current.cluster_ids if i not in source_ids) similar_ids = _ordered_union(current.similar_ids, cluster_ids) reference_id = ( diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 86b521b9..2e411a1a 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -419,11 +419,14 @@ def set_selected_index_offset(self, n): view.""" Table.set_selected_index_offset(self, n) - def reset(self, cluster_ids): - """Recreate the similarity view, given the selected clusters in the cluster view.""" + def reset(self, cluster_ids, reference_id=None): + """Recreate the view for an explicit reference and Cluster-role exclusions.""" if not len(cluster_ids): return - similar = emit('request_similar_clusters', self, cluster_ids[-1]) + reference_id = cluster_ids[-1] if reference_id is None else reference_id + if reference_id not in cluster_ids: + raise ValueError('The similarity reference must be selected in the Cluster View.') + similar = emit('request_similar_clusters', self, reference_id) # Clear the table. if similar: rows = [cl for cl in similar[0] if cl['id'] not in cluster_ids] @@ -983,16 +986,19 @@ def _clusters_selected(self, sender, obj, **kwargs): update_views is False.""" if sender != self.cluster_view: return + if obj.get('revision') not in (None, sender._selection_revision): + logger.debug('Ignoring stale Cluster View selection revision.') + return cluster_ids = obj['selected'] next_cluster = obj['next'] kwargs = obj.get('kwargs', {}) logger.debug('Clusters selected: %s (%s)', cluster_ids, next_cluster) - self.selection.set_cluster_selection(cluster_ids) - self.selection.clear_similarity_selection() + change = self.selection.set_normal_selection(cluster_ids) self.task_logger.log(self.cluster_view, 'select', cluster_ids, output=obj) # Update the similarity view when the cluster view selection changes. - self.similarity_view.reset(cluster_ids) - self.similarity_view.set_selected_index_offset(len(self.selected_clusters)) + self.similarity_view.reset(cluster_ids, reference_id=change.after.reference_id) + self.similarity_view.set_selected_ids(()) + self._update_selection_colors() # Emit supervisor.select event unless update_views is False. This happens after # a merge event, where the views should not be updated after the first cluster_view.select # event, but instead after the second similarity_view.select event. @@ -1007,17 +1013,52 @@ def _similar_selected(self, sender, obj): stack, and emit the global supervisor.select event.""" if sender != self.similarity_view: return + if obj.get('revision') not in (None, sender._selection_revision): + logger.debug('Ignoring stale Similarity View selection revision.') + return similar = obj['selected'] next_similar = obj['next'] kwargs = obj.get('kwargs', {}) logger.debug('Similar clusters selected: %s (%s)', similar, next_similar) self.selection.set_similarity_selection(similar) + self._update_selection_colors() self.task_logger.log(self.similarity_view, 'select', similar, output=obj) emit('select', self, self.selected, **kwargs) if similar: self.similarity_view.scroll_to(similar[-1]) self.similarity_view.dock.set_status(f'similar clusters: {", ".join(map(str, similar))}') + def _update_selection_colors(self): + """Project authoritative presentation positions into both role tables.""" + order = self.selection.state.presentation_order + self.cluster_view.set_selected_index_order(order) + self.similarity_view.set_selected_index_order(order) + + def _apply_selection_change(self, change, callback=None): + """Project one complete controller transition and publish it atomically.""" + state = change.after + cluster_payload = self.cluster_view.set_selected_ids(state.cluster_ids) + if state.cluster_ids: + self.similarity_view.reset(state.cluster_ids, reference_id=state.reference_id) + similar_payload = self.similarity_view.set_selected_ids(state.similar_ids) + self._update_selection_colors() + self.task_logger.log( + self.cluster_view, + 'select', + list(state.cluster_ids), + output=cluster_payload, + ) + self.task_logger.log( + self.similarity_view, + 'select', + list(state.similar_ids), + output=similar_payload, + ) + if change.presentation_changed: + emit('select', self, list(state.presentation_order)) + if callback: + self.cluster_view._schedule_callback(callback, state) + def _promote_similar_on_right_click(self, sender, cluster_id): """Promote a right-clicked similarity row through the normal action queue.""" emit('action', self.action_creator, 'promote_similar', cluster_id) @@ -1359,7 +1400,8 @@ def previous(self, callback=None): def unselect_similar(self, callback=None): """Select only the clusters in the cluster view.""" - self.cluster_view.select(self.selected_clusters, callback=callback) + change = self.selection.clear_similarity_selection() + self._apply_selection_change(change, callback=callback) def select_first_similar(self, n=None, callback=None): """Select the first N eligible clusters currently shown in the similarity view.""" @@ -1389,47 +1431,31 @@ def set_skip_masked_clusters(self, skip_masked, callback=None): def promote_similar(self, cluster_id, callback=None): """Move a similarity row into the cluster view while preserving all other selections.""" - cluster_ids = list(self.selected_clusters) - similar = [value for value in self.selected_similar if value != cluster_id] - - if not cluster_ids: - self.cluster_view.select([cluster_id], callback=callback) - return - - # Insert before the current anchor (the final cluster-view selection) so rebuilding the - # similarity view keeps the same reference cluster. - cluster_ids.insert(-1, cluster_id) - - def restore_similar(_): - self.similarity_view.select(similar, callback=callback) - - # Wait to update the other views until the remaining similarity selection is restored. - self.cluster_view.select( - cluster_ids, - callback=restore_similar, - update_views=False, - ) + state = self.selection.state + if cluster_id in state.similar_ids: + change = self.selection.transfer_similarity_to_cluster((cluster_id,)) + elif cluster_id not in state.cluster_ids: + cluster_ids = list(state.cluster_ids) + cluster_ids.append(cluster_id) + change = self.selection.set_normal_selection( + cluster_ids, + state.similar_ids, + reference_id=state.reference_id or cluster_id, + presentation_order=(*state.presentation_order, cluster_id), + ) + else: + change = self.selection.restore(state) + self._apply_selection_change(change, callback=callback) def demote_cluster(self, cluster_id, callback=None): """Move a selected cluster row into the similarity view.""" - cluster_ids = list(self.selected_clusters) - if cluster_id not in cluster_ids or len(cluster_ids) == 1: + state = self.selection.state + if cluster_id not in state.cluster_ids or cluster_id == state.reference_id: if callback: callback(None) return - - cluster_ids.remove(cluster_id) - similar = [value for value in self.selected_similar if value != cluster_id] - similar.append(cluster_id) - - def restore_similar(_): - self.similarity_view.select(similar, callback=callback) - - self.cluster_view.select( - cluster_ids, - callback=restore_similar, - update_views=False, - ) + change = self.selection.transfer_cluster_to_similarity((cluster_id,)) + self._apply_selection_change(change, callback=callback) def toggle_cluster_selection(self, cluster_id, callback=None): """Add or remove a cluster from the cluster-view selection.""" diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index 044e6de4..a858ca3b 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -25,6 +25,12 @@ def test_state_rejects_invalid_ids_reference_and_presentation(): CurationSelectionState(cluster_ids=(1,), reference_id=2) with raises(ValueError, match='exactly'): CurationSelectionState(cluster_ids=(1,), presentation_order=(2,)) + with raises(ValueError, match='first presentation'): + CurationSelectionState( + cluster_ids=(1, 2), + reference_id=2, + presentation_order=(1, 2), + ) with raises(ValueError, match='Normal-mode'): CurationSelectionState(mode=WorkflowMode.MERGE) @@ -36,20 +42,21 @@ def test_state_is_immutable(): state.reference_id = 2 -def test_set_cluster_selection_uses_last_id_or_explicit_reference(): +def test_set_cluster_selection_uses_blue_first_id_or_explicit_reference(): controller = CurationSelectionController() change = controller.set_cluster_selection((3, 1)) - assert change.after.reference_id == 1 + assert change.after.reference_id == 3 assert change.presentation_changed assert change.reference_changed change = controller.set_cluster_selection((1, 2)) - assert change.after.reference_id == 2 + assert change.after.reference_id == 1 assert change.after.presentation_order == (1, 2) - change = controller.set_cluster_selection((1, 2), reference_id=1) - assert change.after.reference_id == 1 + change = controller.set_cluster_selection((1, 2), reference_id=2) + assert change.after.reference_id == 2 + assert change.after.presentation_order == (2, 1) def test_set_similarity_and_clear_similarity_selection(): @@ -69,6 +76,17 @@ def test_set_similarity_and_clear_similarity_selection(): assert change.after.presentation_order == (1,) +def test_set_normal_selection_replaces_all_roles_atomically(): + controller = CurationSelectionController() + + change = controller.set_normal_selection((3, 1), (2,), reference_id=1) + + assert change.after.cluster_ids == (3, 1) + assert change.after.similar_ids == (2,) + assert change.after.reference_id == 1 + assert change.after.presentation_order == (1, 3, 2) + + def test_role_transfers_leave_effective_presentation_unchanged(): controller = CurationSelectionController( CurationSelectionState( @@ -103,6 +121,8 @@ def test_role_transfer_rejects_ids_not_in_the_source_selection(): controller.transfer_cluster_to_similarity((2,)) with raises(ValueError, match='similarity selection'): controller.transfer_similarity_to_cluster((1,)) + with raises(ValueError, match='reference'): + controller.transfer_cluster_to_similarity((1,)) def test_snapshot_restore_and_noop_change_classification(): diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 68c9b3f0..94b38e90 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -394,19 +394,59 @@ def test_selection_shadow_tracks_cross_view_transfers(supervisor): supervisor.promote_similar(11) supervisor.block() - assert supervisor.selection.state.cluster_ids == (10, 11, 30) + assert supervisor.selection.state.cluster_ids == (10, 30, 11) assert supervisor.selection.state.similar_ids == (20, 1) - assert supervisor.selection.state.reference_id == 30 + assert supervisor.selection.state.reference_id == 10 assert supervisor.selection.state.presentation_order == tuple(supervisor.selected) - supervisor.demote_cluster(10) + supervisor.demote_cluster(30) supervisor.block() - assert supervisor.selection.state.cluster_ids == (11, 30) - assert supervisor.selection.state.similar_ids == (20, 1, 10) - assert supervisor.selection.state.reference_id == 30 + assert supervisor.selection.state.cluster_ids == (10, 11) + assert supervisor.selection.state.similar_ids == (20, 1, 30) + assert supervisor.selection.state.reference_id == 10 assert supervisor.selection.state.presentation_order == tuple(supervisor.selected) +def test_cross_view_role_transfers_preserve_public_selection_and_colors(supervisor): + _select(supervisor, [10, 30], [20, 11]) + events = [] + + @connect(sender=supervisor) + def on_select(sender, cluster_ids): + events.append(cluster_ids) + + supervisor.promote_similar(11) + supervisor.block() + supervisor.demote_cluster(30) + supervisor.block() + + assert events == [] + assert supervisor.selected == [10, 30, 20, 11] + assert supervisor.cluster_view._selected_color_index(10) == 0 + assert supervisor.similarity_view._selected_color_index(30) == 1 + assert supervisor.similarity_view._selected_color_index(20) == 2 + assert supervisor.cluster_view._selected_color_index(11) == 3 + + unconnect(on_select) + + +def test_stale_table_selection_revision_is_ignored(supervisor): + _select(supervisor, [10], [20]) + state = supervisor.selection.state + + supervisor._clusters_selected( + supervisor.cluster_view, + { + 'selected': [30], + 'next': None, + 'kwargs': {}, + 'revision': supervisor.cluster_view._selection_revision - 1, + }, + ) + + assert supervisor.selection.state is state + + def test_block_flushes_pending_selections(qtbot, supervisor): supervisor.cluster_view.debouncer.delay = 60_000 supervisor.similarity_view.debouncer.delay = 60_000 @@ -500,7 +540,7 @@ def test_supervisor_select_order(qtbot, supervisor): _assert_selected(supervisor, [0, 1]) -def test_supervisor_multi_cluster_similarity_reference_and_positional_colors(supervisor): +def test_supervisor_multi_cluster_reference_is_explicit_and_blue(supervisor): requested = [] @connect(sender=supervisor.similarity_view) @@ -509,9 +549,9 @@ def on_request_similar_clusters(sender, cluster_id): _select(supervisor, [10, 30], [20]) - # Similarity uses the last Cluster View row as its reference, whereas the - # first selected cluster owns the blue positional color slot. - assert requested == [30] + # The first Cluster View row is the explicit Similarity reference and owns + # the blue positional color slot. + assert requested == [10] assert supervisor.selected == [10, 30, 20] def rgb(color): @@ -744,9 +784,9 @@ def test_supervisor_promote_similar_with_control_right_click(qtbot, supervisor): ) supervisor.block() - assert supervisor.selected_clusters == [10, 11, 30] + assert supervisor.selected_clusters == [10, 30, 11] assert supervisor.selected_similar == [20, 1] - assert supervisor.selected == [10, 11, 30, 20, 1] + assert supervisor.selected == [10, 30, 20, 11, 1] assert 11 not in similarity_view.get_ids() @@ -762,7 +802,7 @@ def test_supervisor_promote_unselected_similar_with_control_right_click(qtbot, s ) supervisor.block() - assert supervisor.selected_clusters == [1, 30] + assert supervisor.selected_clusters == [30, 1] assert supervisor.selected_similar == [20, 11] @@ -771,23 +811,23 @@ def test_supervisor_demote_cluster_with_control_right_click(qtbot, supervisor): cluster_view = supervisor.cluster_view control_modifier = Qt.MetaModifier if sys.platform == 'darwin' else Qt.ControlModifier - index = cluster_view._proxy_index_for_id(10) + index = cluster_view._proxy_index_for_id(30) pos = cluster_view.table_view.visualRect(index).center() qtbot.mouseClick(cluster_view.table_view.viewport(), Qt.RightButton, control_modifier, pos=pos) supervisor.block() - assert supervisor.selected_clusters == [30] - assert supervisor.selected_similar == [20, 11, 10] - assert supervisor.selected == [30, 20, 11, 10] + assert supervisor.selected_clusters == [10] + assert supervisor.selected_similar == [20, 11, 30] + assert supervisor.selected == [10, 30, 20, 11] - index = cluster_view._proxy_index_for_id(30) + index = cluster_view._proxy_index_for_id(10) pos = cluster_view.table_view.visualRect(index).center() qtbot.mouseClick(cluster_view.table_view.viewport(), Qt.RightButton, control_modifier, pos=pos) supervisor.block() # Keep one cluster as the similarity reference. - assert supervisor.selected_clusters == [30] - assert supervisor.selected_similar == [20, 11, 10] + assert supervisor.selected_clusters == [10] + assert supervisor.selected_similar == [20, 11, 30] index = cluster_view._proxy_index_for_id(1) pos = cluster_view.table_view.visualRect(index).center() @@ -795,8 +835,8 @@ def test_supervisor_demote_cluster_with_control_right_click(qtbot, supervisor): supervisor.block() # Rows outside the Cluster View selection cannot be transferred. - assert supervisor.selected_clusters == [30] - assert supervisor.selected_similar == [20, 11, 10] + assert supervisor.selected_clusters == [10] + assert supervisor.selected_similar == [20, 11, 30] def test_supervisor_control_left_click_toggles_selection_in_each_view(qtbot, supervisor): diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index 12ae278a..cdf5acd7 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -546,6 +546,8 @@ def __init__( self.data = list(data or []) self._selected_ids = [] self._selected_index_offset = 0 + self._selected_index_by_id = None + self._selection_revision = 0 self._filter_text = '' self._filter_is_active = False self._current_sort = None @@ -827,14 +829,14 @@ def _selected_visible_ids(self): def _selection_background(self, row_id): if row_id not in self._selected_ids: return None - pos = self._selected_ids.index(row_id) + self._selected_index_offset + pos = self._selected_color_index(row_id) colors = list(colormaps.default * 255) r, g, b = colors[pos % len(colors)] return QColor(int(r), int(g), int(b), 160) def _foreground_color(self, row, column): if column == 'id' and row.get('id') in self._selected_ids: - pos = self._selected_ids.index(row.get('id')) + self._selected_index_offset + pos = self._selected_color_index(row.get('id')) colors = list(colormaps.default * 255) r, g, b = colors[pos % len(colors)] if _is_bright((int(r), int(g), int(b))): @@ -846,6 +848,11 @@ def _foreground_color(self, row, column): return QColor('#888888') return None + def _selected_color_index(self, row_id): + if self._selected_index_by_id is not None and row_id in self._selected_index_by_id: + return self._selected_index_by_id[row_id] + return self._selected_ids.index(row_id) + self._selected_index_offset + def _refresh_selection(self): selection_model = self.table_view.selectionModel() if selection_model is None: @@ -864,9 +871,15 @@ def _refresh_selection(self): def _selected_payload(self, kwargs=None): selected = self.get_selected_ids() next_id = self.get_sibling_id(selected[-1] if selected else None, 'next') - return {'selected': selected, 'next': next_id, 'kwargs': kwargs or {}} + return { + 'selected': selected, + 'next': next_id, + 'kwargs': kwargs or {}, + 'revision': self._selection_revision, + } def _emit_selected(self, kwargs=None): + self._selection_revision += 1 payload = self._selected_payload(kwargs) self._emit_event('select', payload) return payload @@ -1036,6 +1049,7 @@ def set_selected_ids(self, ids): assert all(_is_integer(_) for _ in ids) visible = set(self._visible_ids()) self._selected_ids = [row_id for row_id in ids if row_id in visible] + self._selection_revision += 1 self._refresh_selection() return self._selected_payload() @@ -1166,6 +1180,12 @@ def get_current_sort(self, callback=None): def set_selected_index_offset(self, n): self._selected_index_offset = n + self._selected_index_by_id = None + self.table_view.viewport().update() + + def set_selected_index_order(self, ids): + """Set stable positional-color indices independently of table-role order.""" + self._selected_index_by_id = {row_id: index for index, row_id in enumerate(_uniq(ids))} self.table_view.viewport().update() def clear_temporary_files(self): From e05f85ec7cf68a8a0b077096665658ecd51d8470 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 01:03:26 +0200 Subject: [PATCH 007/110] refactor: remove selection state from task history --- phy/cluster/supervisor.py | 175 +++++++++++++++------------ phy/cluster/tests/test_supervisor.py | 88 +++++--------- phy/gui/widgets.py | 10 ++ 3 files changed, 135 insertions(+), 138 deletions(-) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 2e411a1a..31564219 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -9,6 +9,7 @@ import logging import sys from contextlib import ExitStack +from dataclasses import dataclass from functools import partial from numbers import Integral @@ -60,6 +61,22 @@ def _ensure_all_ints(l): # ----------------------------------------------------------------------------- +@dataclass(frozen=True) +class QueuedTask: + """One callback-compatible action with its explicit pre-action selection.""" + + sender: object + name: str + args: tuple + kwargs: dict + selection_before: object = None + next_similar_before: int | None = None + + def __iter__(self): + # Preserve the long-standing internal four-value task unpacking contract. + return iter((self.sender, self.name, self.args, self.kwargs)) + + class TaskLogger: """Internal object that gandles all clustering actions and the automatic actions that should follow as part of the "wizard".""" @@ -89,7 +106,21 @@ def enqueue(self, sender, name, *args, output=None, **kwargs): kwargs, output, ) - self._queue.append((sender, name, args, kwargs)) + selection = getattr(self.supervisor, 'selection', None) + selection_before = selection.snapshot() if selection is not None else None + next_similar_before = None + if self.similarity_view is not None and hasattr(self.similarity_view, '_selected_payload'): + next_similar_before = self.similarity_view._selected_payload()['next'] + self._queue.append( + QueuedTask( + sender=sender, + name=name, + args=args, + kwargs=kwargs, + selection_before=selection_before, + next_similar_before=next_similar_before, + ) + ) def dequeue(self): """Dequeue the oldest item in the queue.""" @@ -148,67 +179,26 @@ def enqueue_after(self, task, output): def _after_merge(self, task, output): """Tasks that should follow a merge.""" - merged, to = output.deleted, output.added[0] - cluster_ids, next_cluster, similar, next_similar = self.last_state() - # Update views after cluster_view.select event only if there is no similar clusters. - # Otherwise, this is only the similarity_view that will raise the select event leading - # to view updates. - do_select_new = self.auto_select_after_action and similar is not None - self.enqueue(self.cluster_view, 'select', [to], update_views=not do_select_new) - if do_select_new: # pragma: no cover - if set(merged).intersection(similar) and next_similar is not None: - similar = [next_similar] - self.enqueue(self.similarity_view, 'select', similar) + self.supervisor._select_after_merge( + output, + task.selection_before, + auto_select=self.auto_select_after_action, + next_similar=task.next_similar_before, + ) def _after_split(self, task, output): """Tasks that should follow a split.""" - self.enqueue(self.cluster_view, 'select', output.added) - - def _get_clusters(self, which): - cluster_ids, next_cluster, similar, next_similar = self.last_state() - if which == 'all': - return _uniq(cluster_ids + similar) - elif which == 'best': - return cluster_ids - elif which == 'similar': - return similar - return which + self.supervisor._select_after_split(output) def _after_move(self, task, output): """Tasks that should follow a move.""" - which = output.metadata_changed - moved = set(self._get_clusters(which)) - cluster_ids, next_cluster, similar, next_similar = self.last_state() - cluster_ids = set(cluster_ids or ()) - similar = set(similar or ()) - # Move best. - if moved <= cluster_ids: - self.enqueue(self.cluster_view, 'next') - # Move similar. - elif moved <= similar: - self.enqueue(self.similarity_view, 'next') - # Move all. - else: - self.enqueue(self.cluster_view, 'next') - self.enqueue(self.similarity_view, 'next') + self.supervisor._select_after_move(task.selection_before, output.metadata_changed) def _after_undo(self, task, output): - """Task that should follow an undo.""" - last_action = self.last_task(name_not_in=('select', 'next', 'previous', 'undo', 'redo')) - self._select_state(self.last_state(last_action)) + """Selection restoration is owned by contextual GlobalHistory entries.""" def _after_redo(self, task, output): - """Task that should follow an redo.""" - last_undo = self.last_task('undo') - # Select the last state before the last undo. - self._select_state(self.last_state(last_undo)) - - def _select_state(self, state): - """Enqueue select actions when a state (selected clusters and similar clusters) is set.""" - cluster_ids, next_cluster, similar, next_similar = state - self.enqueue(self.cluster_view, 'select', cluster_ids, update_views=not similar) - if similar: - self.enqueue(self.similarity_view, 'select', similar) + """Selection restoration is owned by contextual GlobalHistory entries.""" def _log(self, task, output): """Add a completed task to the history stack.""" @@ -241,31 +231,6 @@ def last_task(self, name=None, name_not_in=()): assert name_ return (sender, name_, args, kwargs, output) - def last_state(self, task=None): - """Return (cluster_ids, next_cluster, similar, next_similar).""" - cluster_state = (None, None) - similarity_state = (None, None) - h = self._history - # Last state until the passed task, if applicable. - if task: - i = self._history.index(task) - h = self._history[:i] - for sender, name, args, kwargs, output in reversed(h): - # Last selection is cluster view selection: return the state. - if ( - sender == self.similarity_view - and similarity_state == (None, None) - and name in ('select', 'next', 'previous') - ): - similarity_state = (output['selected'], output['next']) if output else (None, None) - if ( - sender == self.cluster_view - and cluster_state == (None, None) - and name in ('select', 'next', 'previous') - ): - cluster_state = (output['selected'], output['next']) if output else (None, None) - return (*cluster_state, *similarity_state) - def show_history(self): """Show the history stack.""" print('=== History ===') @@ -1059,6 +1024,56 @@ def _apply_selection_change(self, change, callback=None): if callback: self.cluster_view._schedule_callback(callback, state) + def _select_after_merge( + self, + up, + selection_before, + *, + auto_select=False, + next_similar=None, + ): + """Apply the settled post-merge selection from an explicit before snapshot.""" + similar_ids = () + if auto_select and selection_before is not None: # pragma: no cover + similar_ids = selection_before.similar_ids + if set(up.deleted).intersection(similar_ids) and next_similar is not None: + similar_ids = (next_similar,) + reference_id = up.added[0] + self.similarity_view.reset((reference_id,), reference_id=reference_id) + visible = set(self.similarity_view.get_ids()) + similar_ids = tuple(cluster_id for cluster_id in similar_ids if cluster_id in visible) + change = self.selection.set_normal_selection((up.added[0],), similar_ids) + self._apply_selection_change(change) + + def _select_after_split(self, up): + """Select all clusters created by a split as one settled transition.""" + change = self.selection.set_normal_selection(tuple(up.added)) + self._apply_selection_change(change) + + def _select_after_move(self, selection_before, moved_cluster_ids): + """Apply wizard navigation after metadata changes without task-log reconstruction.""" + if selection_before is None: + return + moved = set(moved_cluster_ids) + cluster_ids = set(selection_before.cluster_ids) + similar_ids = set(selection_before.similar_ids) + + if moved <= cluster_ids: + next_clusters = self.cluster_view.selection_after_navigation() + next_similar = () + elif moved <= similar_ids: + next_clusters = selection_before.cluster_ids + next_similar = self.similarity_view.selection_after_navigation() + else: + next_clusters = self.cluster_view.selection_after_navigation() + if next_clusters: + reference_id = next_clusters[0] + self.similarity_view.reset(next_clusters, reference_id=reference_id) + next_similar = self.similarity_view.selection_after_navigation() + + change = self.selection.set_normal_selection(next_clusters, next_similar) + self._apply_selection_change(change) + def _promote_similar_on_right_click(self, sender, cluster_id): """Promote a right-clicked similarity row through the normal action queue.""" emit('action', self.action_creator, 'promote_similar', cluster_id) @@ -1297,6 +1312,7 @@ def merge(self, cluster_ids=None, to=None): cluster_ids = self.selected if len(cluster_ids or []) <= 1: return + selection_before = self.selection.snapshot() # A merge synchronously emits several related table mutations: metadata # inheritance, addition of the merged cluster, and removal of its # ancestors. Fit each attached table once after the complete operation @@ -1307,6 +1323,8 @@ def merge(self, cluster_ids=None, to=None): if table is not None: stack.enter_context(table.batch_update()) out = self.clustering.merge(cluster_ids, to=to) + if not getattr(getattr(self, 'task_logger', None), '_processing', False): + self._select_after_merge(out, selection_before) self._global_history.action(self.clustering) return out @@ -1321,7 +1339,10 @@ def split(self, spike_ids=None, spike_clusters_rel=0): if len(spike_ids) == 0: logger.warning("""No spikes selected, cannot split.""") return + task_logger = getattr(self, 'task_logger', None) out = self.clustering.split(spike_ids, spike_clusters_rel=spike_clusters_rel) + if not getattr(task_logger, '_processing', False): + self._select_after_split(out) self._global_history.action(self.clustering) return out diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 94b38e90..827e6644 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -103,6 +103,8 @@ class MockSimilarityView(MockClusterView): pass class MockSupervisor: + post_actions = None + def merge(self, cluster_ids, to, callback=None): callback(Bunch(deleted=cluster_ids, added=[to])) @@ -118,87 +120,52 @@ def undo(self, callback=None): def redo(self, callback=None): callback(Bunch()) - out = TaskLogger(MockClusterView(), MockSimilarityView(), MockSupervisor()) - - return out + def _select_after_merge(self, output, selection_before, **kwargs): + self.post_actions = ('merge', output, selection_before, kwargs) + def _select_after_split(self, output): + self.post_actions = ('split', output) -def test_task_1(tl): - assert tl.last_state(None) is None + def _select_after_move(self, selection_before, cluster_ids): + self.post_actions = ('move', selection_before, cluster_ids) + out = TaskLogger(MockClusterView(), MockSimilarityView(), MockSupervisor()) -def test_task_2(tl): - tl.enqueue(tl.cluster_view, 'select', [0]) - tl.process() - assert tl.last_state() == ([0], 1, None, None) + return out -def test_task_3(tl): +def test_task_logger_runs_callback_compatible_table_task(tl): tl.enqueue(tl.cluster_view, 'select', [0]) - tl.enqueue(tl.similarity_view, 'select', [100]) tl.process() - assert tl.last_state() == ([0], 1, [100], 101) + assert tl._history[-1][1] == 'select' + assert tl._history[-1][-1] == {'selected': [0], 'next': 1} -def test_task_merge(tl): - tl.enqueue(tl.cluster_view, 'select', [0]) - tl.enqueue(tl.similarity_view, 'select', [100]) +def test_task_logger_delegates_merge_follow_up(tl): tl.enqueue(tl.supervisor, 'merge', [0, 100], 1000) tl.process() - assert tl.last_state() == ([1000], 1001, None, None) - - tl.enqueue(tl.supervisor, 'undo') - tl.process() - assert tl.last_state() == ([0], 1, [100], 101) - - tl.enqueue(tl.supervisor, 'redo') - tl.process() - assert tl.last_state() == ([1000], 1001, None, None) + name, output, selection_before, kwargs = tl.supervisor.post_actions + assert name == 'merge' + assert output.added == [1000] + assert selection_before is None + assert kwargs['auto_select'] is False -def test_task_split(tl): - tl.enqueue(tl.cluster_view, 'select', [0]) - tl.enqueue(tl.similarity_view, 'select', [100]) +def test_task_logger_delegates_split_follow_up(tl): tl.enqueue(tl.supervisor, 'split', [0, 100], [1000, 1001]) tl.process() - assert tl.last_state() == ([1000, 1001], 1002, None, None) + name, output = tl.supervisor.post_actions + assert name == 'split' + assert output.added == [1000, 1001] -def test_task_move_1(tl): - tl.enqueue(tl.cluster_view, 'select', [0]) +def test_task_logger_delegates_move_follow_up(tl): tl.enqueue(tl.supervisor, 'move', [0], 'good') tl.process() - assert tl.last_state() == ([1], 2, None, None) - - -def test_task_move_best(tl): - tl.enqueue(tl.cluster_view, 'select', [0]) - tl.enqueue(tl.similarity_view, 'select', [100]) - tl.enqueue(tl.supervisor, 'move', 'best', 'good') - tl.process() - - assert tl.last_state() == ([1], 2, None, None) - - -def test_task_move_similar(tl): - tl.enqueue(tl.cluster_view, 'select', [0]) - tl.enqueue(tl.similarity_view, 'select', [100]) - tl.enqueue(tl.supervisor, 'move', 'similar', 'good') - tl.process() - - assert tl.last_state() == ([0], 1, [101], 102) - - -def test_task_move_all(tl): - tl.enqueue(tl.cluster_view, 'select', [0]) - tl.enqueue(tl.similarity_view, 'select', [100]) - tl.enqueue(tl.supervisor, 'move', 'all', 'good') - tl.process() - - assert tl.last_state() == ([1], 2, [101], 102) + assert tl.supervisor.post_actions == ('move', None, [0]) # ------------------------------------------------------------------------------ @@ -361,9 +328,8 @@ def _select(supervisor, cluster_ids, similar=None): supervisor.task_logger.process() supervisor.block() supervisor.task_logger.show_history() - - assert supervisor.task_logger.last_state()[0] == cluster_ids - assert supervisor.task_logger.last_state()[2] == similar + assert supervisor.selected_clusters == cluster_ids + assert supervisor.selected_similar == (similar or []) def _assert_selected(supervisor, sel): diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index cdf5acd7..a6884e55 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -1026,6 +1026,16 @@ def get_next_id(self, callback=None): def get_previous_id(self, callback=None): return self._async_return(self.get_sibling_id(None, 'previous'), callback) + def selection_after_navigation(self, direction='next'): + """Return the row selection produced by navigation without mutating the table.""" + if direction not in ('next', 'previous'): + raise ValueError("Direction must be 'next' or 'previous'.") + if not self.get_selected_ids(): + navigable = self._visible_navigable_ids() + return navigable[:1] + row_id = self.get_sibling_id(direction=direction) + return [row_id] if row_id is not None else [] + def first(self, callback=None): return self._async_return(self._select_first_or_last('first'), callback) From 450a85948d3ca3228197066f93edfc5763cb4e76 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 01:05:59 +0200 Subject: [PATCH 008/110] refactor: add contextual curation history --- docs/api.md | 85 +++++++++++++++++++++++++++- docs/changelog.md | 6 ++ phy/cluster/_history.py | 66 +++++++++++++++++---- phy/cluster/_selection.py | 6 +- phy/cluster/supervisor.py | 39 +++++++++++-- phy/cluster/tests/test_history.py | 44 ++++++++++++++ phy/cluster/tests/test_selection.py | 12 ++++ phy/cluster/tests/test_supervisor.py | 32 +++++++++++ 8 files changed, 271 insertions(+), 19 deletions(-) diff --git a/docs/api.md b/docs/api.md index a07a0e1a..7984a43f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1946,6 +1946,15 @@ minimumSizeHint(self) -> QSize +--- + +#### Table.selection_after_navigation + + +**`Table.selection_after_navigation(self, direction='next')`** + +Return the row selection produced by navigation without mutating the table. + --- #### Table.set_busy @@ -1955,6 +1964,15 @@ minimumSizeHint(self) -> QSize +--- + +#### Table.set_selected_ids + + +**`Table.set_selected_ids(self, ids)`** + +Project selected row IDs without emitting a selection event. + --- #### Table.set_selected_index_offset @@ -1964,6 +1982,15 @@ minimumSizeHint(self) -> QSize +--- + +#### Table.set_selected_index_order + + +**`Table.set_selected_index_order(self, ids)`** + +Set stable positional-color indices independently of table-role order. + --- #### Table.sizeHint @@ -6791,6 +6818,15 @@ minimumSizeHint(self) -> QSize +--- + +#### ClusterView.selection_after_navigation + + +**`ClusterView.selection_after_navigation(self, direction='next')`** + +Return the row selection produced by navigation without mutating the table. + --- #### ClusterView.set_busy @@ -6800,6 +6836,15 @@ minimumSizeHint(self) -> QSize +--- + +#### ClusterView.set_selected_ids + + +**`ClusterView.set_selected_ids(self, ids)`** + +Project selected row IDs without emitting a selection event. + --- #### ClusterView.set_selected_index_offset @@ -6809,6 +6854,15 @@ minimumSizeHint(self) -> QSize +--- + +#### ClusterView.set_selected_index_order + + +**`ClusterView.set_selected_index_order(self, ids)`** + +Set stable positional-color indices independently of table-role order. + --- #### ClusterView.set_state @@ -9508,9 +9562,9 @@ minimumSizeHint(self) -> QSize #### SimilarityView.reset -**`SimilarityView.reset(self, cluster_ids)`** +**`SimilarityView.reset(self, cluster_ids, reference_id=None)`** -Recreate the similarity view, given the selected clusters in the cluster view. +Recreate the view for an explicit reference and Cluster-role exclusions. --- @@ -9548,6 +9602,15 @@ Recreate the similarity view, given the selected clusters in the cluster view. +--- + +#### SimilarityView.selection_after_navigation + + +**`SimilarityView.selection_after_navigation(self, direction='next')`** + +Return the row selection produced by navigation without mutating the table. + --- #### SimilarityView.set_busy @@ -9557,6 +9620,15 @@ Recreate the similarity view, given the selected clusters in the cluster view. +--- + +#### SimilarityView.set_selected_ids + + +**`SimilarityView.set_selected_ids(self, ids)`** + +Project selected row IDs without emitting a selection event. + --- #### SimilarityView.set_selected_index_offset @@ -9569,6 +9641,15 @@ view. --- +#### SimilarityView.set_selected_index_order + + +**`SimilarityView.set_selected_index_order(self, ids)`** + +Set stable positional-color indices independently of table-role order. + +--- + #### SimilarityView.set_state diff --git a/docs/changelog.md b/docs/changelog.md index 4ca30298..372e1e58 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -53,6 +53,12 @@ behavior they verify rather than listed separately. ### Changed +- The first, blue Cluster View selection is now the explicit Similarity + reference. Moving non-reference rows between Cluster and Similarity roles + preserves their presentation order, colors, and scientific-view selection. +- Undo and redo restore the complete selection context around merge, split, + and metadata actions; redo also preserves selection-only exploration made + after the original action. - Merge and assignment operations update the small cluster-ID collection incrementally instead of rescanning every spike. - Merges gather their spikes from the maintained per-cluster arrays while diff --git a/phy/cluster/_history.py b/phy/cluster/_history.py index c1ba5ae6..0038645c 100644 --- a/phy/cluster/_history.py +++ b/phy/cluster/_history.py @@ -1,5 +1,7 @@ """History class for undo stack.""" +from dataclasses import dataclass, replace + # ------------------------------------------------------------------------------ # Imports # ------------------------------------------------------------------------------ @@ -125,21 +127,59 @@ def redo(self): return self.forward() +@dataclass(frozen=True) +class CurationHistoryEntry: + """Reversible data controllers plus the curation context around an action.""" + + controllers: tuple = () + description: str = '' + selection_before: object = None + selection_after: object = None + workflow_context: object = None + + class GlobalHistory(History): """Merge several controllers with different undo stacks.""" - def __init__(self, process_ups=None): - super().__init__(()) + def __init__(self, process_ups=None, restore_context=None): + super().__init__(CurationHistoryEntry()) self.process_ups = process_ups - - def action(self, *controllers): + self.restore_context = restore_context + + def action( + self, + *controllers, + description='', + selection_before=None, + selection_after=None, + workflow_context=None, + ): """Register one or several controllers for this action.""" - self.add(tuple(controllers)) + self.add( + CurationHistoryEntry( + controllers=tuple(controllers), + description=description, + selection_before=selection_before, + selection_after=selection_after, + workflow_context=workflow_context, + ) + ) def add_to_current_action(self, controller): """Add a controller to the current action.""" item = self.current_item - self._history[self._index] = item + (controller,) + self._history[self._index] = replace( + item, + controllers=item.controllers + (controller,), + ) + + def update_current_context(self, **kwargs): + """Update contextual fields on the current action before undoing it.""" + self._history[self._index] = replace(self.current_item, **kwargs) + + def _restore(self, entry, selection, direction): + if self.restore_context is not None and selection is not None: + self.restore_context(selection, entry.workflow_context, direction) def undo(self): """Undo the last action. @@ -147,11 +187,12 @@ def undo(self): This will call `undo()` on all controllers involved in this action. """ - controllers = self.back() - if controllers is None: + entry = self.back() + if entry is None: ups = () else: - ups = tuple([controller.undo() for controller in controllers]) + ups = tuple(controller.undo() for controller in entry.controllers) + self._restore(entry, entry.selection_before, 'undo') if self.process_ups is not None: return self.process_ups(ups) else: @@ -163,11 +204,12 @@ def redo(self): This will call `redo()` on all controllers involved in this action. """ - controllers = self.forward() - if controllers is None: + entry = self.forward() + if entry is None: ups = () else: - ups = tuple([controller.redo() for controller in controllers]) + ups = tuple(controller.redo() for controller in entry.controllers) + self._restore(entry, entry.selection_after, 'redo') if self.process_ups is not None: return self.process_ups(ups) else: diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index 455366b4..5cd04049 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -220,7 +220,11 @@ def transfer_similarity_to_cluster(self, cluster_ids): raise ValueError('Transferred IDs must belong to the similarity selection.') similar_ids = tuple(i for i in current.similar_ids if i not in source_ids) cluster_selection = _ordered_union(current.cluster_ids, cluster_ids) - reference_id = current.reference_id or (cluster_ids[-1] if cluster_ids else None) + reference_id = ( + current.reference_id + if current.reference_id is not None + else (cluster_ids[0] if cluster_ids else None) + ) after = CurationSelectionState( cluster_ids=cluster_selection, similar_ids=similar_ids, diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 31564219..e3b19330 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -733,7 +733,10 @@ def __init__( self.cluster_meta.set(label, [cl], v, add_to_stack=False) # Create the GlobalHistory instance. - self._global_history = GlobalHistory(process_ups=_process_ups) + self._global_history = GlobalHistory( + process_ups=_process_ups, + restore_context=self._restore_history_context, + ) # Create The Action Creator instance. self.action_creator = ActionCreator(self) @@ -1024,6 +1027,11 @@ def _apply_selection_change(self, change, callback=None): if callback: self.cluster_view._schedule_callback(callback, state) + def _restore_history_context(self, selection, workflow_context, direction): + """Restore a curation snapshot after the associated data undo or redo.""" + change = self.selection.restore(selection) + self._apply_selection_change(change) + def _select_after_merge( self, up, @@ -1325,7 +1333,12 @@ def merge(self, cluster_ids=None, to=None): out = self.clustering.merge(cluster_ids, to=to) if not getattr(getattr(self, 'task_logger', None), '_processing', False): self._select_after_merge(out, selection_before) - self._global_history.action(self.clustering) + self._global_history.action( + self.clustering, + description='merge', + selection_before=selection_before, + selection_after=self.selection.snapshot(), + ) return out def split(self, spike_ids=None, spike_clusters_rel=0): @@ -1339,11 +1352,17 @@ def split(self, spike_ids=None, spike_clusters_rel=0): if len(spike_ids) == 0: logger.warning("""No spikes selected, cannot split.""") return + selection_before = self.selection.snapshot() task_logger = getattr(self, 'task_logger', None) out = self.clustering.split(spike_ids, spike_clusters_rel=spike_clusters_rel) if not getattr(task_logger, '_processing', False): self._select_after_split(out) - self._global_history.action(self.clustering) + self._global_history.action( + self.clustering, + description='split', + selection_before=selection_before, + selection_after=self.selection.snapshot(), + ) return out # Move actions @@ -1366,8 +1385,14 @@ def label(self, name, value, cluster_ids=None): cluster_ids = [cluster_ids] if len(cluster_ids) == 0: return + selection_before = self.selection.snapshot() self.cluster_meta.set(name, cluster_ids, value) - self._global_history.action(self.cluster_meta) + self._global_history.action( + self.cluster_meta, + description=f'label:{name}', + selection_before=selection_before, + selection_after=self.selection.snapshot(), + ) # Add column if needed. if name != 'group' and name not in self.columns: logger.debug('Add column %s.', name) @@ -1504,6 +1529,12 @@ def is_dirty(self): def undo(self): """Undo the last action.""" + # Selection-only exploration does not create history entries. Preserve the exact + # state at the time undo is requested so redo remains a true inverse operation. + if self._global_history.current_position > 0: + self._global_history.update_current_context( + selection_after=self.selection.snapshot(), + ) self._global_history.undo() def redo(self): diff --git a/phy/cluster/tests/test_history.py b/phy/cluster/tests/test_history.py index 6c6aa5ac..84067d2f 100644 --- a/phy/cluster/tests/test_history.py +++ b/phy/cluster/tests/test_history.py @@ -140,3 +140,47 @@ def test_global_history(): assert gh.redo() == 'h1 first' assert gh.redo() == 'h2 first' assert gh.redo() == 'h1 secondh2 second' + + +def test_global_history_restores_context_after_controllers_and_preserves_it_on_extension(): + calls = [] + + class Controller(History): + def undo(self): + calls.append('controller undo') + return super().undo() + + def redo(self): + calls.append('controller redo') + return super().redo() + + def restore(selection, workflow, direction): + calls.append((direction, selection, workflow)) + + h1 = Controller() + h2 = Controller() + h1.add('h1') + h2.add('h2') + gh = GlobalHistory(restore_context=restore) + gh.action( + h1, + selection_before='before', + selection_after='after', + workflow_context='normal', + ) + gh.add_to_current_action(h2) + + assert gh.undo() == ('h1', 'h2') + assert calls == [ + 'controller undo', + 'controller undo', + ('undo', 'before', 'normal'), + ] + + calls.clear() + assert gh.redo() == ('h1', 'h2') + assert calls == [ + 'controller redo', + 'controller redo', + ('redo', 'after', 'normal'), + ] diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index a858ca3b..5043b577 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -112,6 +112,18 @@ def test_role_transfers_leave_effective_presentation_unchanged(): assert not change.presentation_changed +def test_zero_reference_survives_similarity_to_cluster_transfer(): + controller = CurationSelectionController( + CurationSelectionState(cluster_ids=(0,), similar_ids=(4,), reference_id=0) + ) + + change = controller.transfer_similarity_to_cluster((4,)) + + assert change.after.cluster_ids == (0, 4) + assert change.after.reference_id == 0 + assert change.after.presentation_order == (0, 4) + + def test_role_transfer_rejects_ids_not_in_the_source_selection(): controller = CurationSelectionController( CurationSelectionState(cluster_ids=(1,), similar_ids=(2,), reference_id=1) diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 827e6644..e08b1197 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -914,20 +914,24 @@ def test_supervisor_filter(qtbot, supervisor): def test_supervisor_merge_1(qtbot, supervisor): _select(supervisor, [30], [20]) _assert_selected(supervisor, [30, 20]) + selection_before = supervisor.selection.snapshot() supervisor.actions.merge() supervisor.block() _assert_selected(supervisor, [31]) + selection_after = supervisor.selection.snapshot() supervisor.actions.undo() supervisor.block() _assert_selected(supervisor, [30, 20]) + assert supervisor.selection.state == selection_before supervisor.actions.redo() supervisor.block() supervisor.task_logger.show_history() _assert_selected(supervisor, [31]) + assert supervisor.selection.state == selection_after assert supervisor.is_dirty() @@ -948,6 +952,26 @@ def on_select(sender, cluster_ids): assert len(_l) == 1 +def test_supervisor_redo_preserves_selection_exploration_after_action(supervisor): + _select(supervisor, [30], [20]) + selection_before = supervisor.selection.snapshot() + supervisor.actions.merge() + supervisor.block() + + next_similar = supervisor.similarity_view.get_ids()[0] + supervisor.similarity_view.select([next_similar]) + supervisor.block() + selection_at_undo = supervisor.selection.snapshot() + + supervisor.actions.undo() + supervisor.block() + assert supervisor.selection.state == selection_before + + supervisor.actions.redo() + supervisor.block() + assert supervisor.selection.state == selection_at_undo + + def test_supervisor_merge_batches_table_fitting(monkeypatch, supervisor): _select(supervisor, [30], [20]) fit_calls = {'cluster': 0, 'similarity': 0} @@ -992,19 +1016,23 @@ def test_supervisor_merge_move(qtbot, supervisor): def test_supervisor_split_0(qtbot, supervisor): _select(supervisor, [1, 2]) _assert_selected(supervisor, [1, 2]) + selection_before = supervisor.selection.snapshot() supervisor.actions.split([1, 2]) supervisor.block() _assert_selected(supervisor, [31, 32, 33]) + selection_after = supervisor.selection.snapshot() supervisor.actions.undo() supervisor.block() _assert_selected(supervisor, [1, 2]) + assert supervisor.selection.state == selection_before supervisor.actions.redo() supervisor.block() _assert_selected(supervisor, [31, 32, 33]) + assert supervisor.selection.state == selection_after def test_supervisor_split_1(supervisor): @@ -1126,20 +1154,24 @@ def test_supervisor_label_cluster_3(supervisor): def test_supervisor_move_1(supervisor): _select(supervisor, [20]) _assert_selected(supervisor, [20]) + selection_before = supervisor.selection.snapshot() assert not supervisor.move('', '') supervisor.actions.move('noise', 'all') supervisor.block() _assert_selected(supervisor, [11]) + selection_after = supervisor.selection.snapshot() supervisor.actions.undo() supervisor.block() _assert_selected(supervisor, [20]) + assert supervisor.selection.state == selection_before supervisor.actions.redo() supervisor.block() _assert_selected(supervisor, [11]) + assert supervisor.selection.state == selection_after def test_supervisor_move_undo_restores_table_group(supervisor): From b9588ad3435805ebcfbf5066cc669b9b5f991e37 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 20:28:15 +0200 Subject: [PATCH 009/110] feat: add merge session and mode lifecycle --- phy/cluster/_selection.py | 251 +++++++++++++++++++++++++--- phy/cluster/tests/test_selection.py | 97 ++++++++++- 2 files changed, 327 insertions(+), 21 deletions(-) diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index 5cd04049..15ef07d4 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -5,6 +5,8 @@ separately how and when to render a :class:`SelectionChange`. """ +from __future__ import annotations + from dataclasses import dataclass from enum import Enum @@ -29,9 +31,57 @@ def _ordered_union(*cluster_id_lists) -> tuple[int, ...]: return tuple(dict.fromkeys(cluster_id for ids in cluster_id_lists for cluster_id in ids)) +@dataclass(frozen=True) +class NormalWorkflowSnapshot: + """Normal-mode selection plus opaque view state needed for cancellation.""" + + cluster_ids: tuple[int, ...] + similar_ids: tuple[int, ...] + reference_id: int | None + presentation_order: tuple[int, ...] + workflow_context: object = None + + def __post_init__(self): + state = CurationSelectionState( + cluster_ids=self.cluster_ids, + similar_ids=self.similar_ids, + reference_id=self.reference_id, + presentation_order=self.presentation_order, + ) + object.__setattr__(self, 'cluster_ids', state.cluster_ids) + object.__setattr__(self, 'similar_ids', state.similar_ids) + object.__setattr__(self, 'reference_id', state.reference_id) + object.__setattr__(self, 'presentation_order', state.presentation_order) + + @property + def selection(self): + """Return the Normal-mode selection represented by this snapshot.""" + return CurationSelectionState( + cluster_ids=self.cluster_ids, + similar_ids=self.similar_ids, + reference_id=self.reference_id, + presentation_order=self.presentation_order, + ) + + +@dataclass(frozen=True) +class MergeSession: + """Temporary ordered merge workspace tied to one fixed reference cluster.""" + + reference_id: int + ordered_ids: tuple[int, ...] + entry_snapshot: NormalWorkflowSnapshot + + def __post_init__(self): + ordered_ids = _as_unique_ids(self.ordered_ids) + if not ordered_ids or ordered_ids[0] != self.reference_id: + raise ValueError('The merge reference must be the first staged cluster.') + object.__setattr__(self, 'ordered_ids', ordered_ids) + + @dataclass(frozen=True) class CurationSelectionState: - """The authoritative, immutable Normal-mode curation selection. + """The authoritative, immutable curation selection. ``presentation_order`` is the effective selection in the order delivered to scientific views. It is independent from the two role-specific orders @@ -43,22 +93,36 @@ class CurationSelectionState: similar_ids: tuple[int, ...] = () reference_id: int | None = None presentation_order: tuple[int, ...] | None = None + merge: MergeSession | None = None def __post_init__(self): - if self.mode is not WorkflowMode.NORMAL: - raise ValueError('Only Normal-mode selection state is supported.') - cluster_ids = _as_unique_ids(self.cluster_ids) similar_ids = _as_unique_ids(self.similar_ids) reference_id = self.reference_id - if reference_id is None and cluster_ids: - reference_id = cluster_ids[0] - if reference_id is not None and reference_id not in cluster_ids: - raise ValueError('The reference ID must belong to the cluster selection.') - effective_ids = _ordered_union(cluster_ids, similar_ids) + merge = self.merge + if self.mode is WorkflowMode.NORMAL: + if merge is not None: + raise ValueError('Normal mode cannot contain a merge session.') + if reference_id is None and cluster_ids: + reference_id = cluster_ids[0] + if reference_id is not None and reference_id not in cluster_ids: + raise ValueError('The reference ID must belong to the cluster selection.') + effective_ids = _ordered_union(cluster_ids, similar_ids) + else: + if merge is None: + raise ValueError('Merge mode requires a merge session.') + if cluster_ids: + raise ValueError('Cluster selection must be empty in Merge mode.') + if reference_id is None: + reference_id = merge.reference_id + if reference_id != merge.reference_id: + raise ValueError('The selection and merge references must agree.') + if set(similar_ids).intersection(merge.ordered_ids): + raise ValueError('A cluster cannot be both staged and selected as similar.') + effective_ids = _ordered_union(merge.ordered_ids, similar_ids) default_presentation = _ordered_union( (reference_id,) if reference_id is not None else (), - cluster_ids, + merge.ordered_ids if merge is not None else cluster_ids, similar_ids, ) presentation_order = ( @@ -83,8 +147,17 @@ def __post_init__(self): @property def effective_ids(self): - """The ordered unique union of Cluster and Similarity selections.""" - return _ordered_union(self.cluster_ids, self.similar_ids) + """Return the effective selection for the active workflow mode.""" + return _ordered_union(self.merge_ids, self.similar_ids) + + @property + def merge_ids(self): + """Return staged IDs in Merge mode, otherwise the Cluster selection.""" + return self.merge.ordered_ids if self.merge is not None else self.cluster_ids + + @property + def is_merge_mode(self): + return self.mode is WorkflowMode.MERGE # A state is itself an immutable and complete snapshot for Normal mode. The @@ -110,7 +183,9 @@ def create(cls, before, after): before=before, after=after, roles_changed=( - before.cluster_ids != after.cluster_ids or before.similar_ids != after.similar_ids + before.cluster_ids != after.cluster_ids + or before.similar_ids != after.similar_ids + or before.merge_ids != after.merge_ids ), presentation_changed=before.presentation_order != after.presentation_order, reference_changed=before.reference_id != after.reference_id, @@ -124,12 +199,10 @@ def changed(self): class CurationSelectionController: - """Apply validated, atomic Normal-mode selection transitions.""" + """Apply validated, atomic curation selection transitions.""" def __init__(self, state=None): self._state = state or CurationSelectionState() - if self._state.mode is not WorkflowMode.NORMAL: - raise ValueError('Only Normal-mode selection is supported.') @property def state(self): @@ -137,7 +210,7 @@ def state(self): return self._state def snapshot(self): - """Return an immutable snapshot of the current Normal-mode state.""" + """Return the current immutable selection state.""" return self._state def restore(self, snapshot): @@ -148,6 +221,7 @@ def restore(self, snapshot): def set_cluster_selection(self, cluster_ids, reference_id=None): """Set Cluster View IDs, using the first (blue) ID as the default reference.""" + self._require_normal_mode() cluster_ids = _as_unique_ids(cluster_ids) if reference_id is None: reference_id = cluster_ids[0] if cluster_ids else None @@ -176,10 +250,24 @@ def set_normal_selection( def set_similarity_selection(self, similar_ids): """Set Similarity View IDs without changing the current reference.""" + current = self._state + similar_ids = _as_unique_ids(similar_ids) + effective_ids = _ordered_union(current.merge_ids, similar_ids) + presentation_order = _ordered_union( + tuple( + cluster_id + for cluster_id in current.presentation_order + if cluster_id in effective_ids + ), + effective_ids, + ) after = CurationSelectionState( - cluster_ids=self._state.cluster_ids, - similar_ids=_as_unique_ids(similar_ids), - reference_id=self._state.reference_id, + mode=current.mode, + cluster_ids=current.cluster_ids, + similar_ids=similar_ids, + reference_id=current.reference_id, + presentation_order=presentation_order, + merge=current.merge, ) return self._apply(after) @@ -189,6 +277,7 @@ def clear_similarity_selection(self): def transfer_cluster_to_similarity(self, cluster_ids): """Move Cluster View IDs to Similarity View without changing presentation.""" + self._require_normal_mode() cluster_ids = _as_unique_ids(cluster_ids) source_ids = set(cluster_ids) current = self._state @@ -213,6 +302,7 @@ def transfer_cluster_to_similarity(self, cluster_ids): def transfer_similarity_to_cluster(self, cluster_ids): """Move Similarity View IDs to Cluster View without changing presentation.""" + self._require_normal_mode() cluster_ids = _as_unique_ids(cluster_ids) source_ids = set(cluster_ids) current = self._state @@ -233,6 +323,127 @@ def transfer_similarity_to_cluster(self, cluster_ids): ) return self._apply(after) + def enter_merge_mode(self, workflow_context=None): + """Stage the complete Normal-mode selection and enter Merge mode.""" + self._require_normal_mode() + current = self._state + if not current.cluster_ids: + raise ValueError('Merge mode requires a Cluster View selection.') + snapshot = NormalWorkflowSnapshot( + cluster_ids=current.cluster_ids, + similar_ids=current.similar_ids, + reference_id=current.reference_id, + presentation_order=current.presentation_order, + workflow_context=workflow_context, + ) + ordered_ids = _ordered_union( + (current.reference_id,), + current.cluster_ids, + current.similar_ids, + ) + merge = MergeSession(current.reference_id, ordered_ids, snapshot) + after = CurationSelectionState( + mode=WorkflowMode.MERGE, + reference_id=current.reference_id, + presentation_order=current.presentation_order, + merge=merge, + ) + return self._apply(after) + + def cancel_merge_mode(self): + """Leave Merge mode and restore the exact entry selection.""" + self._require_merge_mode() + return self._apply(self._state.merge.entry_snapshot.selection) + + def add_to_merge(self, cluster_ids, insertion=None): + """Stage candidates, removing them from Similarity selection if necessary.""" + self._require_merge_mode() + cluster_ids = _as_unique_ids(cluster_ids) + current = self._state + new_ids = tuple( + cluster_id for cluster_id in cluster_ids if cluster_id not in current.merge_ids + ) + if not new_ids: + return self._apply(current) + ordered_ids = list(current.merge_ids) + if insertion is None: + insertion = len(ordered_ids) + if not 1 <= insertion <= len(ordered_ids): + raise ValueError('Merge insertion must follow the fixed reference.') + ordered_ids[insertion:insertion] = new_ids + merge = MergeSession( + current.reference_id, + tuple(ordered_ids), + current.merge.entry_snapshot, + ) + similar_ids = tuple( + cluster_id for cluster_id in current.similar_ids if cluster_id not in new_ids + ) + presentation_order = _ordered_union(current.presentation_order, new_ids) + after = CurationSelectionState( + mode=WorkflowMode.MERGE, + similar_ids=similar_ids, + reference_id=current.reference_id, + presentation_order=presentation_order, + merge=merge, + ) + return self._apply(after) + + def remove_from_merge(self, cluster_ids): + """Return staged non-reference candidates to the Similarity selection.""" + self._require_merge_mode() + cluster_ids = _as_unique_ids(cluster_ids) + current = self._state + if current.reference_id in cluster_ids: + raise ValueError('The merge reference cannot be removed.') + if not set(cluster_ids) <= set(current.merge_ids): + raise ValueError('Removed IDs must belong to the merge session.') + remaining = tuple( + cluster_id for cluster_id in current.merge_ids if cluster_id not in cluster_ids + ) + merge = MergeSession(current.reference_id, remaining, current.merge.entry_snapshot) + after = CurationSelectionState( + mode=WorkflowMode.MERGE, + similar_ids=_ordered_union(current.similar_ids, cluster_ids), + reference_id=current.reference_id, + presentation_order=current.presentation_order, + merge=merge, + ) + return self._apply(after) + + def reorder_merge(self, cluster_ids, insertion): + """Move staged candidates to an insertion point without changing colors.""" + self._require_merge_mode() + cluster_ids = _as_unique_ids(cluster_ids) + current = self._state + if current.reference_id in cluster_ids: + raise ValueError('The merge reference cannot be reordered.') + if not set(cluster_ids) <= set(current.merge_ids): + raise ValueError('Reordered IDs must belong to the merge session.') + remaining = [ + cluster_id for cluster_id in current.merge_ids if cluster_id not in cluster_ids + ] + if not 1 <= insertion <= len(remaining): + raise ValueError('Merge insertion must follow the fixed reference.') + remaining[insertion:insertion] = cluster_ids + merge = MergeSession(current.reference_id, tuple(remaining), current.merge.entry_snapshot) + after = CurationSelectionState( + mode=WorkflowMode.MERGE, + similar_ids=current.similar_ids, + reference_id=current.reference_id, + presentation_order=current.presentation_order, + merge=merge, + ) + return self._apply(after) + + def _require_normal_mode(self): + if self._state.is_merge_mode: + raise RuntimeError('This operation is unavailable in Merge mode.') + + def _require_merge_mode(self): + if not self._state.is_merge_mode: + raise RuntimeError('This operation requires Merge mode.') + def _apply(self, after): before = self._state self._state = after diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index 5043b577..68d4dd93 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -7,6 +7,8 @@ from .._selection import ( CurationSelectionController, CurationSelectionState, + MergeSession, + NormalWorkflowSnapshot, WorkflowMode, ) @@ -31,7 +33,7 @@ def test_state_rejects_invalid_ids_reference_and_presentation(): reference_id=2, presentation_order=(1, 2), ) - with raises(ValueError, match='Normal-mode'): + with raises(ValueError, match='requires a merge session'): CurationSelectionState(mode=WorkflowMode.MERGE) @@ -153,3 +155,96 @@ def test_snapshot_restore_and_noop_change_classification(): assert not change.changed assert not change.roles_changed assert not change.presentation_changed + + +def test_merge_session_validates_reference_and_state_roles(): + snapshot = NormalWorkflowSnapshot((1,), (), 1, (1,)) + with raises(ValueError, match='first staged'): + MergeSession(1, (2, 1), snapshot) + merge = MergeSession(1, (1, 2), snapshot) + with raises(ValueError, match='Cluster selection'): + CurationSelectionState( + mode=WorkflowMode.MERGE, + cluster_ids=(1,), + reference_id=1, + merge=merge, + ) + with raises(ValueError, match='both staged'): + CurationSelectionState( + mode=WorkflowMode.MERGE, + similar_ids=(2,), + reference_id=1, + merge=merge, + ) + + +def test_enter_and_cancel_merge_mode_restore_exact_entry_selection(): + initial = CurationSelectionState( + cluster_ids=(3, 1), + similar_ids=(4, 2), + reference_id=1, + presentation_order=(1, 3, 4, 2), + ) + context = {'cluster_sort': ('id', 'asc')} + controller = CurationSelectionController(initial) + + change = controller.enter_merge_mode(context) + + assert change.mode_changed + assert change.roles_changed + assert not change.presentation_changed + assert change.after.merge_ids == (1, 3, 4, 2) + assert change.after.cluster_ids == () + assert change.after.similar_ids == () + assert set(change.after.effective_ids) == set(initial.effective_ids) + assert change.after.merge.entry_snapshot.workflow_context is context + + change = controller.cancel_merge_mode() + assert change.after == initial + assert change.mode_changed + assert not change.presentation_changed + + +def test_enter_merge_mode_requires_cluster_selection(): + controller = CurationSelectionController(CurationSelectionState(similar_ids=(2,))) + with raises(ValueError, match='Cluster View selection'): + controller.enter_merge_mode() + + +def test_merge_candidate_transfer_and_reorder_preserve_color_order(): + controller = CurationSelectionController( + CurationSelectionState(cluster_ids=(1, 2), similar_ids=(3,), reference_id=1) + ) + controller.enter_merge_mode() + + change = controller.set_similarity_selection((4, 5)) + assert change.after.presentation_order == (1, 2, 3, 4, 5) + + change = controller.add_to_merge((4,)) + assert change.after.merge_ids == (1, 2, 3, 4) + assert change.after.similar_ids == (5,) + assert not change.presentation_changed + + change = controller.remove_from_merge((2,)) + assert change.after.merge_ids == (1, 3, 4) + assert change.after.similar_ids == (5, 2) + assert not change.presentation_changed + + change = controller.reorder_merge((4,), 1) + assert change.after.merge_ids == (1, 4, 3) + assert change.after.presentation_order == (1, 2, 3, 4, 5) + assert not change.presentation_changed + + +def test_merge_candidate_guards_reference_and_duplicate_membership(): + controller = CurationSelectionController(CurationSelectionState(cluster_ids=(1, 2))) + controller.enter_merge_mode() + + change = controller.add_to_merge((2,)) + assert not change.changed + with raises(ValueError, match='reference'): + controller.remove_from_merge((1,)) + with raises(ValueError, match='reference'): + controller.reorder_merge((1,), 1) + with raises(ValueError, match='merge session'): + controller.remove_from_merge((9,)) From 60af60a710460e6bcb89193c40c7abecaab431f3 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 20:28:33 +0200 Subject: [PATCH 010/110] feat: add cluster table drag and drop --- phy/gui/qt.py | 2 + phy/gui/tests/test_widgets.py | 34 +++++++++- phy/gui/widgets.py | 116 +++++++++++++++++++++++++++++++++- 3 files changed, 150 insertions(+), 2 deletions(-) diff --git a/phy/gui/qt.py b/phy/gui/qt.py index fd477f1e..da08e5c1 100644 --- a/phy/gui/qt.py +++ b/phy/gui/qt.py @@ -44,6 +44,7 @@ QEvent, QCoreApplication, QModelIndex, + QMimeData, QItemSelectionModel, QSortFilterProxyModel, qInstallMessageHandler, @@ -57,6 +58,7 @@ QMouseEvent, QGuiApplication, QFontDatabase, + QDrag, QWindow, QOpenGLWindow as _QOpenGLWindow, ) diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index 80755887..8978063a 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -10,7 +10,7 @@ from phylib.utils import connect, unconnect from pytest import fixture, mark -from ..qt import QHeaderView, Qt +from ..qt import QHeaderView, QMimeData, Qt from ..widgets import Barrier, IPythonView, KeyValueWidget, Table, ViewSettingsDialog from . import show_and_wait from .test_qt import _block @@ -60,6 +60,38 @@ def table(qtbot): # ------------------------------------------------------------------------------ +def test_table_cluster_drag_drop_policy_and_payload(table, qtbot): + target = Table(columns=['id'], data=[{'id': 10}, {'id': 11}]) + qtbot.addWidget(target) + table.configure_cluster_drag_drop('similarity', drag_selected_rows=True) + target.configure_cluster_drag_drop('merge', accepted_roles=('similarity',)) + table.set_selected_ids((1, 2)) + + assert table._drag_ids_for_index(table._proxy_index_for_id(1)) == (1, 2) + assert table._drag_ids_for_index(table._proxy_index_for_id(3)) == (3,) + + mime = QMimeData() + mime.setData('application/x-phy-cluster-ids', b'[1, 2, 2]') + assert target.cluster_ids_from_mime(mime) == (1, 2) + assert target.accepts_cluster_drop(table, (1, 2)) + + drops = [] + + @connect(event='cluster_drop', sender=target) + def on_cluster_drop(sender, payload): + drops.append(payload) + + target.emit_cluster_drop(table, (1, 2), 1) + assert drops == [{'source': table, 'cluster_ids': (1, 2), 'insertion': 1}] + unconnect(on_cluster_drop) + + invalid = QMimeData() + invalid.setData('application/x-phy-cluster-ids', b'[1, "spikes"]') + assert target.cluster_ids_from_mime(invalid) == () + table.configure_cluster_drag_drop(None) + assert not table.table_view.dragEnabled() + + def test_key_value_1(qtbot): widget = KeyValueWidget() qtbot.addWidget(widget) diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index a6884e55..68f06a5c 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -34,12 +34,14 @@ QDialog, QDialogButtonBox, QDoubleSpinBox, + QDrag, QEvent, QGridLayout, QHeaderView, QItemSelectionModel, QLabel, QLineEdit, + QMimeData, QModelIndex, QObject, QPalette, @@ -60,6 +62,7 @@ logger = logging.getLogger(__name__) _NO_VALUE = object() +_CLUSTER_IDS_MIME = 'application/x-phy-cluster-ids' # ----------------------------------------------------------------------------- @@ -519,6 +522,45 @@ def _install_table_filter_focus_watcher(): app.installEventFilter(watcher) +class _TableView(QTableView): + """QTableView adapter for cluster-ID-only drag-and-drop intents.""" + + def __init__(self, owner): + super().__init__(owner) + self._owner = owner + + def startDrag(self, supported_actions): + ids = self._owner._drag_ids_for_index(self.currentIndex()) + if not ids: + return + mime = QMimeData() + mime.setData(_CLUSTER_IDS_MIME, json.dumps(ids).encode('utf8')) + drag = QDrag(self) + drag.setMimeData(mime) + drag.exec_(Qt.MoveAction) + + def dragEnterEvent(self, event): + if self._owner._accept_cluster_drop_event(event): + event.acceptProposedAction() + else: + event.ignore() + + def dragMoveEvent(self, event): + self.dragEnterEvent(event) + + def dropEvent(self, event): + source = event.source() + source_table = source._owner if isinstance(source, _TableView) else None + ids = self._owner.cluster_ids_from_mime(event.mimeData()) + if source_table is None or not self._owner.accepts_cluster_drop(source_table, ids): + event.ignore() + return + index = self.indexAt(event.pos()) + insertion = index.row() if index.isValid() else len(self._owner._visible_ids()) + self._owner.emit_cluster_drop(source_table, ids, insertion) + event.acceptProposedAction() + + class Table(QWidget): """A sortable native Qt table with a compatibility API for legacy callers.""" @@ -557,6 +599,9 @@ def __init__( self._column_widths_fitted = False self._row_height_fitted = False self.skip_masked = bool(skip_masked) + self._drag_role = None + self._accepted_drag_roles = set() + self._drag_selected_rows = True self._group_colors = { 'good': QColor('#86D16D'), 'mua': QColor('#afafaf'), @@ -572,7 +617,7 @@ def __init__( self.filter_edit.returnPressed.connect(self._apply_filter_from_editor) self.filter_edit.installEventFilter(self) - self.table_view = QTableView(self) + self.table_view = _TableView(self) self.table_view.viewport().installEventFilter(self) self.table_view.setSelectionBehavior(QAbstractItemView.SelectRows) self.table_view.setSelectionMode(QAbstractItemView.ExtendedSelection) @@ -601,6 +646,75 @@ def __init__( self._init_table(columns=columns, value_names=value_names, data=data, sort=sort) _install_table_filter_focus_watcher() + def configure_cluster_drag_drop( + self, + role, + *, + accepted_roles=(), + drag_selected_rows=True, + ): + """Enable reusable cluster-ID drag/drop and declare accepted source roles.""" + self._drag_role = role + self._accepted_drag_roles = set(accepted_roles) + self._drag_selected_rows = bool(drag_selected_rows) + enabled = role is not None + self.table_view.setDragEnabled(enabled) + self.table_view.setAcceptDrops(bool(self._accepted_drag_roles)) + self.table_view.viewport().setAcceptDrops(bool(self._accepted_drag_roles)) + self.table_view.setDropIndicatorShown(bool(self._accepted_drag_roles)) + self.table_view.setDefaultDropAction(Qt.MoveAction) + self.table_view.setDragDropMode( + QAbstractItemView.DragDrop if enabled else QAbstractItemView.NoDragDrop + ) + + def _drag_ids_for_index(self, index): + if self._drag_role is None or not index.isValid(): + return () + visible = self._visible_ids() + if not 0 <= index.row() < len(visible): + return () + clicked = visible[index.row()] + if self._drag_selected_rows and clicked in self._selected_ids: + return tuple(self._selected_visible_ids()) + return (clicked,) + + @staticmethod + def cluster_ids_from_mime(mime): + """Decode and validate a cluster-ID-only MIME payload.""" + if mime is None or not mime.hasFormat(_CLUSTER_IDS_MIME): + return () + try: + ids = json.loads(bytes(mime.data(_CLUSTER_IDS_MIME)).decode('utf8')) + except (TypeError, ValueError, UnicodeDecodeError): + return () + if not isinstance(ids, list) or any(not _is_integer(cluster_id) for cluster_id in ids): + return () + return tuple(_uniq(ids)) + + def accepts_cluster_drop(self, source, cluster_ids): + """Return whether a source table and payload satisfy this table's policy.""" + return bool( + cluster_ids and source is not None and source._drag_role in self._accepted_drag_roles + ) + + def _accept_cluster_drop_event(self, event): + source = event.source() + source_table = source._owner if isinstance(source, _TableView) else None + ids = self.cluster_ids_from_mime(event.mimeData()) + return self.accepts_cluster_drop(source_table, ids) + + def emit_cluster_drop(self, source, cluster_ids, insertion): + """Emit one domain-neutral transfer/reorder intent.""" + emit( + 'cluster_drop', + self, + { + 'source': source, + 'cluster_ids': tuple(cluster_ids), + 'insertion': int(insertion), + }, + ) + @property def debouncer(self): return self._debouncer From 1dea2bbaf4d49b1cae1d4134668041d7b0f47d3c Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 20:29:33 +0200 Subject: [PATCH 011/110] feat: add merge candidate interactions --- phy/cluster/supervisor.py | 355 +++++++++++++++++++++++++++++++++++++- 1 file changed, 350 insertions(+), 5 deletions(-) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index e3b19330..7e0b2a26 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -17,7 +17,7 @@ from phylib.utils import Bunch, connect, emit, unconnect from phy.gui.actions import Actions -from phy.gui.qt import QHeaderView, _block, _wait, set_busy +from phy.gui.qt import QAbstractItemView, QHeaderView, _block, _wait, set_busy from phy.gui.widgets import Barrier, Table, _uniq from ._history import GlobalHistory @@ -407,6 +407,38 @@ def reset(self, cluster_ids, reference_id=None): return similar +class MergeView(Table): + """Display the ordered contents of the temporary Merge workspace.""" + + def __init__(self, *args, data=None, columns=(), **kwargs): + super().__init__(*args, title='MERGE MODE', debounce_events=(), **kwargs) + columns = ['id'] + [column for column in columns if column != 'id'] + self._init_table( + columns=columns, + value_names=columns + [{'data': ['group']}], + data=data, + sort=None, + ) + self.filter_edit.hide() + self.table_view.setSelectionMode(QAbstractItemView.NoSelection) + + def _on_row_clicked(self, index): + """Rows are workspace members, not an independent selection.""" + + def _on_header_clicked(self, section): + """Merge order changes only through explicit reorder intents.""" + + def set_merge_ids(self, cluster_ids, data, presentation_order): + """Project one complete ordered Merge session.""" + self.remove_all_and_add(data, fit_columns=not self._column_widths_fitted) + self.set_selected_index_order(presentation_order) + self.set_selected_ids(cluster_ids) + + def _drag_ids_for_index(self, index): + ids = super()._drag_ids_for_index(index) + return () if ids and ids[0] == self._reference_id else ids + + # ----------------------------------------------------------------------------- # ActionCreator # ----------------------------------------------------------------------------- @@ -442,6 +474,7 @@ class ActionCreator: # Qt maps Meta to the physical Control key on macOS. 'select_first_similar': 'meta+space' if sys.platform == 'darwin' else 'ctrl+space', 'unselect_similar': 'backspace', + 'toggle_merge_mode': 'c', 'next_best': 'down', 'previous_best': 'up', # Misc. @@ -547,6 +580,7 @@ def _create_select_actions(self): docstring='Select the first N eligible clusters shown in the similarity view.', ) self.add(w, 'unselect_similar') + self.add(w, 'toggle_merge_mode') self.add( w, 'skip_noise_and_mua', @@ -685,6 +719,8 @@ def __init__( self.context = context self.similarity = similarity # function cluster => [(cl, sim), ...] self.actions = None # will be set when attaching the GUI + self.gui = None + self.merge_view = None self._is_dirty = None self._sort = sort # Initial sort requested in the constructor # This is populated alongside the existing TaskLogger-derived selection during the @@ -829,6 +865,8 @@ def _save_new_cluster_id(self, sender, up): def _save_gui_state(self, gui): """Save the GUI state with the cluster view and similarity view.""" + if self.selection.state.is_merge_mode: + self._cancel_merge_mode() gui.state.update_view_state(self.cluster_view, self.cluster_view.state) gui.state['n_similar_clusters_to_select'] = self.n_similar_clusters_to_select gui.state['skip_masked_clusters'] = self.skip_masked_clusters @@ -849,6 +887,37 @@ def _get_similar_clusters(self, sender, cluster_id): ] return data + @staticmethod + def _table_workflow_state(view): + """Capture lightweight native-table context used by Merge cancellation.""" + return { + 'sort': tuple(view._current_sort) if view._current_sort else None, + 'filter': view._filter_text, + 'scroll': view.table_view.verticalScrollBar().value(), + } + + def _workflow_context(self): + return { + 'cluster': self._table_workflow_state(self.cluster_view), + 'similarity': self._table_workflow_state(self.similarity_view), + } + + @staticmethod + def _restore_table_workflow_state(view, state): + if not state: + return + sort = state.get('sort') + if sort: + view.sort_by(*sort) + view.filter(state.get('filter', '')) + view.table_view.verticalScrollBar().setValue(state.get('scroll', 0)) + + def _restore_workflow_context(self, context): + if not context: + return + self._restore_table_workflow_state(self.cluster_view, context.get('cluster')) + self._restore_table_workflow_state(self.similarity_view, context.get('similarity')) + def get_cluster_info(self, cluster_id, exclude=()): """Return the data associated to a given cluster.""" out = {'id': cluster_id} @@ -905,6 +974,32 @@ def _create_views(self, gui=None, sort=None): # Change the state after every clustering action, according to the action flow. connect(self._after_action, event='cluster', sender=self) + def _create_merge_view(self, state=None): + state = state or self.selection.state + data = [self.get_cluster_info(cluster_id) for cluster_id in state.merge_ids] + self.merge_view = MergeView(self.gui, data=data, columns=self.columns) + self.merge_view._reference_id = state.reference_id + self.merge_view.configure_cluster_drag_drop( + 'merge', accepted_roles=('merge', 'similarity'), drag_selected_rows=False + ) + self.similarity_view.configure_cluster_drag_drop( + 'similarity', accepted_roles=('merge',), drag_selected_rows=True + ) + connect( + self._remove_merge_candidate_on_right_click, + event='row_right_click', + sender=self.merge_view, + ) + connect(self._on_cluster_drop, event='cluster_drop', sender=self.merge_view) + connect(self._on_cluster_drop, event='cluster_drop', sender=self.similarity_view) + self.gui.add_view(self.merge_view, position='left', closable=True) + self.merge_view.dock.add_button( + name='cancel_merge_mode', + text='Cancel Merge Mode', + callback=lambda checked: self.toggle_merge_mode(), + ) + return self.merge_view + def _reset_cluster_view(self): """Recreate the cluster view.""" logger.debug('Reset the cluster view.') @@ -931,6 +1026,8 @@ def _clusters_added_and_removed(self, added, removed): data = [self.get_cluster_info(cluster_id) for cluster_id in added] self.cluster_view.add_remove(data, removed) self.similarity_view.add_remove(data, removed) + if self.merge_view is not None: + self.merge_view.add_remove(data, removed) def _cluster_metadata_changed(self, field, cluster_ids): """Update the cluster and similarity views when clusters metadata is updated.""" @@ -947,6 +1044,8 @@ def _cluster_metadata_changed(self, field, cluster_ids): ) self.cluster_view.change(data) self.similarity_view.change(data) + if self.merge_view is not None: + self.merge_view.change(data) def _clusters_selected(self, sender, obj, **kwargs): """When clusters are selected in the cluster view, register the action in the history @@ -954,6 +1053,9 @@ def _clusters_selected(self, sender, obj, **kwargs): update_views is False.""" if sender != self.cluster_view: return + if self.selection.state.is_merge_mode: + logger.warning('Cluster selection is unavailable in Merge mode.') + return if obj.get('revision') not in (None, sender._selection_revision): logger.debug('Ignoring stale Cluster View selection revision.') return @@ -990,6 +1092,7 @@ def _similar_selected(self, sender, obj): logger.debug('Similar clusters selected: %s (%s)', similar, next_similar) self.selection.set_similarity_selection(similar) self._update_selection_colors() + self._project_merge_view() self.task_logger.log(self.similarity_view, 'select', similar, output=obj) emit('select', self, self.selected, **kwargs) if similar: @@ -1001,15 +1104,32 @@ def _update_selection_colors(self): order = self.selection.state.presentation_order self.cluster_view.set_selected_index_order(order) self.similarity_view.set_selected_index_order(order) + if self.merge_view is not None: + self.merge_view.set_selected_index_order(order) + + def _project_merge_view(self): + state = self.selection.state + if self.merge_view is None or not state.is_merge_mode: + return + data = [self.get_cluster_info(cluster_id) for cluster_id in state.merge_ids] + self.merge_view.set_merge_ids(state.merge_ids, data, state.presentation_order) + self.merge_view.dock.set_status(self._merge_status_text()) + + def _merge_status_text(self): + state = self.selection.state + staged = len(state.merge_ids) + similar = len(state.similar_ids) + return f'MERGE MODE — {staged} staged + {similar} selected similar = {staged + similar} clusters' def _apply_selection_change(self, change, callback=None): """Project one complete controller transition and publish it atomically.""" state = change.after cluster_payload = self.cluster_view.set_selected_ids(state.cluster_ids) - if state.cluster_ids: - self.similarity_view.reset(state.cluster_ids, reference_id=state.reference_id) + if state.reference_id is not None: + self.similarity_view.reset(state.merge_ids, reference_id=state.reference_id) similar_payload = self.similarity_view.set_selected_ids(state.similar_ids) self._update_selection_colors() + self._project_merge_view() self.task_logger.log( self.cluster_view, 'select', @@ -1027,10 +1147,101 @@ def _apply_selection_change(self, change, callback=None): if callback: self.cluster_view._schedule_callback(callback, state) + def _set_merge_mode_ui(self, active): + self.cluster_view.setEnabled(not active) + if active: + self.cluster_view.dock.set_status('MERGE MODE — Cluster View disabled') + else: + ids = self.selection.state.cluster_ids + self.cluster_view.dock.set_status(f'clusters: {", ".join(map(str, ids))}') + if self.actions is not None: + can_redo_merge = False + if active: + index = self._global_history.current_position + 1 + history = self._global_history._history + can_redo_merge = index < len(history) and self._is_merge_history_context( + history[index].workflow_context + ) + for name in self.actions._actions_dict: + enabled = not active or name == 'merge' or (name == 'redo' and can_redo_merge) + (self.actions.enable if enabled else self.actions.disable)(name) + if self.select_actions is not None: + allowed = { + 'toggle_merge_mode', + 'select_first_similar', + 'select_n_similar', + 'unselect_similar', + 'next', + 'previous', + 'skip_noise_and_mua', + } + for name in self.select_actions._actions_dict: + ( + self.select_actions.enable + if not active or name in allowed + else self.select_actions.disable + )(name) + + def _close_merge_view(self): + view = self.merge_view + self.merge_view = None + if view is not None and view in self.gui.views: + view.dock.close() + self.similarity_view.configure_cluster_drag_drop(None) + + def _on_cluster_drop(self, sender, payload): + """Translate generic table drops into Merge controller intents.""" + if not self.selection.state.is_merge_mode: + return + source = payload['source'] + cluster_ids = payload['cluster_ids'] + insertion = payload['insertion'] + if sender is self.merge_view and source is self.similarity_view: + insertion = min(max(1, insertion), len(self.selection.state.merge_ids)) + self.add_to_merge(cluster_ids, insertion=insertion) + elif sender is self.similarity_view and source is self.merge_view: + self.remove_from_merge(cluster_ids) + elif sender is self.merge_view and source is self.merge_view: + current = self.selection.state.merge_ids + removed_before = sum( + current.index(cluster_id) < insertion for cluster_id in cluster_ids + ) + adjusted = max(1, insertion - removed_before) + self.reorder_merge(cluster_ids, adjusted) + + def _cancel_merge_mode(self, close_view=True): + if not self.selection.state.is_merge_mode: + return + context = self.selection.state.merge.entry_snapshot.workflow_context + change = self.selection.cancel_merge_mode() + self._set_merge_mode_ui(False) + self._apply_selection_change(change) + self._restore_workflow_context(context) + if close_view: + self._close_merge_view() + def _restore_history_context(self, selection, workflow_context, direction): """Restore a curation snapshot after the associated data undo or redo.""" + if selection.is_merge_mode and self.merge_view is None: + self._create_merge_view(selection) + self._set_merge_mode_ui(True) + elif not selection.is_merge_mode: + self._set_merge_mode_ui(False) change = self.selection.restore(selection) self._apply_selection_change(change) + if selection.is_merge_mode: + context = ( + workflow_context.get('tables') + if self._is_merge_history_context(workflow_context) + else selection.merge.entry_snapshot.workflow_context + ) + self._restore_workflow_context(context) + else: + self._close_merge_view() + + @staticmethod + def _is_merge_history_context(context): + return isinstance(context, dict) and context.get('mode') == 'merge' def _select_after_merge( self, @@ -1086,6 +1297,9 @@ def _promote_similar_on_right_click(self, sender, cluster_id): """Promote a right-clicked similarity row through the normal action queue.""" emit('action', self.action_creator, 'promote_similar', cluster_id) + def _remove_merge_candidate_on_right_click(self, sender, cluster_id): + emit('action', self.action_creator, 'remove_from_merge', cluster_id) + def _demote_cluster_on_right_click(self, sender, cluster_id): """Demote a right-clicked cluster row through the normal action queue.""" emit('action', self.action_creator, 'demote_cluster', cluster_id) @@ -1093,6 +1307,24 @@ def _demote_cluster_on_right_click(self, sender, cluster_id): def _on_action(self, sender, name, *args): """Called when an action is triggered: enqueue and process the task.""" assert sender == self.action_creator + if self.selection.state.is_merge_mode and name in { + 'split', + 'label', + 'move', + 'select', + 'sort', + 'filter', + 'clear_filter', + 'first', + 'last', + 'reset_wizard', + 'next_best', + 'previous_best', + 'demote_cluster', + 'undo', + }: + logger.warning('Action `%s` is unavailable in Merge mode.', name) + return # Ignore wizard navigation requests triggered while another selection task is still # being processed. This keeps an explicit select followed immediately by next() # from advancing two steps in one block cycle. @@ -1121,6 +1353,9 @@ def _after_action(self, sender, up): up.description.replace('metadata_', ''), up.metadata_changed, ) + if self.selection.state.is_merge_mode: + self.task_logger.process() + return # Table filtering or cluster removal may make projected rows disappear without a # selection event. Keep the authoritative role state synchronized before applying # the post-action navigation policy. @@ -1156,6 +1391,8 @@ def _set_busy(self, busy): # Let the cluster views know that the GUI is busy. self.cluster_view.set_busy(busy) self.similarity_view.set_busy(busy) + if self.merge_view is not None: + self.merge_view.set_busy(busy) # If the GUI is no longer busy, deliver the latest selection on the next timer tick. # Keeping this asynchronous avoids re-entering the task queue during a busy transition. if not busy: @@ -1166,6 +1403,9 @@ def _set_busy(self, busy): def select(self, *cluster_ids, callback=None): """Select a list of clusters.""" + if self.selection.state.is_merge_mode: + logger.warning('Cluster selection is unavailable in Merge mode.') + return # HACK: allow for `select(1, 2, 3)` in addition to `select([1, 2, 3])` # This makes it more convenient to select multiple clusters with # the snippet: `:c 1 2 3` instead of `:c 1,2,3`. @@ -1176,18 +1416,30 @@ def select(self, *cluster_ids, callback=None): # Update the cluster view selection. self.cluster_view.select(cluster_ids, callback=callback) + def _reject_cluster_action_in_merge_mode(self, name): + if not self.selection.state.is_merge_mode: + return False + logger.warning('Action `%s` is unavailable in Merge mode.', name) + return True + # Cluster view actions # ------------------------------------------------------------------------- def sort(self, column, sort_dir='desc'): """Sort the cluster view by a given column, in a given order (asc or desc).""" + if self._reject_cluster_action_in_merge_mode('sort'): + return self.cluster_view.sort_by(column, sort_dir=sort_dir) def filter(self, text): """Filter the clusters using a boolean expression on the column names.""" + if self._reject_cluster_action_in_merge_mode('filter'): + return self.cluster_view.filter(text) def clear_filter(self): + if self._reject_cluster_action_in_merge_mode('clear_filter'): + return self.cluster_view.filter('') # Properties @@ -1216,6 +1468,8 @@ def state(self): def attach(self, gui): """Attach to the GUI.""" + self.gui = gui + saved_n_similar = gui.state.get( 'n_similar_clusters_to_select', self.n_similar_clusters_to_select ) @@ -1255,6 +1509,13 @@ def attach(self, gui): ) connect(self._save_gui_state, event='close', sender=gui) + + @connect(event='close_view') + def on_close_view(view, sender): + if view is self.merge_view: + self.merge_view = None + self._cancel_merge_mode(close_view=False) + gui.add_view(self.cluster_view, position='left', closable=False) gui.add_view(self.similarity_view, position='left', closable=False) @@ -1302,6 +1563,12 @@ def selected_similar(self): """Selected clusters in the similarity view only.""" return list(self.selection.state.similar_ids) + @property + def selected_merge(self): + """Clusters staged in Merge View, or an empty list in Normal mode.""" + state = self.selection.state + return list(state.merge_ids) if state.is_merge_mode else [] + @property def selected(self): """Selected clusters in the cluster and similarity views.""" @@ -1316,33 +1583,49 @@ def n_spikes(self, cluster_id): def merge(self, cluster_ids=None, to=None): """Merge the selected clusters.""" + merge_mode = self.selection.state.is_merge_mode + if merge_mode and cluster_ids is not None and set(cluster_ids) != set(self.selected): + logger.warning('An explicit merge cannot differ from the active Merge workspace.') + return if cluster_ids is None: cluster_ids = self.selected if len(cluster_ids or []) <= 1: + if merge_mode: + logger.warning('Select at least one additional candidate before merging.') return selection_before = self.selection.snapshot() + workflow_context = ( + {'mode': 'merge', 'tables': self._workflow_context()} if merge_mode else None + ) # A merge synchronously emits several related table mutations: metadata # inheritance, addition of the merged cluster, and removal of its # ancestors. Fit each attached table once after the complete operation # instead of rescanning every row after every intermediate mutation. with ExitStack() as stack: - for table_name in ('cluster_view', 'similarity_view'): + for table_name in ('cluster_view', 'similarity_view', 'merge_view'): table = getattr(self, table_name, None) if table is not None: stack.enter_context(table.batch_update()) out = self.clustering.merge(cluster_ids, to=to) if not getattr(getattr(self, 'task_logger', None), '_processing', False): self._select_after_merge(out, selection_before) + if merge_mode: + self._set_merge_mode_ui(False) + self._close_merge_view() self._global_history.action( self.clustering, description='merge', selection_before=selection_before, selection_after=self.selection.snapshot(), + workflow_context=workflow_context, ) return out def split(self, spike_ids=None, spike_clusters_rel=0): """Make a new cluster out of the specified spikes.""" + if self.selection.state.is_merge_mode: + logger.warning('Split is unavailable in Merge mode.') + return if spike_ids is None: # Concatenate all spike_ids returned by views who respond to request_split. spike_ids = emit('request_split', self) @@ -1379,6 +1662,9 @@ def get_labels(self, field): def label(self, name, value, cluster_ids=None): """Assign a label to some clusters.""" + if self.selection.state.is_merge_mode: + logger.warning('Cluster metadata changes are unavailable in Merge mode.') + return if cluster_ids is None: cluster_ids = self.selected if not hasattr(cluster_ids, '__len__'): @@ -1423,19 +1709,27 @@ def move(self, group, which): def reset_wizard(self, callback=None): """Reset the wizard.""" + if self._reject_cluster_action_in_merge_mode('reset_wizard'): + return self.cluster_view.first(callback=callback or partial(emit, 'wizard_done', self)) def next_best(self, callback=None): """Select the next best cluster in the cluster view.""" + if self._reject_cluster_action_in_merge_mode('next_best'): + return self.cluster_view.next(callback=callback or partial(emit, 'wizard_done', self)) def previous_best(self, callback=None): """Select the previous best cluster in the cluster view.""" + if self._reject_cluster_action_in_merge_mode('previous_best'): + return self.cluster_view.previous(callback=callback or partial(emit, 'wizard_done', self)) def next(self, callback=None): """Select the next cluster in the similarity view.""" - if not self.selected_clusters: + if self.selection.state.is_merge_mode: + self.similarity_view.next(callback=callback or partial(emit, 'wizard_done', self)) + elif not self.selected_clusters: self.cluster_view.first(callback=callback or partial(emit, 'wizard_done', self)) else: self.similarity_view.next(callback=callback or partial(emit, 'wizard_done', self)) @@ -1449,6 +1743,53 @@ def unselect_similar(self, callback=None): change = self.selection.clear_similarity_selection() self._apply_selection_change(change, callback=callback) + def toggle_merge_mode(self, callback=None): + """Enter Merge mode, or cancel the active Merge workspace.""" + if self.selection.state.is_merge_mode: + self._cancel_merge_mode() + if callback: + callback(self.selection.state) + return self.selection.state + self.cluster_view.debouncer.flush() + self.similarity_view.debouncer.flush() + if not self.selection.state.cluster_ids: + logger.warning('Select at least one Cluster View row before entering Merge mode.') + return + change = self.selection.enter_merge_mode(self._workflow_context()) + self._create_merge_view() + self._set_merge_mode_ui(True) + self._apply_selection_change(change, callback=callback) + return change.after + + def add_to_merge(self, cluster_ids, insertion=None, callback=None): + """Transfer candidate IDs into the Merge workspace.""" + cluster_ids = tuple(cluster_ids) + candidates = set(self.similarity_view.get_ids()) + if not set(cluster_ids) <= candidates: + logger.warning('Merge candidates must be visible in Similarity View.') + return + change = self.selection.add_to_merge(cluster_ids, insertion=insertion) + self._apply_selection_change(change, callback=callback) + return change.after + + def remove_from_merge(self, cluster_ids, callback=None): + """Transfer staged candidates back to Similarity View.""" + if isinstance(cluster_ids, Integral): + cluster_ids = (int(cluster_ids),) + try: + change = self.selection.remove_from_merge(cluster_ids) + except ValueError as e: + logger.warning('%s', e) + return + self._apply_selection_change(change, callback=callback) + return change.after + + def reorder_merge(self, cluster_ids, insertion, callback=None): + """Reorder staged candidates while preserving their color slots.""" + change = self.selection.reorder_merge(cluster_ids, insertion) + self._apply_selection_change(change, callback=callback) + return change.after + def select_first_similar(self, n=None, callback=None): """Select the first N eligible clusters currently shown in the similarity view.""" if n is not None: @@ -1477,6 +1818,8 @@ def set_skip_masked_clusters(self, skip_masked, callback=None): def promote_similar(self, cluster_id, callback=None): """Move a similarity row into the cluster view while preserving all other selections.""" + if self.selection.state.is_merge_mode: + return self.add_to_merge((cluster_id,), callback=callback) state = self.selection.state if cluster_id in state.similar_ids: change = self.selection.transfer_similarity_to_cluster((cluster_id,)) @@ -1505,6 +1848,8 @@ def demote_cluster(self, cluster_id, callback=None): def toggle_cluster_selection(self, cluster_id, callback=None): """Add or remove a cluster from the cluster-view selection.""" + if self._reject_cluster_action_in_merge_mode('toggle_cluster_selection'): + return cluster_ids = list(self.selected_clusters) if cluster_id in cluster_ids: cluster_ids.remove(cluster_id) From 96c1024eaa77ae3c674842e316739931ccd1efe5 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 20:29:36 +0200 Subject: [PATCH 012/110] feat: restore merge sessions through history --- phy/cluster/supervisor.py | 15 ++ phy/cluster/tests/test_supervisor.py | 254 +++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 7e0b2a26..6c796dbb 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1859,10 +1859,14 @@ def toggle_cluster_selection(self, cluster_id, callback=None): def first(self, callback=None): """Select the first cluster in the cluster view.""" + if self._reject_cluster_action_in_merge_mode('first'): + return self.cluster_view.first() def last(self, callback=None): """Select the last cluster in the cluster view.""" + if self._reject_cluster_action_in_merge_mode('last'): + return self.cluster_view.last() # Other actions @@ -1874,6 +1878,9 @@ def is_dirty(self): def undo(self): """Undo the last action.""" + if self.selection.state.is_merge_mode: + logger.warning('Undo is unavailable while a Merge workspace is active.') + return # Selection-only exploration does not create history entries. Preserve the exact # state at the time undo is requested so redo remains a true inverse operation. if self._global_history.current_position > 0: @@ -1884,6 +1891,14 @@ def undo(self): def redo(self): """Undo the last undone action.""" + if self.selection.state.is_merge_mode: + index = self._global_history.current_position + 1 + history = self._global_history._history + if index >= len(history) or not self._is_merge_history_context( + history[index].workflow_context + ): + logger.warning('Redo is unavailable for this Merge workspace.') + return self._global_history.redo() def save(self): diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index e08b1197..b2ae36c0 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -25,6 +25,7 @@ from ..supervisor import ( ActionCreator, ClusterView, + MergeView, SimilarityView, Supervisor, TaskLogger, @@ -396,6 +397,259 @@ def on_select(sender, cluster_ids): unconnect(on_select) +def test_supervisor_merge_mode_lifecycle_restores_entry_state(supervisor): + _select(supervisor, [10, 30], [20, 11]) + entry = supervisor.selection.snapshot() + supervisor.cluster_view.filter('id >= 10') + supervisor.similarity_view.filter('id >= 1') + context = supervisor._workflow_context() + events = [] + + @connect(sender=supervisor) + def on_select(sender, cluster_ids): + events.append(cluster_ids) + + state = supervisor.toggle_merge_mode() + + assert state.is_merge_mode + assert supervisor.selected_merge == [10, 30, 20, 11] + assert supervisor.selected_clusters == [] + assert supervisor.selected_similar == [] + assert supervisor.selected == [10, 30, 20, 11] + assert isinstance(supervisor.merge_view, MergeView) + assert supervisor.merge_view.get_ids() == [10, 30, 20, 11] + assert supervisor.merge_view.dock.get_widget('cancel_merge_mode') is not None + assert not supervisor.cluster_view.isEnabled() + assert events == [] + + supervisor.similarity_view.filter('id < 20') + supervisor.toggle_merge_mode() + + assert supervisor.selection.state == entry + assert supervisor.merge_view is None + assert supervisor.cluster_view.isEnabled() + assert supervisor._workflow_context() == context + assert events == [] + unconnect(on_select) + + +def test_supervisor_merge_candidate_interactions_preserve_colors(supervisor): + _select(supervisor, [10, 30], [20]) + supervisor.toggle_merge_mode() + candidate = supervisor.similarity_view.get_ids()[0] + events = [] + + @connect(sender=supervisor) + def on_select(sender, cluster_ids): + events.append(cluster_ids) + + supervisor.similarity_view.select([candidate]) + supervisor.block() + assert events == [[10, 30, 20, candidate]] + events.clear() + + supervisor.add_to_merge((candidate,)) + assert supervisor.selected_merge == [10, 30, 20, candidate] + assert supervisor.selected_similar == [] + assert events == [] + + supervisor.remove_from_merge(30) + assert supervisor.selected_merge == [10, 20, candidate] + assert supervisor.selected_similar == [30] + assert supervisor.selected == [10, 30, 20, candidate] + assert events == [] + + supervisor.reorder_merge((candidate,), 1) + assert supervisor.selected_merge == [10, candidate, 20] + assert supervisor.selected == [10, 30, 20, candidate] + assert events == [] + assert supervisor.merge_view._selected_color_index(10) == 0 + assert supervisor.similarity_view._selected_color_index(30) == 1 + assert supervisor.merge_view._selected_color_index(20) == 2 + assert supervisor.merge_view._selected_color_index(candidate) == 3 + unconnect(on_select) + + +def test_supervisor_merge_drag_drop_intents(supervisor): + _select(supervisor, [10, 30], [20]) + supervisor.toggle_merge_mode() + candidate = supervisor.similarity_view.get_ids()[0] + + supervisor.merge_view.emit_cluster_drop(supervisor.similarity_view, (candidate,), 1) + assert supervisor.selected_merge == [10, candidate, 30, 20] + + supervisor.merge_view.emit_cluster_drop(supervisor.merge_view, (20,), 1) + assert supervisor.selected_merge == [10, 20, candidate, 30] + + supervisor.similarity_view.emit_cluster_drop(supervisor.merge_view, (candidate,), 0) + assert supervisor.selected_merge == [10, 20, 30] + assert supervisor.selected_similar == [candidate] + + supervisor.toggle_merge_mode() + assert not supervisor.similarity_view.table_view.dragEnabled() + + +def test_supervisor_merge_control_right_click_transfers(supervisor): + _select(supervisor, [10, 30], [20]) + supervisor.toggle_merge_mode() + candidate = supervisor.similarity_view.get_ids()[0] + + supervisor._promote_similar_on_right_click(supervisor.similarity_view, candidate) + supervisor.block() + assert candidate in supervisor.selected_merge + + supervisor._remove_merge_candidate_on_right_click(supervisor.merge_view, candidate) + supervisor.block() + assert candidate not in supervisor.selected_merge + assert candidate in supervisor.selected_similar + + +def test_closing_merge_view_cancels_mode(supervisor): + _select(supervisor, [30], [20]) + entry = supervisor.selection.snapshot() + supervisor.toggle_merge_mode() + merge_view = supervisor.merge_view + + merge_view.dock.close() + + assert supervisor.selection.state == entry + assert supervisor.merge_view is None + assert supervisor.cluster_view.isEnabled() + + +def test_merge_mode_action_and_cancel_control(supervisor): + _select(supervisor, [30], [20]) + + supervisor.select_actions.toggle_merge_mode() + supervisor.block() + assert supervisor.selection.state.is_merge_mode + + supervisor.merge_view.dock.get_widget('cancel_merge_mode').click() + supervisor.block() + assert not supervisor.selection.state.is_merge_mode + assert supervisor.merge_view is None + + +def test_merge_mode_rejects_cluster_mutations(supervisor): + _select(supervisor, [30], [20]) + supervisor.toggle_merge_mode() + state = supervisor.selection.state + + supervisor.select([10]) + supervisor.split([0, 1]) + supervisor.label('group', 'noise', [30]) + supervisor.sort('id') + supervisor.filter('id > 0') + supervisor.first() + supervisor.next_best() + supervisor.merge([30, 20, 10]) + + assert supervisor.selection.state is state + assert set(supervisor.clustering.cluster_ids) >= {30, 20} + + +def test_merge_mode_next_navigates_similarity_not_cluster(supervisor): + _select(supervisor, [30], [20]) + supervisor.toggle_merge_mode() + before = supervisor.selection.state + + supervisor.next() + supervisor.block() + + assert supervisor.selection.state.is_merge_mode + assert supervisor.selection.state.merge is before.merge + assert supervisor.selected_clusters == [] + assert len(supervisor.selected_similar) == 1 + + +def test_merge_mode_merge_undo_redo_restores_workspace(supervisor): + _select(supervisor, [30], [20]) + assignments_before = supervisor.clustering.spike_clusters.copy() + supervisor.toggle_merge_mode() + candidate = supervisor.similarity_view.get_ids()[0] + supervisor.similarity_view.select([candidate]) + supervisor.block() + merge_before = supervisor.selection.snapshot() + + up = supervisor.merge() + supervisor.block() + + merged_id = up.added[0] + assert not supervisor.selection.state.is_merge_mode + assert supervisor.selected == [merged_id] + assert supervisor.merge_view is None + assert set(up.deleted) == {30, 20, candidate} + assignments_after = supervisor.clustering.spike_clusters.copy() + events = [] + + @connect(sender=supervisor) + def on_select(sender, cluster_ids): + events.append(cluster_ids) + + supervisor.undo() + supervisor.block() + + ae(supervisor.clustering.spike_clusters, assignments_before) + assert supervisor.selection.state == merge_before + assert supervisor.selected_merge == [30, 20] + assert supervisor.selected_similar == [candidate] + assert supervisor.merge_view is not None + assert supervisor.actions.get('redo').isEnabled() + assert events[-1] == list(merge_before.presentation_order) + + supervisor.redo() + supervisor.block() + + ae(supervisor.clustering.spike_clusters, assignments_after) + assert not supervisor.selection.state.is_merge_mode + assert supervisor.selected == [merged_id] + assert supervisor.merge_view is None + assert events[-1] == [merged_id] + unconnect(on_select) + + +def test_uncommitted_merge_workspace_does_not_undo_prior_action(supervisor): + _select(supervisor, [30], [20]) + supervisor.merge() + supervisor.block() + merged_selection = supervisor.selection.snapshot() + supervisor.toggle_merge_mode() + + supervisor.undo() + + assert supervisor.selection.state.is_merge_mode + assert supervisor.selection.state.merge.entry_snapshot.selection == merged_selection + + +def test_failed_merge_preserves_complete_merge_workspace(monkeypatch, supervisor): + _select(supervisor, [30], [20]) + supervisor.toggle_merge_mode() + state = supervisor.selection.state + rows = supervisor.merge_view.get_ids() + + def fail(*args, **kwargs): + raise RuntimeError('merge failed') + + monkeypatch.setattr(supervisor.clustering, 'merge', fail) + with raises(RuntimeError, match='merge failed'): + supervisor.merge() + + assert supervisor.selection.state is state + assert supervisor.merge_view.get_ids() == rows + assert not supervisor.cluster_view.isEnabled() + + +def test_saving_gui_state_cancels_transient_merge_selection(supervisor): + _select(supervisor, [30], [20]) + entry = supervisor.selection.snapshot() + supervisor.toggle_merge_mode() + + supervisor._save_gui_state(supervisor.gui) + + assert supervisor.selection.state == entry + assert supervisor.merge_view is None + + def test_stale_table_selection_revision_is_ignored(supervisor): _select(supervisor, [10], [20]) state = supervisor.selection.state From 5166d72230d3e3bbfd32d1b3e00c464988bb95ec Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 20:29:43 +0200 Subject: [PATCH 013/110] docs: finalize merge view workflow --- design/README.md | 8 +- design/merge-view-architecture.md | 2 +- design/merge-view-workflow.md | 2 +- docs/api.md | 153 ++++++++++++++++++++++++++++++ docs/changelog.md | 7 ++ docs/clustering.md | 22 +++++ docs/gui.md | 6 ++ docs/quickstart.md | 5 + docs/shortcuts.md | 1 + 9 files changed, 200 insertions(+), 6 deletions(-) diff --git a/design/README.md b/design/README.md index 0864a41f..08b05096 100644 --- a/design/README.md +++ b/design/README.md @@ -18,11 +18,11 @@ silently alter the workflow contract. ### Current status -- The manual Merge View workflow has been designed but not implemented. -- The supporting architecture has been audited and a target design proposed. +- The manual Merge View workflow and its supporting architecture are implemented + on the feature branch. - Merge Propositions and `curation.json` are intentionally deferred. -- The next task is the characterization-test and state-model preparation phase - described in the architecture proposal. +- The remaining work is release review and validation of the implemented + workflow; Merge Propositions remain a separate future project. Agents continuing this work should first read the repository `AGENTS.md`, then both Merge View documents completely. Merge, selection, undo/redo, saved cluster diff --git a/design/merge-view-architecture.md b/design/merge-view-architecture.md index 5a48dfc8..89101bf5 100644 --- a/design/merge-view-architecture.md +++ b/design/merge-view-architecture.md @@ -1,6 +1,6 @@ # Merge View architecture proposal -Status: proposed for phy 2.2.0 +Status: implemented for phy 2.2.0 This document describes the internal architecture and incremental refactor recommended for implementing the user behavior fixed in the diff --git a/design/merge-view-workflow.md b/design/merge-view-workflow.md index 8319d465..cc032d40 100644 --- a/design/merge-view-workflow.md +++ b/design/merge-view-workflow.md @@ -1,6 +1,6 @@ # Merge View workflow specification -Status: proposed for phy 2.2.0 +Status: implemented for phy 2.2.0 Companion document: [Merge View architecture proposal](merge-view-architecture.md) diff --git a/docs/api.md b/docs/api.md index 7984a43f..d06ae546 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1669,6 +1669,15 @@ A sortable native Qt table with a compatibility API for legacy callers. --- +#### Table.accepts_cluster_drop + + +**`Table.accepts_cluster_drop(self, source, cluster_ids)`** + +Return whether a source table and payload satisfy this table's policy. + +--- + #### Table.add @@ -1723,6 +1732,33 @@ Compatibility no-op kept for callers from the removed WebEngine path. --- +#### Table.cluster_ids_from_mime + + +**`Table.cluster_ids_from_mime(mime)`** + +Decode and validate a cluster-ID-only MIME payload. + +--- + +#### Table.configure_cluster_drag_drop + + +**`Table.configure_cluster_drag_drop(self, role, *, accepted_roles=(), drag_selected_rows=True)`** + +Enable reusable cluster-ID drag/drop and declare accepted source roles. + +--- + +#### Table.emit_cluster_drop + + +**`Table.emit_cluster_drop(self, source, cluster_ids, insertion)`** + +Emit one domain-neutral transfer/reorder intent. + +--- + #### Table.eval_js @@ -6541,6 +6577,15 @@ Display a table of all clusters with metrics and labels as columns. Derive from --- +#### ClusterView.accepts_cluster_drop + + +**`ClusterView.accepts_cluster_drop(self, source, cluster_ids)`** + +Return whether a source table and payload satisfy this table's policy. + +--- + #### ClusterView.add @@ -6595,6 +6640,33 @@ Compatibility no-op kept for callers from the removed WebEngine path. --- +#### ClusterView.cluster_ids_from_mime + + +**`ClusterView.cluster_ids_from_mime(mime)`** + +Decode and validate a cluster-ID-only MIME payload. + +--- + +#### ClusterView.configure_cluster_drag_drop + + +**`ClusterView.configure_cluster_drag_drop(self, role, *, accepted_roles=(), drag_selected_rows=True)`** + +Enable reusable cluster-ID drag/drop and declare accepted source roles. + +--- + +#### ClusterView.emit_cluster_drop + + +**`ClusterView.emit_cluster_drop(self, source, cluster_ids, insertion)`** + +Emit one domain-neutral transfer/reorder intent. + +--- + #### ClusterView.eval_js @@ -9316,6 +9388,15 @@ in the cluster view. --- +#### SimilarityView.accepts_cluster_drop + + +**`SimilarityView.accepts_cluster_drop(self, source, cluster_ids)`** + +Return whether a source table and payload satisfy this table's policy. + +--- + #### SimilarityView.add @@ -9370,6 +9451,33 @@ Compatibility no-op kept for callers from the removed WebEngine path. --- +#### SimilarityView.cluster_ids_from_mime + + +**`SimilarityView.cluster_ids_from_mime(mime)`** + +Decode and validate a cluster-ID-only MIME payload. + +--- + +#### SimilarityView.configure_cluster_drag_drop + + +**`SimilarityView.configure_cluster_drag_drop(self, role, *, accepted_roles=(), drag_selected_rows=True)`** + +Enable reusable cluster-ID drag/drop and declare accepted source roles. + +--- + +#### SimilarityView.emit_cluster_drop + + +**`SimilarityView.emit_cluster_drop(self, source, cluster_ids, insertion)`** + +Emit one domain-neutral transfer/reorder intent. + +--- + #### SimilarityView.eval_js @@ -9755,6 +9863,15 @@ When this component is attached to a GUI, the following events are emitted: --- +#### Supervisor.add_to_merge + + +**`Supervisor.add_to_merge(self, cluster_ids, insertion=None, callback=None)`** + +Transfer candidate IDs into the Merge workspace. + +--- + #### Supervisor.attach @@ -9937,6 +10054,24 @@ Undo the last undone action. --- +#### Supervisor.remove_from_merge + + +**`Supervisor.remove_from_merge(self, cluster_ids, callback=None)`** + +Transfer staged candidates back to Similarity View. + +--- + +#### Supervisor.reorder_merge + + +**`Supervisor.reorder_merge(self, cluster_ids, insertion, callback=None)`** + +Reorder staged candidates while preserving their color slots. + +--- + #### Supervisor.reset_wizard @@ -10012,6 +10147,15 @@ Add or remove a cluster from the cluster-view selection. --- +#### Supervisor.toggle_merge_mode + + +**`Supervisor.toggle_merge_mode(self, callback=None)`** + +Enter Merge mode, or cancel the active Merge workspace. + +--- + #### Supervisor.undo @@ -10066,6 +10210,15 @@ Selected clusters in the cluster view only. --- +#### Supervisor.selected_merge + + +**`Supervisor.selected_merge`** + +Clusters staged in Merge View, or an empty list in Normal mode. + +--- + #### Supervisor.selected_similar diff --git a/docs/changelog.md b/docs/changelog.md index 372e1e58..5fb50984 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -13,6 +13,13 @@ behavior they verify rather than listed separately. ### Added +- Stage and order manual merge candidates in the new **Merge View**. Press `C` + to enter or cancel Merge mode, transfer candidates with + `Control`-right-click or drag-and-drop, and press `G` to merge every staged + cluster plus the current Similarity View selection. Cluster colors remain + stable, cancellation restores the entry state, and undo restores the full + pre-merge workspace. + - Select the first eligible clusters in the Similarity View with `Control+Space`. The default is 15 clusters; **Select > Select N Similar** changes the number and remembers it across sessions. diff --git a/docs/clustering.md b/docs/clustering.md index 69b24d4e..4476ee7a 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -12,6 +12,28 @@ All spikes belonging to either of the selected clusters will be assigned to that ![image](https://user-images.githubusercontent.com/1942359/58953860-eac37400-8797-11e9-962d-2cf79ea55853.png) +### Staging candidates in Merge View + +Press `C` with at least one Cluster View row selected to open **MERGE MODE**. phy stages the blue +reference first, followed by the other Cluster View selections and the current Similarity View +selection. Cluster View is disabled while the workspace is active, and the plots keep the same +clusters and colors. + +Every row in Merge View is part of the pending merge. Continue exploring Similarity View, where +sorting, filtering, `Control+Space`, and multi-selection remain available. The Merge View status +shows the number of staged clusters, selected similar clusters, and the total that `G` will merge. + +Move candidates between the two views with `Control`-right-click or drag-and-drop. Drag within +Merge View to reorder candidates. The first blue reference cannot be moved or removed, and +transfers or reordering do not change cluster colors or redraw the scientific views unnecessarily. +Press `Backspace` to clear only the Similarity View selection when the merge should contain only +the staged rows. + +Press `G` to commit the staged clusters plus the selected Similarity candidates. Press `C` again, +use **Cancel Merge Mode**, or close Merge View to cancel and restore the exact state from before +entry. Undoing a committed Merge-mode merge restores the complete workspace as it appeared just +before `G`; Redo reapplies the merge and returns to the normal workflow. + ## Splitting clusters diff --git a/docs/gui.md b/docs/gui.md index 8e127a05..8fb91069 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -56,6 +56,12 @@ Control-right-clicking a Similarity View row promotes it into the primary select preserving the current comparison. See [Similarity and the wizard](similarity.md) for the complete workflow. +Press `C` to stage the current selections in Merge View. In this temporary mode, Cluster View is +disabled, every Merge View row is included in the pending merge, and Similarity View remains +available for exploring additional candidates. Control-right-click or drag rows between Merge and +Similarity views, or drag inside Merge View to reorder candidates. Press `G` to commit or `C` to +cancel. See [Staging candidates in Merge View](clustering.md#staging-candidates-in-merge-view). + ## Sorting and filtering Click a Cluster View column header to sort the table. Enter a boolean expression in the filter box diff --git a/docs/quickstart.md b/docs/quickstart.md index 7c332670..719ed632 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -89,6 +89,7 @@ Useful first-session keys include: | Show all shortcuts | `H` | | Select the next similarity candidate | `Space` | | Return to only the Cluster View selection | `Backspace` | +| Enter or cancel Merge mode | `C` | | Merge selected clusters | `G` | | Split a feature selection | `K` | | Undo / redo | `Ctrl+Z` / `Ctrl+Shift+Z` | @@ -113,6 +114,10 @@ If the evidence strongly supports one unit split by the sorter, select the clusters and press `G` to merge. phy gives the result a new cluster ID. Press `Ctrl+Z` immediately if the result is not what you intended. +For a longer comparison, press `C` first. Merge View keeps the candidates staged while you +continue exploring Similarity View. Its status shows exactly how many clusters `G` will merge. +Press `C` again or close Merge View to cancel without changing the clustering. + Splitting requires selecting spikes in a view that supports lasso or polygon selection, commonly the Feature View, and pressing `K`. It is worth learning merge, undo, and save on the example dataset before attempting a scientific diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 76de2119..56f1a49c 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -38,6 +38,7 @@ Keyboard shortcuts - reset ctrl+alt+space - select_first_similar ctrl+space - split k +- toggle_merge_mode c - undo ctrl+z - unselect_similar backspace From 138abda74a4b70bd091c6f2d0d53a35b96b0dad5 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 20:58:03 +0200 Subject: [PATCH 014/110] Advance similar-cluster selection batches --- docs/api.md | 2 +- docs/changelog.md | 5 +++-- phy/cluster/supervisor.py | 10 ++++++++-- phy/cluster/tests/test_supervisor.py | 5 +++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/api.md b/docs/api.md index d06ae546..29134c10 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10107,7 +10107,7 @@ Select a list of clusters. **`Supervisor.select_first_similar(self, n=None, callback=None)`** -Select the first N eligible clusters currently shown in the similarity view. +Select N eligible similar clusters, advancing after the current selection. --- diff --git a/docs/changelog.md b/docs/changelog.md index 5fb50984..1544980e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -21,8 +21,9 @@ behavior they verify rather than listed separately. pre-merge workspace. - Select the first eligible clusters in the Similarity View with - `Control+Space`. The default is 15 clusters; **Select > Select N Similar** - changes the number and remembers it across sessions. + `Control+Space`; repeat the shortcut to select successive batches. The + default is 15 clusters; **Select > Select N Similar** changes the number + and remembers it across sessions. - Skip clusters labeled `noise` or `mua` during wizard navigation and batch similarity selection. **Select > Skip Noise and MUA** controls the behavior and remembers the preference across sessions. diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 6c796dbb..9c8c275c 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1791,13 +1791,19 @@ def reorder_merge(self, cluster_ids, insertion, callback=None): return change.after def select_first_similar(self, n=None, callback=None): - """Select the first N eligible clusters currently shown in the similarity view.""" + """Select N eligible similar clusters, advancing after the current selection.""" + select_from_start = n is not None if n is not None: self.n_similar_clusters_to_select = self._validate_n_similar_clusters_to_select(n) n = self.n_similar_clusters_to_select def select(cluster_ids): - self.similarity_view.select(cluster_ids[:n], callback=callback) + start = 0 + if not select_from_start: + selected = self.similarity_view.get_selected_ids() + if selected and selected[-1] in cluster_ids: + start = cluster_ids.index(selected[-1]) + 1 + self.similarity_view.select(cluster_ids[start : start + n], callback=callback) self.similarity_view.get_navigable_ids(callback=select) diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index b2ae36c0..b8a0fd76 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -824,14 +824,15 @@ def test_supervisor_select_first_similar(qtbot, supervisor, gui): assert supervisor.selected_similar == navigable_ids[:2] assert supervisor.n_similar_clusters_to_select == 2 - # The shortcut variant uses the saved preference and replaces the similar selection. + # The shortcut variant uses the saved preference and advances past the current selection. similarity_view.sort_by('id', 'desc') navigable_ids = similarity_view.get_navigable_ids() + previous_last = navigable_ids.index(similarity_view.get_selected_ids()[-1]) control_modifier = Qt.MetaModifier if sys.platform == 'darwin' else Qt.ControlModifier qtbot.keyClick(gui, Qt.Key_Space, control_modifier) supervisor.block() assert supervisor.selected_clusters == [30] - assert supervisor.selected_similar == navigable_ids[:2] + assert supervisor.selected_similar == navigable_ids[previous_last + 1 : previous_last + 3] # Selecting more rows than are available is safe. supervisor.select_actions.select_n_similar(100) From 2575ae035a436c2b309dcf4062257093bbd4f41b Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 21:14:23 +0200 Subject: [PATCH 015/110] fix: release merge view Qt resources --- phy/cluster/supervisor.py | 26 ++++++++++- phy/cluster/tests/test_merge_lifecycle.py | 53 +++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 phy/cluster/tests/test_merge_lifecycle.py diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 9c8c275c..6236e7ec 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -17,7 +17,7 @@ from phylib.utils import Bunch, connect, emit, unconnect from phy.gui.actions import Actions -from phy.gui.qt import QAbstractItemView, QHeaderView, _block, _wait, set_busy +from phy.gui.qt import QAbstractItemView, QHeaderView, Qt, _block, _wait, set_busy from phy.gui.widgets import Barrier, Table, _uniq from ._history import GlobalHistory @@ -721,6 +721,7 @@ def __init__( self.actions = None # will be set when attaching the GUI self.gui = None self.merge_view = None + self._merge_close_callback = None self._is_dirty = None self._sort = sort # Initial sort requested in the constructor # This is populated alongside the existing TaskLogger-derived selection during the @@ -874,6 +875,12 @@ def _save_gui_state(self, gui): # Compatibility no-op on the native table implementation. self.cluster_view.clear_temporary_files() self.similarity_view.clear_temporary_files() + if self._merge_close_callback is not None: + unconnect(self._merge_close_callback) + self._merge_close_callback = None + # The GUI is closing and Qt will destroy its native QObject. Do not retain the + # corresponding Python wrapper until interpreter shutdown. + self.gui = None def _get_similar_clusters(self, sender, cluster_id): """Return the clusters similar to a given cluster.""" @@ -993,6 +1000,7 @@ def _create_merge_view(self, state=None): connect(self._on_cluster_drop, event='cluster_drop', sender=self.merge_view) connect(self._on_cluster_drop, event='cluster_drop', sender=self.similarity_view) self.gui.add_view(self.merge_view, position='left', closable=True) + self.merge_view.dock.setAttribute(Qt.WA_DeleteOnClose) self.merge_view.dock.add_button( name='cancel_merge_mode', text='Cancel Merge Mode', @@ -1185,10 +1193,22 @@ def _set_merge_mode_ui(self, active): def _close_merge_view(self): view = self.merge_view self.merge_view = None + if view is not None: + self._disconnect_merge_view_events(view) if view is not None and view in self.gui.views: view.dock.close() + if view is not None: + unconnect(view.dock) self.similarity_view.configure_cluster_drag_drop(None) + def _disconnect_merge_view_events(self, view): + """Release event-registry references owned by a temporary Merge View.""" + unconnect( + view, + self._on_cluster_drop, + self._remove_merge_candidate_on_right_click, + ) + def _on_cluster_drop(self, sender, payload): """Translate generic table drops into Merge controller intents.""" if not self.selection.state.is_merge_mode: @@ -1513,9 +1533,13 @@ def attach(self, gui): @connect(event='close_view') def on_close_view(view, sender): if view is self.merge_view: + self._disconnect_merge_view_events(view) + unconnect(view.dock) self.merge_view = None self._cancel_merge_mode(close_view=False) + self._merge_close_callback = on_close_view + gui.add_view(self.cluster_view, position='left', closable=False) gui.add_view(self.similarity_view, position='left', closable=False) diff --git a/phy/cluster/tests/test_merge_lifecycle.py b/phy/cluster/tests/test_merge_lifecycle.py new file mode 100644 index 00000000..ff64d0ff --- /dev/null +++ b/phy/cluster/tests/test_merge_lifecycle.py @@ -0,0 +1,53 @@ +"""Regression tests for temporary Merge View resource cleanup.""" + +from phylib.utils.event import _EVENT + +from phy.gui.tests.conftest import gui # noqa: F401 + +from .test_supervisor import _select, supervisor # noqa: F401 + + +def test_supervisor_merge_mode_releases_temporary_event_callbacks(supervisor): + _select(supervisor, [30], [20]) + + def callbacks_for(callback): + return [entry for entry in _EVENT._callbacks if entry[2] == callback] + + def retained_by_event_callback(obj): + retained = [] + for entry in _EVENT._callbacks: + callback = entry[2] + cells = getattr(callback, '__closure__', ()) or () + if any(cell.cell_contents is obj for cell in cells): + retained.append(entry) + return retained + + assert callbacks_for(supervisor._on_cluster_drop) == [] + assert callbacks_for(supervisor._remove_merge_candidate_on_right_click) == [] + + for _ in range(2): + supervisor.toggle_merge_mode() + merge_view = supervisor.merge_view + + assert len(callbacks_for(supervisor._on_cluster_drop)) == 2 + assert len(callbacks_for(supervisor._remove_merge_candidate_on_right_click)) == 1 + + supervisor.toggle_merge_mode() + + assert callbacks_for(supervisor._on_cluster_drop) == [] + assert callbacks_for(supervisor._remove_merge_candidate_on_right_click) == [] + assert all( + sender not in (merge_view, merge_view.dock) for _, sender, _, _ in _EVENT._callbacks + ) + assert retained_by_event_callback(merge_view) == [] + assert retained_by_event_callback(merge_view.dock) == [] + + close_callback = supervisor._merge_close_callback + assert close_callback is not None + assert callbacks_for(close_callback) + + supervisor._save_gui_state(supervisor.gui) + + assert callbacks_for(close_callback) == [] + assert supervisor._merge_close_callback is None + assert supervisor.gui is None From 048acb9467eca87b29e99831cbb8767987bf0bd4 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 21:57:20 +0200 Subject: [PATCH 016/110] fix: refine merge mode interactions --- phy/cluster/_selection.py | 12 ++--- phy/cluster/supervisor.py | 27 ++++++++-- phy/cluster/tests/test_selection.py | 10 ++-- phy/cluster/tests/test_supervisor.py | 78 ++++++++++++++++++++++++---- phy/gui/widgets.py | 55 ++++++++++++++++++++ 5 files changed, 156 insertions(+), 26 deletions(-) diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index 15ef07d4..de779f8f 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -84,8 +84,8 @@ class CurationSelectionState: """The authoritative, immutable curation selection. ``presentation_order`` is the effective selection in the order delivered - to scientific views. It is independent from the two role-specific orders - so a future role transfer can leave colors and redraw state untouched. + to scientific views. In Merge mode it is derived from the visible roles: + Merge View order first, followed by Similarity View selection order. """ mode: WorkflowMode = WorkflowMode.NORMAL @@ -127,7 +127,7 @@ def __post_init__(self): ) presentation_order = ( default_presentation - if self.presentation_order is None + if self.presentation_order is None or self.mode is WorkflowMode.MERGE else _as_unique_ids(self.presentation_order) ) @@ -379,12 +379,10 @@ def add_to_merge(self, cluster_ids, insertion=None): similar_ids = tuple( cluster_id for cluster_id in current.similar_ids if cluster_id not in new_ids ) - presentation_order = _ordered_union(current.presentation_order, new_ids) after = CurationSelectionState( mode=WorkflowMode.MERGE, similar_ids=similar_ids, reference_id=current.reference_id, - presentation_order=presentation_order, merge=merge, ) return self._apply(after) @@ -406,13 +404,12 @@ def remove_from_merge(self, cluster_ids): mode=WorkflowMode.MERGE, similar_ids=_ordered_union(current.similar_ids, cluster_ids), reference_id=current.reference_id, - presentation_order=current.presentation_order, merge=merge, ) return self._apply(after) def reorder_merge(self, cluster_ids, insertion): - """Move staged candidates to an insertion point without changing colors.""" + """Move staged candidates to an insertion point.""" self._require_merge_mode() cluster_ids = _as_unique_ids(cluster_ids) current = self._state @@ -431,7 +428,6 @@ def reorder_merge(self, cluster_ids, insertion): mode=WorkflowMode.MERGE, similar_ids=current.similar_ids, reference_id=current.reference_id, - presentation_order=current.presentation_order, merge=merge, ) return self._apply(after) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 6236e7ec..c0d7d301 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -420,7 +420,10 @@ def __init__(self, *args, data=None, columns=(), **kwargs): sort=None, ) self.filter_edit.hide() - self.table_view.setSelectionMode(QAbstractItemView.NoSelection) + # A current row is required by QAbstractItemView to initiate a drag. The + # selection is local interaction state only; every Merge row remains part of + # the scientific selection projected through ``_selected_ids``. + self.table_view.setSelectionMode(QAbstractItemView.SingleSelection) def _on_row_clicked(self, index): """Rows are workspace members, not an independent selection.""" @@ -474,7 +477,7 @@ class ActionCreator: # Qt maps Meta to the physical Control key on macOS. 'select_first_similar': 'meta+space' if sys.platform == 'darwin' else 'ctrl+space', 'unselect_similar': 'backspace', - 'toggle_merge_mode': 'c', + 'toggle_merge_mode': 'v', 'next_best': 'down', 'previous_best': 'up', # Misc. @@ -722,6 +725,7 @@ def __init__( self.gui = None self.merge_view = None self._merge_close_callback = None + self._merge_dock_state = None self._is_dirty = None self._sort = sort # Initial sort requested in the constructor # This is populated alongside the existing TaskLogger-derived selection during the @@ -1000,6 +1004,13 @@ def _create_merge_view(self, state=None): connect(self._on_cluster_drop, event='cluster_drop', sender=self.merge_view) connect(self._on_cluster_drop, event='cluster_drop', sender=self.similarity_view) self.gui.add_view(self.merge_view, position='left', closable=True) + if self._merge_dock_state is not None: + self.gui.restoreState(self._merge_dock_state['window']) + if self._merge_dock_state['floating']: + self.merge_view.dock.setFloating(True) + self.merge_view.dock.restoreGeometry(self._merge_dock_state['geometry']) + else: + self.gui.splitDockWidget(self.cluster_view.dock, self.merge_view.dock, Qt.Vertical) self.merge_view.dock.setAttribute(Qt.WA_DeleteOnClose) self.merge_view.dock.add_button( name='cancel_merge_mode', @@ -1156,7 +1167,9 @@ def _apply_selection_change(self, change, callback=None): self.cluster_view._schedule_callback(callback, state) def _set_merge_mode_ui(self, active): - self.cluster_view.setEnabled(not active) + self.cluster_view._set_interaction_overlay( + 'CLUSTER VIEW DISABLED IN MERGE MODE\nPress V to re-enable it.' if active else None + ) if active: self.cluster_view.dock.set_status('MERGE MODE — Cluster View disabled') else: @@ -1193,6 +1206,12 @@ def _set_merge_mode_ui(self, active): def _close_merge_view(self): view = self.merge_view self.merge_view = None + if view is not None and view in self.gui.views: + self._merge_dock_state = { + 'window': self.gui.saveState(), + 'floating': view.dock.isFloating(), + 'geometry': view.dock.saveGeometry(), + } if view is not None: self._disconnect_merge_view_events(view) if view is not None and view in self.gui.views: @@ -1809,7 +1828,7 @@ def remove_from_merge(self, cluster_ids, callback=None): return change.after def reorder_merge(self, cluster_ids, insertion, callback=None): - """Reorder staged candidates while preserving their color slots.""" + """Reorder staged candidates and their scientific presentation order.""" change = self.selection.reorder_merge(cluster_ids, insertion) self._apply_selection_change(change, callback=callback) return change.after diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index 68d4dd93..5408241d 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -211,7 +211,7 @@ def test_enter_merge_mode_requires_cluster_selection(): controller.enter_merge_mode() -def test_merge_candidate_transfer_and_reorder_preserve_color_order(): +def test_merge_candidate_transfer_and_reorder_follow_visible_role_order(): controller = CurationSelectionController( CurationSelectionState(cluster_ids=(1, 2), similar_ids=(3,), reference_id=1) ) @@ -223,17 +223,19 @@ def test_merge_candidate_transfer_and_reorder_preserve_color_order(): change = controller.add_to_merge((4,)) assert change.after.merge_ids == (1, 2, 3, 4) assert change.after.similar_ids == (5,) + assert change.after.presentation_order == (1, 2, 3, 4, 5) assert not change.presentation_changed change = controller.remove_from_merge((2,)) assert change.after.merge_ids == (1, 3, 4) assert change.after.similar_ids == (5, 2) - assert not change.presentation_changed + assert change.after.presentation_order == (1, 3, 4, 5, 2) + assert change.presentation_changed change = controller.reorder_merge((4,), 1) assert change.after.merge_ids == (1, 4, 3) - assert change.after.presentation_order == (1, 2, 3, 4, 5) - assert not change.presentation_changed + assert change.after.presentation_order == (1, 4, 3, 5, 2) + assert change.presentation_changed def test_merge_candidate_guards_reference_and_duplicate_membership(): diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index b8a0fd76..2293365b 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -15,7 +15,7 @@ from phy.gui import GUI from phy.gui.actions import _get_shortcut_string -from phy.gui.qt import QHeaderView, Qt, qInstallMessageHandler +from phy.gui.qt import QAbstractItemView, QHeaderView, Qt, qInstallMessageHandler from phy.gui.tests.test_widgets import _assert, _wait_until_table_ready from phy.gui.widgets import Barrier from phy.utils.color import selected_cluster_color @@ -419,7 +419,10 @@ def on_select(sender, cluster_ids): assert isinstance(supervisor.merge_view, MergeView) assert supervisor.merge_view.get_ids() == [10, 30, 20, 11] assert supervisor.merge_view.dock.get_widget('cancel_merge_mode') is not None - assert not supervisor.cluster_view.isEnabled() + assert supervisor.cluster_view.isEnabled() + assert supervisor.cluster_view._interaction_blocked + assert supervisor.cluster_view._interaction_overlay.isVisible() + assert 'Press V' in supervisor.cluster_view._interaction_overlay.text() assert events == [] supervisor.similarity_view.filter('id < 20') @@ -428,12 +431,35 @@ def on_select(sender, cluster_ids): assert supervisor.selection.state == entry assert supervisor.merge_view is None assert supervisor.cluster_view.isEnabled() + assert not supervisor.cluster_view._interaction_blocked assert supervisor._workflow_context() == context assert events == [] unconnect(on_select) -def test_supervisor_merge_candidate_interactions_preserve_colors(supervisor): +def test_supervisor_merge_view_opens_below_cluster_and_restores_position(qtbot, supervisor): + _select(supervisor, [30], [20]) + + supervisor.toggle_merge_mode() + qtbot.wait(10) + cluster_rect = supervisor.cluster_view.dock.geometry() + merge_rect = supervisor.merge_view.dock.geometry() + assert merge_rect.top() >= cluster_rect.bottom() + + supervisor.merge_view.dock.setFloating(True) + supervisor.merge_view.dock.move(70, 80) + qtbot.wait(10) + floating_position = supervisor.merge_view.dock.pos() + + supervisor.toggle_merge_mode() + supervisor.toggle_merge_mode() + qtbot.wait(10) + + assert supervisor.merge_view.dock.isFloating() + assert supervisor.merge_view.dock.pos() == floating_position + + +def test_supervisor_merge_candidate_interactions_follow_visible_role_order(supervisor): _select(supervisor, [10, 30], [20]) supervisor.toggle_merge_mode() candidate = supervisor.similarity_view.get_ids()[0] @@ -456,24 +482,53 @@ def on_select(sender, cluster_ids): supervisor.remove_from_merge(30) assert supervisor.selected_merge == [10, 20, candidate] assert supervisor.selected_similar == [30] - assert supervisor.selected == [10, 30, 20, candidate] - assert events == [] + assert supervisor.selected == [10, 20, candidate, 30] + assert events == [[10, 20, candidate, 30]] + events.clear() supervisor.reorder_merge((candidate,), 1) assert supervisor.selected_merge == [10, candidate, 20] - assert supervisor.selected == [10, 30, 20, candidate] - assert events == [] + assert supervisor.selected == [10, candidate, 20, 30] + assert events == [[10, candidate, 20, 30]] assert supervisor.merge_view._selected_color_index(10) == 0 - assert supervisor.similarity_view._selected_color_index(30) == 1 + assert supervisor.merge_view._selected_color_index(candidate) == 1 assert supervisor.merge_view._selected_color_index(20) == 2 - assert supervisor.merge_view._selected_color_index(candidate) == 3 + assert supervisor.similarity_view._selected_color_index(30) == 3 unconnect(on_select) +def test_merge_backspace_removes_similarity_tail_without_recoloring_merge(supervisor): + _select(supervisor, [10, 30], [20]) + supervisor.toggle_merge_mode() + candidate = supervisor.similarity_view.get_ids()[0] + supervisor.similarity_view.select([candidate]) + supervisor.block() + colors_before = { + cluster_id: supervisor.merge_view._selected_color_index(cluster_id) + for cluster_id in supervisor.selected_merge + } + + supervisor.select_actions.unselect_similar() + supervisor.block() + + assert supervisor.selected == supervisor.selected_merge + assert supervisor.selected_similar == [] + assert { + cluster_id: supervisor.merge_view._selected_color_index(cluster_id) + for cluster_id in supervisor.selected_merge + } == colors_before + + def test_supervisor_merge_drag_drop_intents(supervisor): _select(supervisor, [10, 30], [20]) supervisor.toggle_merge_mode() candidate = supervisor.similarity_view.get_ids()[0] + assert supervisor.merge_view.table_view.selectionMode() == QAbstractItemView.SingleSelection + movable = supervisor.merge_view._proxy_index_for_id(30) + supervisor.merge_view.table_view.setCurrentIndex(movable) + assert supervisor.merge_view._drag_ids_for_index(movable) == (30,) + reference = supervisor.merge_view._proxy_index_for_id(10) + assert supervisor.merge_view._drag_ids_for_index(reference) == () supervisor.merge_view.emit_cluster_drop(supervisor.similarity_view, (candidate,), 1) assert supervisor.selected_merge == [10, candidate, 30, 20] @@ -636,7 +691,7 @@ def fail(*args, **kwargs): assert supervisor.selection.state is state assert supervisor.merge_view.get_ids() == rows - assert not supervisor.cluster_view.isEnabled() + assert supervisor.cluster_view._interaction_blocked def test_saving_gui_state_cancels_transient_merge_selection(supervisor): @@ -936,6 +991,9 @@ def test_supervisor_select_first_similar_config(gui, cluster_ids, similarity): shortcut = supervisor.select_actions.get('select_first_similar').shortcut() expected_shortcut = 'meta+space' if sys.platform == 'darwin' else 'ctrl+space' assert _get_shortcut_string(shortcut) == expected_shortcut + assert ( + _get_shortcut_string(supervisor.select_actions.get('toggle_merge_mode').shortcut()) == 'v' + ) with raises(ValueError, match='positive integer'): supervisor.select_first_similar(0) diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index 68f06a5c..2f6cc630 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -602,6 +602,9 @@ def __init__( self._drag_role = None self._accepted_drag_roles = set() self._drag_selected_rows = True + self._interaction_overlay = None + self._interaction_blocked = False + self._selection_mode_before_block = None self._group_colors = { 'good': QColor('#86D16D'), 'mua': QColor('#afafaf'), @@ -667,6 +670,43 @@ def configure_cluster_drag_drop( QAbstractItemView.DragDrop if enabled else QAbstractItemView.NoDragDrop ) + def _set_interaction_overlay(self, text=None): + """Block table editing while retaining scrolling, with an explanatory overlay.""" + blocked = bool(text) + if blocked == self._interaction_blocked: + if blocked: + self._interaction_overlay.setText(text) + return + self._interaction_blocked = blocked + if blocked: + self._selection_mode_before_block = self.table_view.selectionMode() + self.table_view.setSelectionMode(QAbstractItemView.NoSelection) + self.filter_edit.setEnabled(False) + self.table_view.horizontalHeader().setEnabled(False) + if self._interaction_overlay is None: + overlay = QLabel(self.table_view.viewport()) + overlay.setAlignment(Qt.AlignCenter) + overlay.setWordWrap(True) + overlay.setAttribute(Qt.WA_TransparentForMouseEvents) + overlay.setStyleSheet( + 'background-color: rgba(0, 0, 0, 150); color: #dddddd; ' + 'font-size: 18px; font-weight: bold; padding: 24px;' + ) + self._interaction_overlay = overlay + self._interaction_overlay.setText(text) + self._interaction_overlay.setGeometry(self.table_view.viewport().rect()) + self._interaction_overlay.show() + self._interaction_overlay.raise_() + else: + if self._interaction_overlay is not None: + self._interaction_overlay.hide() + self.filter_edit.setEnabled(True) + self.table_view.horizontalHeader().setEnabled(True) + self.table_view.setSelectionMode( + self._selection_mode_before_block or QAbstractItemView.ExtendedSelection + ) + self._selection_mode_before_block = None + def _drag_ids_for_index(self, index): if self._drag_role is None or not index.isValid(): return () @@ -720,6 +760,21 @@ def debouncer(self): return self._debouncer def eventFilter(self, obj, event): + if obj is self.table_view.viewport() and event.type() == QEvent.Resize: + if self._interaction_overlay is not None: + self._interaction_overlay.setGeometry(self.table_view.viewport().rect()) + if ( + self._interaction_blocked + and obj is self.table_view.viewport() + and event.type() + in { + QEvent.MouseButtonPress, + QEvent.MouseButtonRelease, + QEvent.MouseButtonDblClick, + QEvent.MouseMove, + } + ): + return True if ( obj is self.filter_edit and event.type() == QEvent.MouseButtonPress From 0c4084d752733584cd0957eb03acc3902e98000f Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 21:57:30 +0200 Subject: [PATCH 017/110] docs: align merge mode workflow --- design/merge-view-architecture.md | 20 ++++++++++---------- design/merge-view-workflow.md | 11 ++++++----- docs/api.md | 2 +- docs/changelog.md | 10 ++++++---- docs/clustering.md | 17 +++++++++-------- docs/gui.md | 12 +++++++----- docs/quickstart.md | 6 +++--- docs/shortcuts.md | 2 +- 8 files changed, 43 insertions(+), 37 deletions(-) diff --git a/design/merge-view-architecture.md b/design/merge-view-architecture.md index 89101bf5..490808b2 100644 --- a/design/merge-view-architecture.md +++ b/design/merge-view-architecture.md @@ -15,9 +15,9 @@ table. It introduces: - an explicit workflow mode; - a third cluster role alongside Cluster and Similarity; -- role transfers that must not redraw scientific views; +- an explicit presentation order derived from the two active roles; - a fixed blue Similarity reference; -- stable colors independent of Merge row order; +- positional colors that follow Merge row order and Similarity selection order; - exact cancellation to an entry snapshot; and - restoration of the complete workspace after undoing a committed merge. @@ -143,9 +143,10 @@ The refactor should establish the following invariants: independent domain authorities. 3. Similarity reference is an explicit cluster ID. 4. The reference occupies the blue presentation slot. -5. Merge row order and presentation/color order are independent. -6. Moving a cluster between Similarity and Merge does not change the effective - selection or emit a public selection update. +5. Presentation/color order is Merge row order followed by Similarity selection + order. +6. Moving a cluster between Similarity and Merge does not change membership, + but emits a public selection update when it changes presentation order. 7. Related state changes are applied transactionally; observers see only valid before and after states. 8. Cancellation restores the exact entry snapshot. @@ -536,8 +537,7 @@ restoration is best effort where Qt exposes a reliable value. - Add tests for selection order, effective selection, multi-Cluster Similarity reference, positional colors, Ctrl+Space, Backspace, merge follow-up, undo/redo selection restoration, debouncing, and plugin-facing events. -- Record exact event counts for transfers and merges where redraw suppression is - important. +- Record exact event counts for transfers, reorders, and merges. ### Phase 1: state model in observation mode @@ -557,7 +557,7 @@ restoration is best effort where Qt exposes a reliable value. - Make Similarity reference explicit. - Establish the reviewed blue-reference invariant. -- Separate presentation order from table-role order. +- Derive Merge-mode presentation order from table-role order. - Update characterization tests for the intentionally approved behavior change. ### Phase 4: transactional actions and contextual history @@ -578,7 +578,7 @@ restoration is best effort where Qt exposes a reliable value. - Add reusable native-table drag support. - Connect it to controller transfer and reorder intents. -- Verify multi-row behavior and no-redraw invariants across platforms. +- Verify multi-row behavior and presentation updates across platforms. ### Phase 7: TaskLogger decomposition and cleanup @@ -724,7 +724,7 @@ Do not silently weaken these central invariants to simplify implementation: - the reference is blue and fixed in Merge View; - all entry selections transfer into Merge View; -- role-only transfers do not redraw scientific views; +- Merge-mode transfers and reorders publish their resulting presentation order; - cancellation restores the exact entry state; - `G` has one user-facing meaning in each visibly distinct mode; and - undoing a Merge-mode merge restores the complete pre-commit workspace. diff --git a/design/merge-view-workflow.md b/design/merge-view-workflow.md index cc032d40..21e6162e 100644 --- a/design/merge-view-workflow.md +++ b/design/merge-view-workflow.md @@ -45,7 +45,7 @@ normal workflow on cancellation. At minimum, this includes: - Cluster View selection and its order; - Similarity View selection and its order; - the Similarity reference; -- cluster-to-color assignments; and +- presentation order and cluster colors; and - any filter, sort, scroll, or navigation state changed by entering the mode. All selected clusters are transferred into Merge View in this order: @@ -61,7 +61,7 @@ After the transfer: navigated, filtered, sorted, or used as a drag source or target; - Similarity View remains enabled and remains calculated relative to the blue reference; and -- the effective selection, graphical displays, and cluster colors are unchanged. +- the effective selection and graphical displays initially remain unchanged. Staged clusters are not offered again as active Similarity candidates while they remain in Merge View. @@ -102,9 +102,10 @@ Ctrl+right-click transfers only the clicked row. Dragging a selected Similarity row transfers all selected rows; dragging an unselected row transfers only that row. -Cluster-to-color assignments belong to cluster IDs, not row positions. Adding, -removing, or reordering rows must not reassign colors. Clicking or reordering a -Merge row does not change the scientific views. +In Merge mode, the presentation order delivered to scientific views is always +the Merge View row order followed by the Similarity View selection order. +Adding, removing, or reordering rows may therefore reassign positional colors +and redraw order-dependent scientific views. ## Exploring Similarity diff --git a/docs/api.md b/docs/api.md index 29134c10..3dd306c2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10068,7 +10068,7 @@ Transfer staged candidates back to Similarity View. **`Supervisor.reorder_merge(self, cluster_ids, insertion, callback=None)`** -Reorder staged candidates while preserving their color slots. +Reorder staged candidates and their scientific presentation order. --- diff --git a/docs/changelog.md b/docs/changelog.md index 1544980e..46d7f519 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -13,12 +13,14 @@ behavior they verify rather than listed separately. ### Added -- Stage and order manual merge candidates in the new **Merge View**. Press `C` +- Stage and order manual merge candidates in the new **Merge View**. Press `V` to enter or cancel Merge mode, transfer candidates with `Control`-right-click or drag-and-drop, and press `G` to merge every staged - cluster plus the current Similarity View selection. Cluster colors remain - stable, cancellation restores the entry state, and undo restores the full - pre-merge workspace. + cluster plus the current Similarity View selection. Merge View opens below + Cluster View and remembers its in-session dock position; the dimmed Cluster + View remains scrollable. Scientific views follow Merge View order and then + Similarity View selection order. Cancellation restores the entry state, and + undo restores the full pre-merge workspace. - Select the first eligible clusters in the Similarity View with `Control+Space`; repeat the shortcut to select successive batches. The diff --git a/docs/clustering.md b/docs/clustering.md index 4476ee7a..efc2afde 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -14,10 +14,10 @@ All spikes belonging to either of the selected clusters will be assigned to that ### Staging candidates in Merge View -Press `C` with at least one Cluster View row selected to open **MERGE MODE**. phy stages the blue +Press `V` with at least one Cluster View row selected to open **MERGE MODE**. phy stages the blue reference first, followed by the other Cluster View selections and the current Similarity View -selection. Cluster View is disabled while the workspace is active, and the plots keep the same -clusters and colors. +selection. Cluster View is dimmed and read-only while the workspace is active, but it remains +scrollable; its overlay reminds you that `V` returns to the normal workflow. Every row in Merge View is part of the pending merge. Continue exploring Similarity View, where sorting, filtering, `Control+Space`, and multi-selection remain available. The Merge View status @@ -25,11 +25,12 @@ shows the number of staged clusters, selected similar clusters, and the total th Move candidates between the two views with `Control`-right-click or drag-and-drop. Drag within Merge View to reorder candidates. The first blue reference cannot be moved or removed, and -transfers or reordering do not change cluster colors or redraw the scientific views unnecessarily. -Press `Backspace` to clear only the Similarity View selection when the merge should contain only -the staged rows. +the selection order shown by scientific views is always the Merge View order followed by the +Similarity View selection order. Transfers and reordering can therefore update cluster colors and +redraw order-dependent views. Press `Backspace` to clear only the Similarity View selection when +the merge should contain only the staged rows. -Press `G` to commit the staged clusters plus the selected Similarity candidates. Press `C` again, +Press `G` to commit the staged clusters plus the selected Similarity candidates. Press `V` again, use **Cancel Merge Mode**, or close Merge View to cancel and restore the exact state from before entry. Undoing a committed Merge-mode merge restores the complete workspace as it appeared just before `G`; Redo reapplies the merge and returns to the normal workflow. @@ -58,7 +59,7 @@ You can move up and down in the **cluster view** with the `Up` and `Down` arrows You can move up and down in the **similarity view** with the `Space` and `Shift-space` arrows. The cluster selected in the similarity view is called the **similar cluster**. The idea is to go through every "best cluster" in the cluster view, and review the "similar clusters" in the similarity view (sorted by decreasing similarity with the best cluster). -Press `Control+Space` to select the first 15 eligible clusters currently shown in the similarity view while preserving the cluster view selection. This uses the current similarity view sorting and filtering, and replaces any previous similarity view selection. To choose a different number, use **Select > Select N Similar**; the chosen number becomes the shortcut's new default and is remembered across sessions. +Press `Control+Space` to select the first 15 eligible clusters currently shown in the similarity view while preserving the cluster view selection. Repeat it to select the next batch. This uses the current similarity view sorting and filtering. To choose a different number, use **Select > Select N Similar**; the chosen number becomes the shortcut's new default and is remembered across sessions. Wizard navigation skips clusters labeled `noise` or `mua` by default. To include them when moving through either table or when selecting N similar clusters, uncheck **Select > Skip Noise and MUA**. This preference is remembered across sessions. Direct selection with the mouse, a cluster ID, or a snippet can always select these clusters. Code that creates a `Supervisor` can choose the initial behavior with `skip_masked_clusters=False`; saved GUI state takes precedence when present. diff --git a/docs/gui.md b/docs/gui.md index 8fb91069..408b8a8a 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -56,11 +56,13 @@ Control-right-clicking a Similarity View row promotes it into the primary select preserving the current comparison. See [Similarity and the wizard](similarity.md) for the complete workflow. -Press `C` to stage the current selections in Merge View. In this temporary mode, Cluster View is -disabled, every Merge View row is included in the pending merge, and Similarity View remains -available for exploring additional candidates. Control-right-click or drag rows between Merge and -Similarity views, or drag inside Merge View to reorder candidates. Press `G` to commit or `C` to -cancel. See [Staging candidates in Merge View](clustering.md#staging-candidates-in-merge-view). +Press `V` to stage the current selections in Merge View. It opens below Cluster View and remembers +where you move it while toggling the mode. In this temporary mode, Cluster View is dimmed and +read-only but remains scrollable, every Merge View row is included in the pending merge, and +Similarity View remains available for exploring additional candidates. Control-right-click or drag +rows between Merge and Similarity views, or drag inside Merge View to reorder candidates. Press `G` +to commit or `V` to cancel. See +[Staging candidates in Merge View](clustering.md#staging-candidates-in-merge-view). ## Sorting and filtering diff --git a/docs/quickstart.md b/docs/quickstart.md index 719ed632..20183d58 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -89,7 +89,7 @@ Useful first-session keys include: | Show all shortcuts | `H` | | Select the next similarity candidate | `Space` | | Return to only the Cluster View selection | `Backspace` | -| Enter or cancel Merge mode | `C` | +| Enter or cancel Merge mode | `V` | | Merge selected clusters | `G` | | Split a feature selection | `K` | | Undo / redo | `Ctrl+Z` / `Ctrl+Shift+Z` | @@ -114,9 +114,9 @@ If the evidence strongly supports one unit split by the sorter, select the clusters and press `G` to merge. phy gives the result a new cluster ID. Press `Ctrl+Z` immediately if the result is not what you intended. -For a longer comparison, press `C` first. Merge View keeps the candidates staged while you +For a longer comparison, press `V` first. Merge View keeps the candidates staged while you continue exploring Similarity View. Its status shows exactly how many clusters `G` will merge. -Press `C` again or close Merge View to cancel without changing the clustering. +Press `V` again or close Merge View to cancel without changing the clustering. Splitting requires selecting spikes in a view that supports lasso or polygon selection, commonly the Feature View, and pressing `K`. It is worth learning diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 56f1a49c..efaca7f3 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -38,7 +38,7 @@ Keyboard shortcuts - reset ctrl+alt+space - select_first_similar ctrl+space - split k -- toggle_merge_mode c +- toggle_merge_mode v - undo ctrl+z - unselect_similar backspace From b4c64b153938c35bad2f699d0a8af38c0d884861 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 21:59:52 +0200 Subject: [PATCH 018/110] Fix firing rate view normalization --- docs/changelog.md | 2 ++ phy/cluster/views/histogram.py | 19 +++++++++++++---- phy/cluster/views/tests/test_histogram.py | 26 ++++++++++++++++++++++- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 46d7f519..498a7d7b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -50,6 +50,8 @@ behavior they verify rather than listed separately. ### Fixed +- Display Firing Rate View values in spikes per second instead of normalized + probability density, with the configured bin count matching the rendered bins. - Start the GUI with released phylib versions that do not yet expose the disjoint-spike selection optimization hint. - Keep dataset-local view settings isolated from global GUI state. In diff --git a/phy/cluster/views/histogram.py b/phy/cluster/views/histogram.py index 1596ae22..c4ac4584 100644 --- a/phy/cluster/views/histogram.py +++ b/phy/cluster/views/histogram.py @@ -31,7 +31,7 @@ def _compute_histogram( assert x_min <= x_max assert n_bins >= 0 n_bins = _clip(n_bins, 2, 1000000) - bins = np.linspace(float(x_min), float(x_max), int(n_bins)) + bins = np.linspace(float(x_min), float(x_max), int(n_bins) + 1) if ignore_zeros: data = data[data != 0] histogram, _ = np.histogram(data, bins=bins) @@ -151,6 +151,9 @@ def _plot_cluster(self, bunch): box_index=bunch.index, ) + def _compute_histogram(self, data): + return _compute_histogram(data, x_min=self.x_min, x_max=self.x_max, n_bins=self.n_bins) + def get_clusters_data(self, load_all=None): bunchs = [] for i, cluster_id in enumerate(self.cluster_ids): @@ -167,9 +170,7 @@ def get_clusters_data(self, load_all=None): assert self.x_min <= self.x_max # Compute the histogram. - bunch.histogram = _compute_histogram( - bunch.data, x_min=self.x_min, x_max=self.x_max, n_bins=self.n_bins - ) + bunch.histogram = self._compute_histogram(bunch.data) bunch.ylim = bunch.histogram.max() bunch.color = selected_cluster_color(i) @@ -414,3 +415,13 @@ class FiringRateView(HistogramView): f'set_x_min ({bin_unit})': f'{alias_char}min', f'set_x_max ({bin_unit})': f'{alias_char}max', } + + def _compute_histogram(self, data): + counts = _compute_histogram( + data, + x_min=self.x_min, + x_max=self.x_max, + n_bins=self.n_bins, + normalize=False, + ) + return counts / self.bin_size diff --git a/phy/cluster/views/tests/test_histogram.py b/phy/cluster/views/tests/test_histogram.py index fec10fd4..2f74165d 100644 --- a/phy/cluster/views/tests/test_histogram.py +++ b/phy/cluster/views/tests/test_histogram.py @@ -7,7 +7,7 @@ import numpy as np from phylib.utils import Bunch -from ..histogram import FiringRateView, HistogramView, ISIView +from ..histogram import FiringRateView, HistogramView, ISIView, _compute_histogram from . import _stop_and_close # ------------------------------------------------------------------------------ @@ -15,6 +15,13 @@ # ------------------------------------------------------------------------------ +def test_compute_histogram_n_bins(): + histogram = _compute_histogram( + np.array([0.5, 1.5, 2.5, 3.5]), x_min=0, x_max=4, n_bins=4, normalize=False + ) + np.testing.assert_array_equal(histogram, np.ones(4)) + + def test_histogram_view_0(qtbot, gui): data = np.random.uniform(low=0, high=10, size=5000) # plot = .1 * np.random.uniform(low=0, high=.5, size=1000) @@ -88,6 +95,23 @@ def test_firing_rate_view_ignores_global_x_max(qtbot, gui): _stop_and_close(qtbot, v) +def test_firing_rate_view_displays_spikes_per_second(qtbot): + v = FiringRateView( + cluster_stat=lambda cluster_id: Bunch( + data=np.arange(0.125, 2, 0.25), + x_min=0.0, + x_max=2.0, + ) + ) + v.n_bins = 2 + v.cluster_ids = [0] + + bunch = v.get_clusters_data()[0] + + np.testing.assert_array_equal(bunch.histogram, np.array([4.0, 4.0])) + _stop_and_close(qtbot, v) + + def test_histogram_view_settings(qtbot, gui, monkeypatch): v = ISIView( cluster_stat=lambda cluster_id: Bunch( From 62d0fca71d8db0002afda95b44d3a1450442d50b Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:10:11 +0200 Subject: [PATCH 019/110] fix: enable merge table drag and drop --- docs/changelog.md | 2 + phy/cluster/tests/test_supervisor.py | 3 ++ phy/gui/tests/test_widgets.py | 46 +++++++++++++++++- phy/gui/widgets.py | 70 ++++++++++++++++++++++++---- 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 498a7d7b..ab50c2f9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -50,6 +50,8 @@ behavior they verify rather than listed separately. ### Fixed +- Keep the disabled Cluster View overlay fixed while scrolling in Merge mode, + increase its dimming, and make native table rows initiate drag-and-drop. - Display Firing Rate View values in spikes per second instead of normalized probability density, with the configured bin count matching the rendered bins. - Start the GUI with released phylib versions that do not yet expose the diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 2293365b..ba18290a 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -523,6 +523,9 @@ def test_supervisor_merge_drag_drop_intents(supervisor): _select(supervisor, [10, 30], [20]) supervisor.toggle_merge_mode() candidate = supervisor.similarity_view.get_ids()[0] + assert supervisor.merge_view.table_view.acceptDrops() + assert supervisor.similarity_view.table_view.acceptDrops() + assert not supervisor.cluster_view.table_view.acceptDrops() assert supervisor.merge_view.table_view.selectionMode() == QAbstractItemView.SingleSelection movable = supervisor.merge_view._proxy_index_for_id(30) supervisor.merge_view.table_view.setCurrentIndex(movable) diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index 8978063a..352d8e7e 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -7,10 +7,11 @@ import sys from functools import partial +import numpy as np from phylib.utils import connect, unconnect -from pytest import fixture, mark +from pytest import fixture, mark, raises -from ..qt import QHeaderView, QMimeData, Qt +from ..qt import QApplication, QEvent, QHeaderView, QMimeData, QMouseEvent, Qt from ..widgets import Barrier, IPythonView, KeyValueWidget, Table, ViewSettingsDialog from . import show_and_wait from .test_qt import _block @@ -69,11 +70,33 @@ def test_table_cluster_drag_drop_policy_and_payload(table, qtbot): assert table._drag_ids_for_index(table._proxy_index_for_id(1)) == (1, 2) assert table._drag_ids_for_index(table._proxy_index_for_id(3)) == (3,) + assert table._proxy_index_for_id(1).flags() & Qt.ItemIsDragEnabled + assert target._proxy_index_for_id(10).flags() & Qt.ItemIsDropEnabled + unrelated = Table(columns=['id'], data=[{'id': 20}]) + qtbot.addWidget(unrelated) + assert not unrelated.table_view.acceptDrops() + assert not unrelated._proxy_index_for_id(20).flags() & Qt.ItemIsDropEnabled + + started = [] + table.table_view.startDrag = lambda actions: started.append(actions) + index = table._proxy_index_for_id(3) + pos = table.table_view.visualRect(index).center() + qtbot.mousePress(table.table_view.viewport(), Qt.LeftButton, pos=pos) + end = pos + type(pos)(QApplication.startDragDistance() + 1, 0) + table.table_view.mouseMoveEvent( + QMouseEvent(QEvent.MouseMove, end, Qt.NoButton, Qt.LeftButton, Qt.NoModifier) + ) + qtbot.mouseRelease(table.table_view.viewport(), Qt.LeftButton, pos=pos) + assert started mime = QMimeData() mime.setData('application/x-phy-cluster-ids', b'[1, 2, 2]') assert target.cluster_ids_from_mime(mime) == (1, 2) assert target.accepts_cluster_drop(table, (1, 2)) + numpy_mime = table._cluster_ids_to_mime((np.int64(1), np.int32(2))) + assert bytes(numpy_mime.data('application/x-phy-cluster-ids')) == b'[1, 2]' + with raises(TypeError, match='integer IDs'): + table._cluster_ids_to_mime((1, 'spikes')) drops = [] @@ -734,3 +757,22 @@ def on_table_filter(sender, row_ids): _block(lambda: emitted == [[0, 1, 2]]) unconnect(on_table_filter) + + +def test_table_interaction_overlay_stays_fixed_while_scrolling(qtbot): + table = Table(columns=['id'], data=[{'id': i} for i in range(100)]) + _wait_until_table_ready(qtbot, table) + table.resize(240, 180) + table._set_interaction_overlay('DISABLED') + qtbot.wait(1) + + geometry = table._interaction_overlay.geometry() + scrollbar = table.table_view.verticalScrollBar() + assert scrollbar.maximum() > 0 + scrollbar.setValue(scrollbar.maximum()) + qtbot.wait(1) + + assert table._interaction_overlay.parent() is table.table_view + assert table._interaction_overlay.geometry() == geometry + assert 'rgba(0, 0, 0, 220)' in table._interaction_overlay.styleSheet() + table.close() diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index 2f6cc630..4be8edf6 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -390,6 +390,15 @@ def data(self, index, role=Qt.DisplayRole): return fg return None + def flags(self, index): + """Advertise drag/drop support when the owning table enables it.""" + flags = super().flags(index) + if index.isValid() and self._table._drag_role is not None: + flags |= Qt.ItemIsDragEnabled + if self._table._accepted_drag_roles: + flags |= Qt.ItemIsDropEnabled + return flags + def set_rows(self, rows): self.beginResetModel() self._rows = list(rows) @@ -528,17 +537,47 @@ class _TableView(QTableView): def __init__(self, owner): super().__init__(owner) self._owner = owner + self._drag_start_pos = None + self._drag_start_index = QModelIndex() def startDrag(self, supported_actions): - ids = self._owner._drag_ids_for_index(self.currentIndex()) + index = self._drag_start_index if self._drag_start_index.isValid() else self.currentIndex() + ids = self._owner._drag_ids_for_index(index) if not ids: return - mime = QMimeData() - mime.setData(_CLUSTER_IDS_MIME, json.dumps(ids).encode('utf8')) drag = QDrag(self) - drag.setMimeData(mime) + drag.setMimeData(self._owner._cluster_ids_to_mime(ids)) drag.exec_(Qt.MoveAction) + def mousePressEvent(self, event): + super().mousePressEvent(event) + self._drag_start_pos = None + self._drag_start_index = QModelIndex() + if event.button() != Qt.LeftButton or self._owner._drag_role is None: + return + index = self.indexAt(event.pos()) + if self._owner._drag_ids_for_index(index): + self._drag_start_pos = event.pos() + self._drag_start_index = index + + def mouseMoveEvent(self, event): + if ( + self._drag_start_pos is not None + and event.buttons() & Qt.LeftButton + and (event.pos() - self._drag_start_pos).manhattanLength() + >= QApplication.startDragDistance() + ): + self.startDrag(Qt.MoveAction) + self._drag_start_pos = None + self._drag_start_index = QModelIndex() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + self._drag_start_pos = None + self._drag_start_index = QModelIndex() + super().mouseReleaseEvent(event) + def dragEnterEvent(self, event): if self._owner._accept_cluster_drop_event(event): event.acceptProposedAction() @@ -684,17 +723,19 @@ def _set_interaction_overlay(self, text=None): self.filter_edit.setEnabled(False) self.table_view.horizontalHeader().setEnabled(False) if self._interaction_overlay is None: - overlay = QLabel(self.table_view.viewport()) + # Parent the overlay to the view frame, not its scrolling viewport: + # QAbstractScrollArea moves viewport children with its contents. + overlay = QLabel(self.table_view) overlay.setAlignment(Qt.AlignCenter) overlay.setWordWrap(True) overlay.setAttribute(Qt.WA_TransparentForMouseEvents) overlay.setStyleSheet( - 'background-color: rgba(0, 0, 0, 150); color: #dddddd; ' + 'background-color: rgba(0, 0, 0, 220); color: #dddddd; ' 'font-size: 18px; font-weight: bold; padding: 24px;' ) self._interaction_overlay = overlay self._interaction_overlay.setText(text) - self._interaction_overlay.setGeometry(self.table_view.viewport().rect()) + self._interaction_overlay.setGeometry(self.table_view.viewport().geometry()) self._interaction_overlay.show() self._interaction_overlay.raise_() else: @@ -718,6 +759,19 @@ def _drag_ids_for_index(self, index): return tuple(self._selected_visible_ids()) return (clicked,) + @staticmethod + def _cluster_ids_to_mime(cluster_ids): + """Encode integer-like cluster IDs as native JSON integers.""" + cluster_ids = tuple(cluster_ids) + if any(not _is_integer(cluster_id) for cluster_id in cluster_ids): + raise TypeError('Cluster drag payloads require integer IDs.') + mime = QMimeData() + mime.setData( + _CLUSTER_IDS_MIME, + json.dumps([int(cluster_id) for cluster_id in cluster_ids]).encode('utf8'), + ) + return mime + @staticmethod def cluster_ids_from_mime(mime): """Decode and validate a cluster-ID-only MIME payload.""" @@ -762,7 +816,7 @@ def debouncer(self): def eventFilter(self, obj, event): if obj is self.table_view.viewport() and event.type() == QEvent.Resize: if self._interaction_overlay is not None: - self._interaction_overlay.setGeometry(self.table_view.viewport().rect()) + self._interaction_overlay.setGeometry(self.table_view.viewport().geometry()) if ( self._interaction_blocked and obj is self.table_view.viewport() From 167263daf4672109a2478ce4cf572bbe5208451c Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:24:04 +0200 Subject: [PATCH 020/110] Align scientific views with table order --- phy/apps/base.py | 22 --- phy/cluster/_selection.py | 57 +----- phy/cluster/supervisor.py | 138 ++++++++------ phy/cluster/tests/test_selection.py | 65 ++----- phy/cluster/tests/test_supervisor.py | 200 ++++++++------------ phy/cluster/views/correlogram.py | 15 +- phy/cluster/views/tests/test_correlogram.py | 28 --- 7 files changed, 174 insertions(+), 351 deletions(-) diff --git a/phy/apps/base.py b/phy/apps/base.py index 26da75c7..657da82e 100644 --- a/phy/apps/base.py +++ b/phy/apps/base.py @@ -1957,27 +1957,6 @@ def create_correlogram_view(self): sample_rate=self.model.sample_rate, ) - @connect(sender=view) - def on_request_promote_similar(sender, cluster_id_a, cluster_id_b): - selected_clusters = set(self.supervisor.selected_clusters) - selected_similar = set(self.supervisor.selected_similar) - logger.debug( - 'Correlogram promotion request for (%s, %s); clusters=%s, similar=%s.', - cluster_id_a, - cluster_id_b, - sorted(selected_clusters), - sorted(selected_similar), - ) - for cluster_id, other_cluster_id in ( - (cluster_id_a, cluster_id_b), - (cluster_id_b, cluster_id_a), - ): - if cluster_id in selected_similar and other_cluster_id in selected_clusters: - logger.debug('Promote similarity cluster %s from correlogram.', cluster_id) - emit('action', self.supervisor.action_creator, 'promote_similar', cluster_id) - return - logger.debug('Correlogram pair does not span ClusterView and SimilarityView.') - @connect(sender=view) def on_view_attached(view_, gui): def validate(values): @@ -2055,7 +2034,6 @@ def edit_view_settings(): @connect(sender=view) def on_close_view(view_, gui): - unconnect(on_request_promote_similar) unconnect(on_view_attached) return view diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index de779f8f..e619c2a6 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -84,7 +84,8 @@ class CurationSelectionState: """The authoritative, immutable curation selection. ``presentation_order`` is the effective selection in the order delivered - to scientific views. In Merge mode it is derived from the visible roles: + to scientific views. The Supervisor derives Normal-mode order from the + visible role tables. In Merge mode it is derived from the visible roles: Merge View order first, followed by Similarity View selection order. """ @@ -275,54 +276,6 @@ def clear_similarity_selection(self): """Clear only the Similarity View selection.""" return self.set_similarity_selection(()) - def transfer_cluster_to_similarity(self, cluster_ids): - """Move Cluster View IDs to Similarity View without changing presentation.""" - self._require_normal_mode() - cluster_ids = _as_unique_ids(cluster_ids) - source_ids = set(cluster_ids) - current = self._state - if not source_ids <= set(current.cluster_ids): - raise ValueError('Transferred IDs must belong to the cluster selection.') - if current.reference_id in source_ids: - raise ValueError('The reference ID cannot move to the similarity selection.') - remaining_clusters = tuple(i for i in current.cluster_ids if i not in source_ids) - similar_ids = _ordered_union(current.similar_ids, cluster_ids) - reference_id = ( - current.reference_id - if current.reference_id in remaining_clusters - else (remaining_clusters[-1] if remaining_clusters else None) - ) - after = CurationSelectionState( - cluster_ids=remaining_clusters, - similar_ids=similar_ids, - reference_id=reference_id, - presentation_order=current.presentation_order, - ) - return self._apply(after) - - def transfer_similarity_to_cluster(self, cluster_ids): - """Move Similarity View IDs to Cluster View without changing presentation.""" - self._require_normal_mode() - cluster_ids = _as_unique_ids(cluster_ids) - source_ids = set(cluster_ids) - current = self._state - if not source_ids <= set(current.similar_ids): - raise ValueError('Transferred IDs must belong to the similarity selection.') - similar_ids = tuple(i for i in current.similar_ids if i not in source_ids) - cluster_selection = _ordered_union(current.cluster_ids, cluster_ids) - reference_id = ( - current.reference_id - if current.reference_id is not None - else (cluster_ids[0] if cluster_ids else None) - ) - after = CurationSelectionState( - cluster_ids=cluster_selection, - similar_ids=similar_ids, - reference_id=reference_id, - presentation_order=current.presentation_order, - ) - return self._apply(after) - def enter_merge_mode(self, workflow_context=None): """Stage the complete Normal-mode selection and enter Merge mode.""" self._require_normal_mode() @@ -336,11 +289,7 @@ def enter_merge_mode(self, workflow_context=None): presentation_order=current.presentation_order, workflow_context=workflow_context, ) - ordered_ids = _ordered_union( - (current.reference_id,), - current.cluster_ids, - current.similar_ids, - ) + ordered_ids = current.presentation_order merge = MergeSession(current.reference_id, ordered_ids, snapshot) after = CurationSelectionState( mode=WorkflowMode.MERGE, diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index c0d7d301..78a481af 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -21,7 +21,7 @@ from phy.gui.widgets import Barrier, Table, _uniq from ._history import GlobalHistory -from ._selection import CurationSelectionController +from ._selection import CurationSelectionController, SelectionChange from ._utils import create_cluster_meta from .clustering import Clustering @@ -726,6 +726,7 @@ def __init__( self.merge_view = None self._merge_close_callback = None self._merge_dock_state = None + self._suspend_presentation_order_sync = False self._is_dirty = None self._sort = sort # Initial sort requested in the constructor # This is populated alongside the existing TaskLogger-derived selection during the @@ -926,8 +927,12 @@ def _restore_table_workflow_state(view, state): def _restore_workflow_context(self, context): if not context: return - self._restore_table_workflow_state(self.cluster_view, context.get('cluster')) - self._restore_table_workflow_state(self.similarity_view, context.get('similarity')) + self._suspend_presentation_order_sync = True + try: + self._restore_table_workflow_state(self.cluster_view, context.get('cluster')) + self._restore_table_workflow_state(self.similarity_view, context.get('similarity')) + finally: + self._suspend_presentation_order_sync = False def get_cluster_info(self, cluster_id, exclude=()): """Return the data associated to a given cluster.""" @@ -957,11 +962,7 @@ def _create_views(self, gui=None, sort=None): ) # Update the action flow and similarity view when selection changes. connect(self._clusters_selected, event='select', sender=self.cluster_view) - connect( - self._demote_cluster_on_right_click, - event='row_right_click', - sender=self.cluster_view, - ) + connect(self._table_order_changed, event='table_sort', sender=self.cluster_view) # Create the similarity view. self.similarity_view = SimilarityView( @@ -976,8 +977,9 @@ def _create_views(self, gui=None, sort=None): sender=self.similarity_view, ) connect(self._similar_selected, event='select', sender=self.similarity_view) + connect(self._table_order_changed, event='table_sort', sender=self.similarity_view) connect( - self._promote_similar_on_right_click, + self._add_similar_to_merge_on_right_click, event='row_right_click', sender=self.similarity_view, ) @@ -1087,6 +1089,7 @@ def _clusters_selected(self, sender, obj, **kwargs): # Update the similarity view when the cluster view selection changes. self.similarity_view.reset(cluster_ids, reference_id=change.after.reference_id) self.similarity_view.set_selected_ids(()) + change = self._normalize_presentation_order(change) self._update_selection_colors() # Emit supervisor.select event unless update_views is False. This happens after # a merge event, where the views should not be updated after the first cluster_view.select @@ -1109,7 +1112,8 @@ def _similar_selected(self, sender, obj): next_similar = obj['next'] kwargs = obj.get('kwargs', {}) logger.debug('Similar clusters selected: %s (%s)', similar, next_similar) - self.selection.set_similarity_selection(similar) + change = self.selection.set_similarity_selection(similar) + change = self._normalize_presentation_order(change) self._update_selection_colors() self._project_merge_view() self.task_logger.log(self.similarity_view, 'select', similar, output=obj) @@ -1118,6 +1122,57 @@ def _similar_selected(self, sender, obj): self.similarity_view.scroll_to(similar[-1]) self.similarity_view.dock.set_status(f'similar clusters: {", ".join(map(str, similar))}') + @staticmethod + def _ids_in_table_order(view, cluster_ids): + """Return selected IDs in row order, retaining filtered-out IDs at the end.""" + cluster_ids = tuple(cluster_ids) + selected = set(cluster_ids) + visible = [cluster_id for cluster_id in view.get_ids() if cluster_id in selected] + visible_set = set(visible) + return tuple(visible) + tuple( + cluster_id for cluster_id in cluster_ids if cluster_id not in visible_set + ) + + def _normalize_presentation_order(self, change): + """Derive scientific-view order from the active workflow's visible role order.""" + state = change.after + similar_ids = self._ids_in_table_order(self.similarity_view, state.similar_ids) + if state.is_merge_mode: + normalized = self.selection.set_similarity_selection(similar_ids) + else: + cluster_ids = self._ids_in_table_order(self.cluster_view, state.cluster_ids) + presentation_order = tuple( + dict.fromkeys( + ( + *((state.reference_id,) if state.reference_id is not None else ()), + *cluster_ids, + *similar_ids, + ) + ) + ) + normalized = self.selection.set_normal_selection( + state.cluster_ids, + state.similar_ids, + reference_id=state.reference_id, + presentation_order=presentation_order, + ) + return SelectionChange.create(change.before, normalized.after) + + def _table_order_changed(self, sender, row_ids): + """Keep scientific-view order synchronized with selected table rows.""" + if self._suspend_presentation_order_sync: + return + if sender is self.cluster_view and self.selection.state.is_merge_mode: + return + state = self.selection.state + change = SelectionChange.create(state, state) + change = self._normalize_presentation_order(change) + if not change.presentation_changed: + return + self._update_selection_colors() + self._project_merge_view() + emit('select', self, list(change.after.presentation_order)) + def _update_selection_colors(self): """Project authoritative presentation positions into both role tables.""" order = self.selection.state.presentation_order @@ -1140,13 +1195,16 @@ def _merge_status_text(self): similar = len(state.similar_ids) return f'MERGE MODE — {staged} staged + {similar} selected similar = {staged + similar} clusters' - def _apply_selection_change(self, change, callback=None): + def _apply_selection_change(self, change, callback=None, normalize_order=True): """Project one complete controller transition and publish it atomically.""" state = change.after cluster_payload = self.cluster_view.set_selected_ids(state.cluster_ids) if state.reference_id is not None: self.similarity_view.reset(state.merge_ids, reference_id=state.reference_id) similar_payload = self.similarity_view.set_selected_ids(state.similar_ids) + if normalize_order: + change = self._normalize_presentation_order(change) + state = change.after self._update_selection_colors() self._project_merge_view() self.task_logger.log( @@ -1254,7 +1312,7 @@ def _cancel_merge_mode(self, close_view=True): context = self.selection.state.merge.entry_snapshot.workflow_context change = self.selection.cancel_merge_mode() self._set_merge_mode_ui(False) - self._apply_selection_change(change) + self._apply_selection_change(change, normalize_order=False) self._restore_workflow_context(context) if close_view: self._close_merge_view() @@ -1267,7 +1325,7 @@ def _restore_history_context(self, selection, workflow_context, direction): elif not selection.is_merge_mode: self._set_merge_mode_ui(False) change = self.selection.restore(selection) - self._apply_selection_change(change) + self._apply_selection_change(change, normalize_order=False) if selection.is_merge_mode: context = ( workflow_context.get('tables') @@ -1332,17 +1390,15 @@ def _select_after_move(self, selection_before, moved_cluster_ids): change = self.selection.set_normal_selection(next_clusters, next_similar) self._apply_selection_change(change) - def _promote_similar_on_right_click(self, sender, cluster_id): - """Promote a right-clicked similarity row through the normal action queue.""" - emit('action', self.action_creator, 'promote_similar', cluster_id) + def _add_similar_to_merge_on_right_click(self, sender, cluster_id): + """Transfer a right-clicked Similarity row only into an active Merge workspace.""" + if not self.selection.state.is_merge_mode: + return + self.add_to_merge((cluster_id,)) def _remove_merge_candidate_on_right_click(self, sender, cluster_id): emit('action', self.action_creator, 'remove_from_merge', cluster_id) - def _demote_cluster_on_right_click(self, sender, cluster_id): - """Demote a right-clicked cluster row through the normal action queue.""" - emit('action', self.action_creator, 'demote_cluster', cluster_id) - def _on_action(self, sender, name, *args): """Called when an action is triggered: enqueue and process the task.""" assert sender == self.action_creator @@ -1359,7 +1415,6 @@ def _on_action(self, sender, name, *args): 'reset_wizard', 'next_best', 'previous_best', - 'demote_cluster', 'undo', }: logger.warning('Action `%s` is unavailable in Merge mode.', name) @@ -1865,47 +1920,6 @@ def set_skip_masked_clusters(self, skip_masked, callback=None): if callback: callback(self.skip_masked_clusters) - def promote_similar(self, cluster_id, callback=None): - """Move a similarity row into the cluster view while preserving all other selections.""" - if self.selection.state.is_merge_mode: - return self.add_to_merge((cluster_id,), callback=callback) - state = self.selection.state - if cluster_id in state.similar_ids: - change = self.selection.transfer_similarity_to_cluster((cluster_id,)) - elif cluster_id not in state.cluster_ids: - cluster_ids = list(state.cluster_ids) - cluster_ids.append(cluster_id) - change = self.selection.set_normal_selection( - cluster_ids, - state.similar_ids, - reference_id=state.reference_id or cluster_id, - presentation_order=(*state.presentation_order, cluster_id), - ) - else: - change = self.selection.restore(state) - self._apply_selection_change(change, callback=callback) - - def demote_cluster(self, cluster_id, callback=None): - """Move a selected cluster row into the similarity view.""" - state = self.selection.state - if cluster_id not in state.cluster_ids or cluster_id == state.reference_id: - if callback: - callback(None) - return - change = self.selection.transfer_cluster_to_similarity((cluster_id,)) - self._apply_selection_change(change, callback=callback) - - def toggle_cluster_selection(self, cluster_id, callback=None): - """Add or remove a cluster from the cluster-view selection.""" - if self._reject_cluster_action_in_merge_mode('toggle_cluster_selection'): - return - cluster_ids = list(self.selected_clusters) - if cluster_id in cluster_ids: - cluster_ids.remove(cluster_id) - else: - cluster_ids.append(cluster_id) - self.cluster_view.select(cluster_ids, callback=callback) - def first(self, callback=None): """Select the first cluster in the cluster view.""" if self._reject_cluster_action_in_merge_mode('first'): diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index 5408241d..c10cc7cb 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -89,56 +89,6 @@ def test_set_normal_selection_replaces_all_roles_atomically(): assert change.after.presentation_order == (1, 3, 2) -def test_role_transfers_leave_effective_presentation_unchanged(): - controller = CurationSelectionController( - CurationSelectionState( - cluster_ids=(1, 2), - similar_ids=(3,), - reference_id=1, - presentation_order=(1, 2, 3), - ) - ) - - change = controller.transfer_cluster_to_similarity((2,)) - assert change.after.cluster_ids == (1,) - assert change.after.similar_ids == (3, 2) - assert change.after.presentation_order == (1, 2, 3) - assert change.roles_changed - assert not change.presentation_changed - - change = controller.transfer_similarity_to_cluster((3,)) - assert change.after.cluster_ids == (1, 3) - assert change.after.similar_ids == (2,) - assert change.after.presentation_order == (1, 2, 3) - assert change.roles_changed - assert not change.presentation_changed - - -def test_zero_reference_survives_similarity_to_cluster_transfer(): - controller = CurationSelectionController( - CurationSelectionState(cluster_ids=(0,), similar_ids=(4,), reference_id=0) - ) - - change = controller.transfer_similarity_to_cluster((4,)) - - assert change.after.cluster_ids == (0, 4) - assert change.after.reference_id == 0 - assert change.after.presentation_order == (0, 4) - - -def test_role_transfer_rejects_ids_not_in_the_source_selection(): - controller = CurationSelectionController( - CurationSelectionState(cluster_ids=(1,), similar_ids=(2,), reference_id=1) - ) - - with raises(ValueError, match='cluster selection'): - controller.transfer_cluster_to_similarity((2,)) - with raises(ValueError, match='similarity selection'): - controller.transfer_similarity_to_cluster((1,)) - with raises(ValueError, match='reference'): - controller.transfer_cluster_to_similarity((1,)) - - def test_snapshot_restore_and_noop_change_classification(): controller = CurationSelectionController( CurationSelectionState(cluster_ids=(1,), similar_ids=(2,), reference_id=1) @@ -205,6 +155,21 @@ def test_enter_and_cancel_merge_mode_restore_exact_entry_selection(): assert not change.presentation_changed +def test_enter_merge_mode_stages_normal_presentation_order(): + initial = CurationSelectionState( + cluster_ids=(1, 2), + similar_ids=(3, 4), + reference_id=1, + presentation_order=(1, 2, 4, 3), + ) + controller = CurationSelectionController(initial) + + change = controller.enter_merge_mode() + + assert change.after.merge_ids == initial.presentation_order + assert not change.presentation_changed + + def test_enter_merge_mode_requires_cluster_selection(): controller = CurationSelectionController(CurationSelectionState(similar_ids=(2,))) with raises(ValueError, match='Cluster View selection'): diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index ba18290a..f58b1bd6 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -356,47 +356,6 @@ def test_supervisor_selection_is_independent_from_task_log(supervisor): assert supervisor.selected == [30, 20] -def test_selection_shadow_tracks_cross_view_transfers(supervisor): - _select(supervisor, [10, 30], [20, 11, 1]) - - supervisor.promote_similar(11) - supervisor.block() - assert supervisor.selection.state.cluster_ids == (10, 30, 11) - assert supervisor.selection.state.similar_ids == (20, 1) - assert supervisor.selection.state.reference_id == 10 - assert supervisor.selection.state.presentation_order == tuple(supervisor.selected) - - supervisor.demote_cluster(30) - supervisor.block() - assert supervisor.selection.state.cluster_ids == (10, 11) - assert supervisor.selection.state.similar_ids == (20, 1, 30) - assert supervisor.selection.state.reference_id == 10 - assert supervisor.selection.state.presentation_order == tuple(supervisor.selected) - - -def test_cross_view_role_transfers_preserve_public_selection_and_colors(supervisor): - _select(supervisor, [10, 30], [20, 11]) - events = [] - - @connect(sender=supervisor) - def on_select(sender, cluster_ids): - events.append(cluster_ids) - - supervisor.promote_similar(11) - supervisor.block() - supervisor.demote_cluster(30) - supervisor.block() - - assert events == [] - assert supervisor.selected == [10, 30, 20, 11] - assert supervisor.cluster_view._selected_color_index(10) == 0 - assert supervisor.similarity_view._selected_color_index(30) == 1 - assert supervisor.similarity_view._selected_color_index(20) == 2 - assert supervisor.cluster_view._selected_color_index(11) == 3 - - unconnect(on_select) - - def test_supervisor_merge_mode_lifecycle_restores_entry_state(supervisor): _select(supervisor, [10, 30], [20, 11]) entry = supervisor.selection.snapshot() @@ -547,12 +506,36 @@ def test_supervisor_merge_drag_drop_intents(supervisor): assert not supervisor.similarity_view.table_view.dragEnabled() -def test_supervisor_merge_control_right_click_transfers(supervisor): +def test_supervisor_control_right_click_transfers_only_in_merge_mode(qtbot, supervisor): _select(supervisor, [10, 30], [20]) + candidate = next( + cluster_id + for cluster_id in supervisor.similarity_view.get_ids() + if cluster_id not in supervisor.selected_similar + ) + index = supervisor.similarity_view._proxy_index_for_id(candidate) + pos = supervisor.similarity_view.table_view.visualRect(index).center() + control_modifier = Qt.MetaModifier if sys.platform == 'darwin' else Qt.ControlModifier + + qtbot.mouseClick( + supervisor.similarity_view.table_view.viewport(), + Qt.RightButton, + control_modifier, + pos=pos, + ) + supervisor.block() + assert candidate not in supervisor.selected_clusters + supervisor.toggle_merge_mode() - candidate = supervisor.similarity_view.get_ids()[0] + index = supervisor.similarity_view._proxy_index_for_id(candidate) + pos = supervisor.similarity_view.table_view.visualRect(index).center() - supervisor._promote_similar_on_right_click(supervisor.similarity_view, candidate) + qtbot.mouseClick( + supervisor.similarity_view.table_view.viewport(), + Qt.RightButton, + control_modifier, + pos=pos, + ) supervisor.block() assert candidate in supervisor.selected_merge @@ -845,6 +828,55 @@ def expected_rgb(index): unconnect(on_request_similar_clusters) +def test_normal_presentation_follows_table_order_and_resorting(supervisor): + _select(supervisor, [30]) + similarity_view = supervisor.similarity_view + similarity_view.sort_by('id', 'asc') + + # Select out of row order: presentation and positional colors still follow the table. + similarity_view.select([20, 1, 11]) + supervisor.block() + assert supervisor.selected_similar == [20, 1, 11] + assert supervisor.selected == [30, 1, 11, 20] + assert similarity_view._selected_color_index(1) == 1 + assert similarity_view._selected_color_index(11) == 2 + assert similarity_view._selected_color_index(20) == 3 + + events = [] + + @connect(sender=supervisor) + def on_select(sender, cluster_ids): + events.append(cluster_ids) + + similarity_view.sort_by('id', 'desc') + + assert supervisor.selected == [30, 20, 11, 1] + assert events == [[30, 20, 11, 1]] + assert similarity_view._selected_color_index(20) == 1 + assert similarity_view._selected_color_index(11) == 2 + assert similarity_view._selected_color_index(1) == 3 + unconnect(on_select) + + +def test_merge_presentation_keeps_merge_order_before_similarity_table_order(supervisor): + _select(supervisor, [30]) + supervisor.toggle_merge_mode() + similarity_view = supervisor.similarity_view + similarity_view.sort_by('id', 'asc') + + similarity_view.select([20, 1, 11]) + supervisor.block() + assert supervisor.selected == [30, 1, 11, 20] + + supervisor.add_to_merge((11,), insertion=1) + assert supervisor.selected_merge == [30, 11] + assert supervisor.selected == [30, 11, 1, 20] + + similarity_view.sort_by('id', 'desc') + assert supervisor.selected_merge == [30, 11] + assert supervisor.selected == [30, 11, 20, 1] + + def test_supervisor_select_event_has_legacy_payload_and_suppression(supervisor): events = [] @@ -1047,80 +1079,6 @@ def test_supervisor_skip_masked_constructor_and_invalid_state(gui, cluster_ids, assert not supervisor.select_actions.get('skip_noise_and_mua').isChecked() -def test_supervisor_promote_similar_with_control_right_click(qtbot, supervisor): - _select(supervisor, [10, 30], [20, 11, 1]) - similarity_view = supervisor.similarity_view - similarity_view.sort_by('id', 'asc') - similarity_view.filter('id >= 1') - - index = similarity_view._proxy_index_for_id(11) - pos = similarity_view.table_view.visualRect(index).center() - qtbot.mouseClick(similarity_view.table_view.viewport(), Qt.RightButton, pos=pos) - supervisor.block() - assert supervisor.selected_clusters == [10, 30] - assert supervisor.selected_similar == [20, 11, 1] - - control_modifier = Qt.MetaModifier if sys.platform == 'darwin' else Qt.ControlModifier - qtbot.mouseClick( - similarity_view.table_view.viewport(), Qt.RightButton, control_modifier, pos=pos - ) - supervisor.block() - - assert supervisor.selected_clusters == [10, 30, 11] - assert supervisor.selected_similar == [20, 1] - assert supervisor.selected == [10, 30, 20, 11, 1] - assert 11 not in similarity_view.get_ids() - - -def test_supervisor_promote_unselected_similar_with_control_right_click(qtbot, supervisor): - _select(supervisor, [30], [20, 11]) - similarity_view = supervisor.similarity_view - - index = similarity_view._proxy_index_for_id(1) - pos = similarity_view.table_view.visualRect(index).center() - control_modifier = Qt.MetaModifier if sys.platform == 'darwin' else Qt.ControlModifier - qtbot.mouseClick( - similarity_view.table_view.viewport(), Qt.RightButton, control_modifier, pos=pos - ) - supervisor.block() - - assert supervisor.selected_clusters == [30, 1] - assert supervisor.selected_similar == [20, 11] - - -def test_supervisor_demote_cluster_with_control_right_click(qtbot, supervisor): - _select(supervisor, [10, 30], [20, 11]) - cluster_view = supervisor.cluster_view - control_modifier = Qt.MetaModifier if sys.platform == 'darwin' else Qt.ControlModifier - - index = cluster_view._proxy_index_for_id(30) - pos = cluster_view.table_view.visualRect(index).center() - qtbot.mouseClick(cluster_view.table_view.viewport(), Qt.RightButton, control_modifier, pos=pos) - supervisor.block() - - assert supervisor.selected_clusters == [10] - assert supervisor.selected_similar == [20, 11, 30] - assert supervisor.selected == [10, 30, 20, 11] - - index = cluster_view._proxy_index_for_id(10) - pos = cluster_view.table_view.visualRect(index).center() - qtbot.mouseClick(cluster_view.table_view.viewport(), Qt.RightButton, control_modifier, pos=pos) - supervisor.block() - - # Keep one cluster as the similarity reference. - assert supervisor.selected_clusters == [10] - assert supervisor.selected_similar == [20, 11, 30] - - index = cluster_view._proxy_index_for_id(1) - pos = cluster_view.table_view.visualRect(index).center() - qtbot.mouseClick(cluster_view.table_view.viewport(), Qt.RightButton, control_modifier, pos=pos) - supervisor.block() - - # Rows outside the Cluster View selection cannot be transferred. - assert supervisor.selected_clusters == [10] - assert supervisor.selected_similar == [20, 11, 30] - - def test_supervisor_control_left_click_toggles_selection_in_each_view(qtbot, supervisor): _select(supervisor, [10, 30], [20]) control_modifier = Qt.MetaModifier if sys.platform == 'darwin' else Qt.ControlModifier @@ -1337,7 +1295,7 @@ def test_supervisor_split_0(qtbot, supervisor): supervisor.actions.split([1, 2]) supervisor.block() - _assert_selected(supervisor, [31, 32, 33]) + _assert_selected(supervisor, [31, 33, 32]) selection_after = supervisor.selection.snapshot() supervisor.actions.undo() @@ -1347,7 +1305,7 @@ def test_supervisor_split_0(qtbot, supervisor): supervisor.actions.redo() supervisor.block() - _assert_selected(supervisor, [31, 32, 33]) + _assert_selected(supervisor, [31, 33, 32]) assert supervisor.selection.state == selection_after @@ -1361,7 +1319,7 @@ def on_request_split(sender): supervisor.actions.split() supervisor.block() - _assert_selected(supervisor, [31, 32, 33]) + _assert_selected(supervisor, [31, 33, 32]) def test_supervisor_split_2(gui, similarity): diff --git a/phy/cluster/views/correlogram.py b/phy/cluster/views/correlogram.py index 3cb474b8..6e5c2046 100644 --- a/phy/cluster/views/correlogram.py +++ b/phy/cluster/views/correlogram.py @@ -9,7 +9,7 @@ import numpy as np from phylib.io.array import _clip -from phylib.utils import Bunch, emit +from phylib.utils import Bunch from phy.plot.transform import Scale from phy.plot.visuals import HistogramVisual, LineVisual, TextVisual @@ -236,19 +236,6 @@ def toggle_labels(self, checked): self.text_visual.hide() self.canvas.update() - def on_mouse_release(self, e): - """Promote a similarity cluster after a stationary secondary click.""" - if e.button != 'Right' or len(self.cluster_ids) < 2: - return - press_pos = self.canvas._mouse_press_position - if press_pos is None or np.linalg.norm(np.asarray(e.pos) - press_pos) > 5: - return - (i, j), _ = self.canvas.grid.box_map(e.pos) - logger.debug('Correlogram secondary click at %s maps to cell (%d, %d).', e.pos, i, j) - if i == j: - return - emit('request_promote_similar', self, self.cluster_ids[i], self.cluster_ids[j]) - def attach(self, gui): """Attach the view to the GUI.""" super().attach(gui) diff --git a/phy/cluster/views/tests/test_correlogram.py b/phy/cluster/views/tests/test_correlogram.py index 45d3cee9..a0cf90a6 100644 --- a/phy/cluster/views/tests/test_correlogram.py +++ b/phy/cluster/views/tests/test_correlogram.py @@ -6,10 +6,6 @@ import numpy as np from phylib.io.mock import artificial_correlograms -from phylib.utils import connect, unconnect - -from phy.gui.qt import QPoint, Qt -from phy.plot.tests import mouse_click from ..correlogram import CorrelogramView from . import _stop_and_close @@ -40,30 +36,6 @@ def get_firing_rate(cluster_ids, bin_size): v.on_select(cluster_ids=[0, 2, 3]) v.on_select(cluster_ids=[0, 2]) - promoted = [] - - @connect(sender=v) - def on_request_promote_similar(sender, cluster_id_a, cluster_id_b): - promoted.append((cluster_id_a, cluster_id_b)) - - v.on_select(cluster_ids=[0, 2, 3]) - width, height = v.canvas.get_size() - mouse_click(qtbot, v.canvas, (width / 2, height / 6), button='Right') - mouse_click(qtbot, v.canvas, (width / 6, height / 6), button='Right') - - assert promoted == [(0, 2)] - - # Trackpad secondary clicks may be held longer than BaseCanvas' 250 ms - # synthetic mouse-click threshold. The release should still be actionable. - pos = QPoint(round(width / 2), round(height / 6)) - qtbot.mousePress(v.canvas, Qt.RightButton, pos=pos) - qtbot.wait(300) - qtbot.mouseRelease(v.canvas, Qt.RightButton, pos=pos) - - assert promoted == [(0, 2), (0, 2)] - - unconnect(on_request_promote_similar) - v.toggle_normalization(True) v.toggle_labels(False) v.toggle_labels(True) From 1b3942a6880df3a4f8ca6dd0d9022b4203638a69 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:24:44 +0200 Subject: [PATCH 021/110] Document mode-dependent selection order --- design/merge-view-architecture.md | 24 +++++++++++---------- design/merge-view-workflow.md | 7 ++++-- docs/api.md | 36 ------------------------------- docs/changelog.md | 15 ++++++------- docs/clustering.md | 14 ++++++------ 5 files changed, 31 insertions(+), 65 deletions(-) diff --git a/design/merge-view-architecture.md b/design/merge-view-architecture.md index 490808b2..a2833558 100644 --- a/design/merge-view-architecture.md +++ b/design/merge-view-architecture.md @@ -54,11 +54,11 @@ Consequently, `TaskLogger` currently has several responsibilities: Extending `TaskLogger.last_state()` with a third view would make selection state more implicit and increase the number of callback-order dependencies. -### 2.2 View transfers are multi-step callback sequences +### 2.2 Merge candidate transfers are multi-step callback sequences -Moving a cluster between Cluster and Similarity currently requires changing one -table, waiting for its callback, restoring the other table, and suppressing the -intermediate scientific-view update with `update_views=False`. +Moving a candidate between Similarity and Merge requires changing both role +tables while preserving one effective selection and publishing only the final +presentation order. Merge mode requires many related transitions: enter, cancel, add, remove, reorder, commit, undo, and redo. Implementing each as a separate callback chain @@ -84,12 +84,13 @@ Many scientific views call `selected_cluster_color(index)` or otherwise derive selection colors from ID order. Replacing every consumer with a new color API would unnecessarily broaden the first refactor. -The target architecture instead owns a stable `presentation_order`. Scientific -views continue receiving an ordered list, preserving the existing plugin and -view contract. Merge ordering is modeled separately. +The target architecture instead owns an explicit `presentation_order`. +Scientific views continue receiving an ordered list, preserving the existing +plugin and view contract. Normal-mode order follows the visible role tables; +Merge ordering is modeled separately and takes precedence while active. An explicit cluster-ID-to-color-slot API may be considered later if requirements -eventually exceed what stable presentation order can express. +eventually exceed what explicit presentation order can express. ### 2.5 History lacks orchestration context @@ -143,8 +144,9 @@ The refactor should establish the following invariants: independent domain authorities. 3. Similarity reference is an explicit cluster ID. 4. The reference occupies the blue presentation slot. -5. Presentation/color order is Merge row order followed by Similarity selection - order. +5. Presentation/color order follows visible Cluster and Similarity row order in + Normal mode, with the reference first. In Merge mode it is Merge row order + followed by visible Similarity selection order. 6. Moving a cluster between Similarity and Merge does not change membership, but emits a public selection update when it changes presentation order. 7. Related state changes are applied transactionally; observers see only valid @@ -602,7 +604,7 @@ Most mode behavior should be testable without Qt: - valid and invalid entry; - fixed reference; - transfer and reorder; -- stable presentation order; +- mode-dependent presentation order; - exact cancellation; - effective membership and merge target; and - before/after snapshot restoration. diff --git a/design/merge-view-workflow.md b/design/merge-view-workflow.md index 21e6162e..2302a8ec 100644 --- a/design/merge-view-workflow.md +++ b/design/merge-view-workflow.md @@ -22,7 +22,9 @@ commits a curation change. The GUI has two mutually exclusive modes: - **Normal mode:** the effective selection is the union of the Cluster View and - Similarity View selections. + Similarity View selections. Scientific views show the blue reference first, + followed by the other selected Cluster View rows and selected Similarity View + rows in their visible table order. - **Merge mode:** the effective selection is the union of every cluster in Merge View and the Similarity View selection. Cluster View is disabled. @@ -103,7 +105,8 @@ row transfers all selected rows; dragging an unselected row transfers only that row. In Merge mode, the presentation order delivered to scientific views is always -the Merge View row order followed by the Similarity View selection order. +the Merge View row order followed by selected Similarity View rows in visible +table order. Adding, removing, or reordering rows may therefore reassign positional colors and redraw order-dependent scientific views. diff --git a/docs/api.md b/docs/api.md index 3dd306c2..4e268696 100644 --- a/docs/api.md +++ b/docs/api.md @@ -7325,15 +7325,6 @@ selected clusters (template view, raster view). --- -#### CorrelogramView.on_mouse_release - - -**`CorrelogramView.on_mouse_release(self, e)`** - -Promote a similarity cluster after a stationary secondary click. - ---- - #### CorrelogramView.on_mouse_wheel @@ -9899,15 +9890,6 @@ Only used in the automated testing suite. ---- - -#### Supervisor.demote_cluster - - -**`Supervisor.demote_cluster(self, cluster_id, callback=None)`** - -Move a selected cluster row into the similarity view. - --- #### Supervisor.filter @@ -10036,15 +10018,6 @@ Select the previous best cluster in the cluster view. --- -#### Supervisor.promote_similar - - -**`Supervisor.promote_similar(self, cluster_id, callback=None)`** - -Move a similarity row into the cluster view while preserving all other selections. - ---- - #### Supervisor.redo @@ -10138,15 +10111,6 @@ Make a new cluster out of the specified spikes. --- -#### Supervisor.toggle_cluster_selection - - -**`Supervisor.toggle_cluster_selection(self, cluster_id, callback=None)`** - -Add or remove a cluster from the cluster-view selection. - ---- - #### Supervisor.toggle_merge_mode diff --git a/docs/changelog.md b/docs/changelog.md index ab50c2f9..b7ae71ae 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -19,7 +19,7 @@ behavior they verify rather than listed separately. cluster plus the current Similarity View selection. Merge View opens below Cluster View and remembers its in-session dock position; the dimmed Cluster View remains scrollable. Scientific views follow Merge View order and then - Similarity View selection order. Cancellation restores the entry state, and + selected Similarity rows in visible table order. Cancellation restores the entry state, and undo restores the full pre-merge workspace. - Select the first eligible clusters in the Similarity View with @@ -29,12 +29,6 @@ behavior they verify rather than listed separately. - Skip clusters labeled `noise` or `mua` during wizard navigation and batch similarity selection. **Select > Skip Noise and MUA** controls the behavior and remembers the preference across sessions. -- Use `Control`-right-click to transfer a selected Cluster View row to the - Similarity View, or to promote a Similarity View row into the Cluster View, - while preserving the existing selections and similarity reference. -- Right-click a cross-correlogram to promote its similar cluster into the - Cluster View selection. Native mouse and trackpad secondary clicks are - supported. - Configure the total number of gray background points in the Amplitude View with `n_spikes_amplitudes_background` (10,000 by default). - Waveform, Amplitude, and Correlogram views support optional fixed total spike @@ -68,8 +62,11 @@ behavior they verify rather than listed separately. ### Changed - The first, blue Cluster View selection is now the explicit Similarity - reference. Moving non-reference rows between Cluster and Similarity roles - preserves their presentation order, colors, and scientific-view selection. + reference. In Normal mode, scientific views and positional colors follow the + selected Cluster and Similarity rows in visible table order; re-sorting either + table updates that presentation. In Merge mode, explicit Merge View order + takes precedence. Normal-mode cross-role mouse transfers and cross-correlogram + promotion have been removed in favor of the Merge workspace. - Undo and redo restore the complete selection context around merge, split, and metadata actions; redo also preserves selection-only exploration made after the original action. diff --git a/docs/clustering.md b/docs/clustering.md index efc2afde..e894065d 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -25,10 +25,10 @@ shows the number of staged clusters, selected similar clusters, and the total th Move candidates between the two views with `Control`-right-click or drag-and-drop. Drag within Merge View to reorder candidates. The first blue reference cannot be moved or removed, and -the selection order shown by scientific views is always the Merge View order followed by the -Similarity View selection order. Transfers and reordering can therefore update cluster colors and -redraw order-dependent views. Press `Backspace` to clear only the Similarity View selection when -the merge should contain only the staged rows. +the selection order shown by scientific views is always the Merge View order followed by selected +Similarity View rows in visible table order. Transfers, table sorting, and Merge reordering can +therefore update cluster colors and redraw order-dependent views. Press `Backspace` to clear only +the Similarity View selection when the merge should contain only the staged rows. Press `G` to commit the staged clusters plus the selected Similarity candidates. Press `V` again, use **Cancel Merge Mode**, or close Merge View to cancel and restore the exact state from before @@ -65,9 +65,9 @@ Wizard navigation skips clusters labeled `noise` or `mua` by default. To include On macOS, this shortcut uses the Control key, not Command. If `Control+Space` is assigned to switching input sources in macOS System Settings, disable or remap that system shortcut so that phy can receive it. -Control-right-click any cluster in the similarity view to move it into the cluster view. Existing selections in both tables are preserved, and the previous best cluster remains the similarity reference. - -Control-right-click a cluster in the cluster view to add it to, or remove it from, the current cluster selection. A plain right-click does not change the selection. +In Normal mode, scientific views and positional colors follow the selected Cluster and Similarity +rows in their visible table order, with the blue Similarity reference first. Sorting either table +updates that presentation. Use Merge mode when you need to collect or explicitly order candidates. For each similar cluster, you can either: From 2fd5b74ca3885b36f9f8af08a50ffbc8a84dc532 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:28:41 +0200 Subject: [PATCH 022/110] Remove obsolete correlogram promotion test --- phy/apps/tests/test_correlogram.py | 34 ------------------------------ 1 file changed, 34 deletions(-) delete mode 100644 phy/apps/tests/test_correlogram.py diff --git a/phy/apps/tests/test_correlogram.py b/phy/apps/tests/test_correlogram.py deleted file mode 100644 index b9ab1b1d..00000000 --- a/phy/apps/tests/test_correlogram.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Tests for correlogram controller interactions.""" - -from phy.cluster.views import CorrelogramView -from phy.plot.tests import mouse_click - -from .test_base import MyController, _mock_controller - - -def test_correlogram_right_click_promotes_similar_cluster(qtbot, tempdir): - controller = _mock_controller(tempdir, MyController) - gui = controller.create_gui(do_prompt_save=False) - with qtbot.waitExposed(gui): - gui.show() - - try: - supervisor = controller.supervisor - supervisor.select([0]) - supervisor.block() - similar_cluster_id = supervisor.similarity_view.get_ids()[0] - supervisor.similarity_view.select([similar_cluster_id]) - supervisor.block() - assert supervisor.selected_similar == [similar_cluster_id] - - view = gui.list_views(CorrelogramView)[0] - qtbot.waitUntil(lambda: set(view.cluster_ids) == {0, similar_cluster_id}) - width, height = view.canvas.get_size() - mouse_click(qtbot, view.canvas, (3 * width / 4, height / 4), button='Right') - qtbot.waitUntil(lambda: similar_cluster_id in supervisor.selected_clusters) - - assert similar_cluster_id in supervisor.selected_clusters - assert similar_cluster_id not in supervisor.selected_similar - finally: - gui.close() - controller.close() From a682638d77a4a81921bb75c626a84be9a5ae6a10 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:30:28 +0200 Subject: [PATCH 023/110] fix: preserve table filter editing after double click --- docs/changelog.md | 5 +++-- phy/gui/tests/test_widgets.py | 17 ++++++++++++++++- phy/gui/widgets.py | 14 ++++---------- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index b7ae71ae..29c5ae25 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -54,8 +54,9 @@ behavior they verify rather than listed separately. particular, a Firing Rate time range saved or leaked from another recording no longer clips spikes in a fresh dataset. - Cluster and Similarity View filters only take keyboard focus after an - explicit click, including when a table is first shown or refreshed. Enter, - Escape, and outside clicks release filter focus so global shortcuts resume. + explicit click, including when a table is first shown or refreshed. Their + native double-click text selection remains editable. Enter, Escape, and + outside clicks release filter focus so global shortcuts resume. - Display metadata columns containing multiple values in the Cluster and Similarity Views instead of leaving their cells blank. diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index 352d8e7e..e1910b94 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -255,11 +255,26 @@ def test_table_invalid_column(qtbot): def test_table_0(qtbot, table): - assert table.filter_edit.focusPolicy() == Qt.NoFocus + assert table.filter_edit.focusPolicy() == Qt.ClickFocus table.filter_edit.clearFocus() qtbot.mouseClick(table.filter_edit, Qt.LeftButton) assert table.filter_edit.hasFocus() + +def test_table_filter_double_click_selection_remains_editable(qtbot, table): + qtbot.mouseClick(table.filter_edit, Qt.LeftButton) + table.filter_edit.setText('cluster 12') + qtbot.mouseDClick(table.filter_edit, Qt.LeftButton) + assert table.filter_edit.hasFocus() + assert table.filter_edit.hasSelectedText() + qtbot.keyClicks(table.filter_edit, 'x') + assert table.filter_edit.text() != 'cluster 12' + qtbot.keyClick(table.filter_edit, Qt.Key_Backspace) + assert table.filter_edit.text() != 'x' + + +def test_table_filter_apply_and_release(qtbot, table): + qtbot.mouseClick(table.filter_edit, Qt.LeftButton) table.filter_edit.setText('id >= 2') qtbot.keyClick(table.filter_edit, Qt.Key_Return) assert not table.filter_edit.hasFocus() diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index 4be8edf6..f84d37ac 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -652,10 +652,10 @@ def __init__( self.filter_edit = QLineEdit(self) self.filter_edit.setObjectName('table-filter') - # Do not let the filter become the table's automatic focus target when the GUI is - # shown, reactivated, or its model is reset. Explicit mouse clicks are handled in - # eventFilter() below so the editor remains usable. - self.filter_edit.setFocusPolicy(Qt.NoFocus) + # Accept focus only from a mouse click. Using ``NoFocus`` and setting focus from + # an event filter prevents QLineEdit from completing some native mouse gestures, + # notably a double-click selection followed by keyboard editing. + self.filter_edit.setFocusPolicy(Qt.ClickFocus) self.filter_edit.returnPressed.connect(self._apply_filter_from_editor) self.filter_edit.installEventFilter(self) @@ -829,12 +829,6 @@ def eventFilter(self, obj, event): } ): return True - if ( - obj is self.filter_edit - and event.type() == QEvent.MouseButtonPress - and event.button() == Qt.LeftButton - ): - self.filter_edit.setFocus(Qt.MouseFocusReason) if ( obj is self.table_view.viewport() and event.type() == QEvent.MouseButtonPress From 2b7aa996bd656fa5be779fbcad2240d0e2107c1f Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:37:13 +0200 Subject: [PATCH 024/110] improve: clarify shortcut and command discovery --- docs/api.md | 3 +-- docs/changelog.md | 4 ++++ docs/gui.md | 4 ++-- docs/keyboard_customization.md | 9 +++++---- docs/quickstart.md | 11 ++++++----- phy/gui/actions.py | 20 ++++++++++++-------- phy/gui/gui.py | 4 +++- phy/gui/tests/test_actions.py | 16 ++++++++++++++++ phy/gui/tests/test_gui.py | 1 + 9 files changed, 50 insertions(+), 22 deletions(-) diff --git a/docs/api.md b/docs/api.md index 4e268696..a99b1433 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1658,8 +1658,7 @@ May be overridden. **`Snippets.command`** -This is used to write a snippet message in the status bar. A cursor is appended at -the end. +Current snippet command, without the status-bar cursor or guidance. --- diff --git a/docs/changelog.md b/docs/changelog.md index 29c5ae25..0ea5fa30 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -44,6 +44,10 @@ behavior they verify rather than listed separately. ### Fixed +- Rename the Help shortcut reference action to **Show shortcuts and commands** + and show Enter/Escape guidance when the `:` command prompt is active. +- Pressing `:` repeatedly no longer leaves the command prompt visible after + Escape closes it. - Keep the disabled Cluster View overlay fixed while scrolling in Merge mode, increase its dimming, and make native table rows initiate drag-and-drop. - Display Firing Rate View values in spikes per second instead of normalized diff --git a/docs/gui.md b/docs/gui.md index 408b8a8a..999ef620 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -91,8 +91,8 @@ Most graphical views share these controls: * Shift-wheel to change the color scheme in color-enabled views. View-specific controls appear in the view menu and in the -[shortcut reference](shortcuts.md). Press `H` or use the Help menu to print the bindings active in -the current session. +[shortcut reference](shortcuts.md). Press `H` or choose **Help > Show shortcuts and commands** to +print the bindings active in the current session. ## Automatic updates and large selections diff --git a/docs/keyboard_customization.md b/docs/keyboard_customization.md index eee2525d..74c6f970 100644 --- a/docs/keyboard_customization.md +++ b/docs/keyboard_customization.md @@ -1,13 +1,14 @@ # Customize keyboard shortcuts -Press `H` or choose **Help > Show all shortcuts** to print the effective -shortcuts and snippets in the console. The generated +Press `H` or choose **Help > Show shortcuts and commands** to print the effective +shortcuts and command aliases in the console. The generated [keyboard shortcut reference](shortcuts.md) lists the defaults, but `H` is the better source after plugins have changed them. Keyboard shortcuts invoke actions immediately. Snippets start with `:`, may -take arguments, and run when you press Enter. For example, `:c 10 12` selects -clusters 10 and 12. Rebinding an action does not change its snippet alias. +take arguments, and run when you press Enter; the status bar shows how to run or +cancel an active command. For example, `:c 10 12` selects clusters 10 and 12. +Rebinding an action does not change its snippet alias. ## Install the example diff --git a/docs/quickstart.md b/docs/quickstart.md index 20183d58..3134aeda 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -63,9 +63,9 @@ Click one row in the Cluster View. Then click a highly ranked row in the Similarity View. The other views should now compare the two clusters using different colors. -Press `H` at any time to show the effective keyboard shortcuts. Menus also show -their shortcuts, and hovering over an action shows its command name in the status -bar. +Press `H` or choose **Help > Show shortcuts and commands** to print the effective +keyboard shortcuts and command aliases. Menus also show their shortcuts, and hovering +over an action shows its command name in the status bar. ## 3. Inspect one cluster @@ -86,7 +86,7 @@ Useful first-session keys include: | Action | Default shortcut | | --- | --- | -| Show all shortcuts | `H` | +| Show shortcuts and commands | `H` | | Select the next similarity candidate | `Space` | | Return to only the Cluster View selection | `Backspace` | | Enter or cancel Merge mode | `V` | @@ -96,7 +96,8 @@ Useful first-session keys include: | Save | `Ctrl+S` | On macOS, menu labels may use platform-native key names. The help window is the -authoritative list for the running build. +authoritative list for the running build. Press `:` to open the command prompt in the +status bar; it shows Enter/Escape guidance while active. ## 4. Compare a possible merge diff --git a/phy/gui/actions.py b/phy/gui/actions.py index 90efc805..0a0bcc79 100644 --- a/phy/gui/actions.py +++ b/phy/gui/actions.py @@ -582,6 +582,7 @@ class Snippets: def __init__(self, gui): self.gui = gui self._status_message = gui.status_message + self._command = '' self.actions = Actions(gui, name='Snippets', menu='&File') @@ -597,18 +598,14 @@ def enable_snippet_mode(): @property def command(self): - """This is used to write a snippet message in the status bar. A cursor is appended at - the end.""" - msg = self.gui.status_message - n = len(msg) - n_cur = len(self.cursor) - return msg[: n - n_cur] + """Current snippet command, without the status-bar cursor or guidance.""" + return self._command @command.setter def command(self, value): - value += self.cursor + self._command = value self.gui.unlock_status() - self.gui.status_message = value + self.gui.status_message = f'{value}{self.cursor} Enter: run · Esc: cancel' self.gui.lock_status() def _backspace(self): @@ -693,6 +690,12 @@ def is_mode_on(self): def mode_on(self): """Enable the snippet mode.""" + # The activation shortcut remains enabled while collecting a command. + # Ignore repeated ':' presses: saving the in-progress prompt as the + # restoration message would leave the status bar looking active after + # Escape, while the Escape action has already been disabled. + if self.is_mode_on(): + return logger.debug('Snippet mode enabled, press `escape` to leave this mode.') # Save the current status message. self._status_message = self.gui.status_message @@ -708,6 +711,7 @@ def mode_on(self): def mode_off(self): """Disable the snippet mode.""" + self._command = '' self.gui.unlock_status() # Reset the GUI status message that was set before the mode was # activated. diff --git a/phy/gui/gui.py b/phy/gui/gui.py index d1388d2e..2d0544c3 100644 --- a/phy/gui/gui.py +++ b/phy/gui/gui.py @@ -625,10 +625,12 @@ def exit(): # Help menu. @self.help_actions.add(shortcut=('HelpContents', 'h')) def show_all_shortcuts(): - """Show the shortcuts of all actions.""" + """Print the active keyboard shortcuts and command aliases.""" for actions in self.actions: actions.show_shortcuts() + self.help_actions.get('show_all_shortcuts').setText('Show shortcuts and commands') + @self.help_actions.add(shortcut='?') def about(): # pragma: no cover """Display an about dialog.""" diff --git a/phy/gui/tests/test_actions.py b/phy/gui/tests/test_actions.py index df719b51..043823f5 100644 --- a/phy/gui/tests/test_actions.py +++ b/phy/gui/tests/test_actions.py @@ -245,10 +245,26 @@ def press(): def test_snippets_message(qtbot, gui): gui.status_message = 'Hello world!' gui.snippets.mode_on() + assert gui.snippets.command == ':' + assert 'Enter: run' in gui.status_message + assert 'Esc: cancel' in gui.status_message gui.snippets.mode_off() assert gui.status_message == 'Hello world!' +def test_snippets_repeated_activation_leaves_escape_available(qtbot, gui): + gui.status_message = 'Hello world!' + snippets = gui.snippets + + snippets.actions.enable_snippet_mode() + snippets.actions.enable_snippet_mode() + assert snippets.is_mode_on() + + snippets.actions._snippet_disable() + assert not snippets.is_mode_on() + assert gui.status_message == 'Hello world!' + + def test_snippets_gui(qtbot, gui, actions): qtbot.addWidget(gui) show_and_wait(qtbot, gui) diff --git a/phy/gui/tests/test_gui.py b/phy/gui/tests/test_gui.py index 39846441..6eb645e9 100644 --- a/phy/gui/tests/test_gui.py +++ b/phy/gui/tests/test_gui.py @@ -129,6 +129,7 @@ def on_close_view(view_, gui): assert gui.state.geometry_state['state'] gui.help_actions.show_all_shortcuts() + assert gui.help_actions.get('show_all_shortcuts').text() == 'Show shortcuts and commands' gui.file_actions.save() gui.file_actions.exit() From f02b5d1a11b2aec3b4c1191f1d7f79c154fa2938 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:39:44 +0200 Subject: [PATCH 025/110] Keep selection colors stable across ordering changes --- phy/cluster/supervisor.py | 44 ++++++++++++++++++++----- phy/cluster/tests/test_supervisor.py | 48 ++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 78a481af..db77deae 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -431,10 +431,10 @@ def _on_row_clicked(self, index): def _on_header_clicked(self, section): """Merge order changes only through explicit reorder intents.""" - def set_merge_ids(self, cluster_ids, data, presentation_order): + def set_merge_ids(self, cluster_ids, data, color_order): """Project one complete ordered Merge session.""" self.remove_all_and_add(data, fit_columns=not self._column_widths_fitted) - self.set_selected_index_order(presentation_order) + self.set_selected_index_order(color_order) self.set_selected_ids(cluster_ids) def _drag_ids_for_index(self, index): @@ -727,6 +727,7 @@ def __init__( self._merge_close_callback = None self._merge_dock_state = None self._suspend_presentation_order_sync = False + self._selection_color_order = () self._is_dirty = None self._sort = sort # Initial sort requested in the constructor # This is populated alongside the existing TaskLogger-derived selection during the @@ -1090,7 +1091,7 @@ def _clusters_selected(self, sender, obj, **kwargs): self.similarity_view.reset(cluster_ids, reference_id=change.after.reference_id) self.similarity_view.set_selected_ids(()) change = self._normalize_presentation_order(change) - self._update_selection_colors() + self._update_selection_colors(reset=True) # Emit supervisor.select event unless update_views is False. This happens after # a merge event, where the views should not be updated after the first cluster_view.select # event, but instead after the second similarity_view.select event. @@ -1173,9 +1174,20 @@ def _table_order_changed(self, sender, row_ids): self._project_merge_view() emit('select', self, list(change.after.presentation_order)) - def _update_selection_colors(self): - """Project authoritative presentation positions into both role tables.""" - order = self.selection.state.presentation_order + def _update_selection_colors(self, reset=False): + """Project stable selection-color positions into all workflow tables.""" + state = self.selection.state + if reset: + order = tuple(state.presentation_order) + else: + active_ids = set(state.effective_ids) + order = tuple( + cluster_id + for cluster_id in self._selection_color_order + if state.is_merge_mode or cluster_id in active_ids + ) + order = tuple(dict.fromkeys((*order, *state.presentation_order))) + self._selection_color_order = order self.cluster_view.set_selected_index_order(order) self.similarity_view.set_selected_index_order(order) if self.merge_view is not None: @@ -1186,7 +1198,7 @@ def _project_merge_view(self): if self.merge_view is None or not state.is_merge_mode: return data = [self.get_cluster_info(cluster_id) for cluster_id in state.merge_ids] - self.merge_view.set_merge_ids(state.merge_ids, data, state.presentation_order) + self.merge_view.set_merge_ids(state.merge_ids, data, self._selection_color_order) self.merge_view.dock.set_status(self._merge_status_text()) def _merge_status_text(self): @@ -1205,7 +1217,9 @@ def _apply_selection_change(self, change, callback=None, normalize_order=True): if normalize_order: change = self._normalize_presentation_order(change) state = change.after - self._update_selection_colors() + self._update_selection_colors( + reset=not state.is_merge_mode and change.reference_changed, + ) self._project_merge_view() self.task_logger.log( self.cluster_view, @@ -1614,6 +1628,11 @@ def on_close_view(view, sender): self._merge_close_callback = on_close_view + @connect(sender=self) + def on_cluster(sender, up): + self._is_dirty = True + self._update_save_feedback() + gui.add_view(self.cluster_view, position='left', closable=False) gui.add_view(self.similarity_view, position='left', closable=False) @@ -1939,6 +1958,14 @@ def is_dirty(self): """Return whether there are any pending changes.""" return self._is_dirty if self._is_dirty in (False, True) else len(self._global_history) > 1 + def _update_save_feedback(self, saved=False): + """Reflect the current curation-save state in the attached GUI.""" + if self.gui is None: + return + self.gui._set_dirty(not saved and self.is_dirty()) + if saved: + self.gui.status_message = 'Curation changes saved.' + def undo(self): """Undo the last action.""" if self.selection.state.is_merge_mode: @@ -1985,6 +2012,7 @@ def save(self): # Cache the spikes_per_cluster array. self._save_spikes_per_cluster() self._is_dirty = False + self._update_save_feedback(saved=True) def block(self): """Block until there are no pending actions. diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index f58b1bd6..9dd6e332 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -431,6 +431,10 @@ def on_select(sender, cluster_ids): supervisor.similarity_view.select([candidate]) supervisor.block() assert events == [[10, 30, 20, candidate]] + colors_before = { + cluster_id: supervisor.merge_view._selected_color_index(cluster_id) + for cluster_id in supervisor.selected + } events.clear() supervisor.add_to_merge((candidate,)) @@ -443,16 +447,21 @@ def on_select(sender, cluster_ids): assert supervisor.selected_similar == [30] assert supervisor.selected == [10, 20, candidate, 30] assert events == [[10, 20, candidate, 30]] + assert supervisor.similarity_view._selected_color_index(30) == colors_before[30] events.clear() supervisor.reorder_merge((candidate,), 1) assert supervisor.selected_merge == [10, candidate, 20] assert supervisor.selected == [10, candidate, 20, 30] assert events == [[10, candidate, 20, 30]] - assert supervisor.merge_view._selected_color_index(10) == 0 - assert supervisor.merge_view._selected_color_index(candidate) == 1 - assert supervisor.merge_view._selected_color_index(20) == 2 - assert supervisor.similarity_view._selected_color_index(30) == 3 + assert { + cluster_id: ( + supervisor.merge_view._selected_color_index(cluster_id) + if cluster_id in supervisor.selected_merge + else supervisor.similarity_view._selected_color_index(cluster_id) + ) + for cluster_id in supervisor.selected + } == colors_before unconnect(on_select) @@ -852,12 +861,32 @@ def on_select(sender, cluster_ids): assert supervisor.selected == [30, 20, 11, 1] assert events == [[30, 20, 11, 1]] - assert similarity_view._selected_color_index(20) == 1 + assert similarity_view._selected_color_index(20) == 3 assert similarity_view._selected_color_index(11) == 2 - assert similarity_view._selected_color_index(1) == 3 + assert similarity_view._selected_color_index(1) == 1 unconnect(on_select) +def test_normal_similarity_insertion_does_not_recolor_existing_rows(supervisor): + _select(supervisor, [30]) + similarity_view = supervisor.similarity_view + similarity_view.sort_by('id', 'asc') + + similarity_view.select([1]) + supervisor.block() + similarity_view.select([1, 20]) + supervisor.block() + color_before = similarity_view._selected_color_index(20) + + # Insert 11 before 20 in visible row order without changing 20's color slot. + similarity_view.select([1, 20, 11]) + supervisor.block() + + assert supervisor.selected == [30, 1, 11, 20] + assert similarity_view._selected_color_index(20) == color_before + assert similarity_view._selected_color_index(11) > color_before + + def test_merge_presentation_keeps_merge_order_before_similarity_table_order(supervisor): _select(supervisor, [30]) supervisor.toggle_merge_mode() @@ -1150,7 +1179,14 @@ def test_supervisor_edge_cases(supervisor): def test_supervisor_save(qtbot, gui, supervisor): + assert not gui.windowTitle().startswith('* ') + supervisor.label('group', 'noise', [30]) + supervisor.block() + assert gui.windowTitle().startswith('* ') + emit('request_save', gui) + assert gui.status_message == 'Curation changes saved.' + assert not gui.windowTitle().startswith('* ') def test_supervisor_skip(qtbot, gui, supervisor): From 50030785524e591ef3babefd15ef1d8c901a8d9e Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:40:20 +0200 Subject: [PATCH 026/110] Document stable workflow table colors --- design/merge-view-architecture.md | 29 +++++++++++++++++------------ design/merge-view-workflow.md | 5 +++-- docs/changelog.md | 11 ++++++----- docs/clustering.md | 14 ++++++++------ 4 files changed, 34 insertions(+), 25 deletions(-) diff --git a/design/merge-view-architecture.md b/design/merge-view-architecture.md index a2833558..9383ce2f 100644 --- a/design/merge-view-architecture.md +++ b/design/merge-view-architecture.md @@ -17,7 +17,7 @@ table. It introduces: - a third cluster role alongside Cluster and Similarity; - an explicit presentation order derived from the two active roles; - a fixed blue Similarity reference; -- positional colors that follow Merge row order and Similarity selection order; +- stable workflow-table colors independent of role and row-order changes; - exact cancellation to an entry snapshot; and - restoration of the complete workspace after undoing a committed merge. @@ -78,7 +78,7 @@ This is an intentional correction of the internal model. Characterization tests must document the existing multi-selection behavior before changing it, and the user-visible consequence must be reviewed during implementation. -### 2.4 Colors are positional throughout the views +### 2.4 Scientific-view colors remain positional Many scientific views call `selected_cluster_color(index)` or otherwise derive selection colors from ID order. Replacing every consumer with a new color API @@ -89,8 +89,11 @@ Scientific views continue receiving an ordered list, preserving the existing plugin and view contract. Normal-mode order follows the visible role tables; Merge ordering is modeled separately and takes precedence while active. -An explicit cluster-ID-to-color-slot API may be considered later if requirements -eventually exceed what explicit presentation order can express. +Workflow tables separately retain a Supervisor-owned cluster-to-color order. +Normal selection additions receive a new slot without recoloring existing rows. +In Merge mode, slots are retained across transfers, reordering, temporary +deselection, and reselection. A corresponding scientific-view color API may be +considered later if their positional-color contract needs to change. ### 2.5 History lacks orchestration context @@ -144,9 +147,10 @@ The refactor should establish the following invariants: independent domain authorities. 3. Similarity reference is an explicit cluster ID. 4. The reference occupies the blue presentation slot. -5. Presentation/color order follows visible Cluster and Similarity row order in +5. Presentation order follows visible Cluster and Similarity row order in Normal mode, with the reference first. In Merge mode it is Merge row order - followed by visible Similarity selection order. + followed by visible Similarity selection order. Workflow-table color slots + are stable independently of those order changes. 6. Moving a cluster between Similarity and Merge does not change membership, but emits a public selection update when it changes presentation order. 7. Related state changes are applied transactionally; observers see only valid @@ -198,7 +202,8 @@ state.is_merge_mode In Normal mode, effective membership is Cluster plus Similarity membership. In Merge mode, it is Merge plus Similarity membership. `presentation_order` is the -ordered unique list emitted to scientific views and used for positional colors. +ordered unique list emitted to scientific views. The Supervisor separately +retains the workflow tables' color-slot order. ### 4.3 Merge session @@ -670,12 +675,12 @@ This could unify all state eventually, but the migration surface includes every view and plugin. It is disproportionate to the feature and too risky for the curation path. -### Introduce an explicit color registry immediately +### Introduce a full scientific-view color registry immediately -A color registry is conceptually clean but would require changing many -scientific views and plugin assumptions. Stable presentation order satisfies the -agreed workflow while providing a compatibility bridge. A full registry remains -a possible later evolution. +A full color registry would require changing many scientific views and plugin +assumptions. The narrower Supervisor-owned workflow-table registry satisfies the +interactive table requirement without changing that API. A full registry +remains a possible later evolution. ### Store Merge UI context inside `Clustering` diff --git a/design/merge-view-workflow.md b/design/merge-view-workflow.md index 2302a8ec..0cdf7b1d 100644 --- a/design/merge-view-workflow.md +++ b/design/merge-view-workflow.md @@ -107,8 +107,9 @@ row. In Merge mode, the presentation order delivered to scientific views is always the Merge View row order followed by selected Similarity View rows in visible table order. -Adding, removing, or reordering rows may therefore reassign positional colors -and redraw order-dependent scientific views. +Adding, removing, or reordering rows may redraw order-dependent scientific +views, but a cluster's workflow-table color slot remains fixed for the entire +Merge session. ## Exploring Similarity diff --git a/docs/changelog.md b/docs/changelog.md index 0ea5fa30..c8ad80b0 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -67,11 +67,12 @@ behavior they verify rather than listed separately. ### Changed - The first, blue Cluster View selection is now the explicit Similarity - reference. In Normal mode, scientific views and positional colors follow the - selected Cluster and Similarity rows in visible table order; re-sorting either - table updates that presentation. In Merge mode, explicit Merge View order - takes precedence. Normal-mode cross-role mouse transfers and cross-correlogram - promotion have been removed in favor of the Merge workspace. + reference. In Normal mode, scientific views follow the selected Cluster and + Similarity rows in visible table order; re-sorting either table updates that + presentation without recoloring existing selections. In Merge mode, explicit + Merge View order takes precedence, while workflow-table colors remain fixed + for the entire session. Normal-mode cross-role mouse transfers and + cross-correlogram promotion have been removed in favor of the Merge workspace. - Undo and redo restore the complete selection context around merge, split, and metadata actions; redo also preserves selection-only exploration made after the original action. diff --git a/docs/clustering.md b/docs/clustering.md index e894065d..07503abd 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -26,9 +26,10 @@ shows the number of staged clusters, selected similar clusters, and the total th Move candidates between the two views with `Control`-right-click or drag-and-drop. Drag within Merge View to reorder candidates. The first blue reference cannot be moved or removed, and the selection order shown by scientific views is always the Merge View order followed by selected -Similarity View rows in visible table order. Transfers, table sorting, and Merge reordering can -therefore update cluster colors and redraw order-dependent views. Press `Backspace` to clear only -the Similarity View selection when the merge should contain only the staged rows. +Similarity View rows in visible table order. Transfers, table sorting, and Merge reordering redraw +order-dependent views, while each cluster keeps the same workflow-table color for the entire Merge +session. Press `Backspace` to clear only the Similarity View selection when the merge should contain +only the staged rows. Press `G` to commit the staged clusters plus the selected Similarity candidates. Press `V` again, use **Cancel Merge Mode**, or close Merge View to cancel and restore the exact state from before @@ -65,9 +66,10 @@ Wizard navigation skips clusters labeled `noise` or `mua` by default. To include On macOS, this shortcut uses the Control key, not Command. If `Control+Space` is assigned to switching input sources in macOS System Settings, disable or remap that system shortcut so that phy can receive it. -In Normal mode, scientific views and positional colors follow the selected Cluster and Similarity -rows in their visible table order, with the blue Similarity reference first. Sorting either table -updates that presentation. Use Merge mode when you need to collect or explicitly order candidates. +In Normal mode, scientific views follow the selected Cluster and Similarity rows in their visible +table order, with the blue Similarity reference first. Sorting either table updates that +presentation without recoloring existing table selections. Use Merge mode when you need to collect +or explicitly order candidates. For each similar cluster, you can either: From 52ed2277db360ebd28f396158d33521f2f930e47 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:43:44 +0200 Subject: [PATCH 027/110] Show curation save feedback --- phy/gui/gui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/phy/gui/gui.py b/phy/gui/gui.py index 2d0544c3..522c189d 100644 --- a/phy/gui/gui.py +++ b/phy/gui/gui.py @@ -584,8 +584,8 @@ def _set_name(self, name, subtitle): """Set the GUI name.""" if name is None: name = self.__class__.__name__ - title = name if not subtitle else f'{name} - {subtitle}' - self.setWindowTitle(title) + self._window_title = name if not subtitle else f'{name} - {subtitle}' + self._set_dirty(False) self.setObjectName(name) # Set the name in the GUI. self.name = name From f225d8bb33fda502ba007cfe7998a011413f091b Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:44:02 +0200 Subject: [PATCH 028/110] Finish curation save feedback --- docs/changelog.md | 2 ++ phy/gui/gui.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index c8ad80b0..920000f7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -44,6 +44,8 @@ behavior they verify rather than listed separately. ### Fixed +- Show an unsaved-changes marker in the window title and confirm successful + curation saves in the status bar. - Rename the Help shortcut reference action to **Show shortcuts and commands** and show Enter/Escape guidance when the `:` command prompt is active. - Pressing `:` repeatedly no longer leaves the command prompt visible after diff --git a/phy/gui/gui.py b/phy/gui/gui.py index 522c189d..06c92c33 100644 --- a/phy/gui/gui.py +++ b/phy/gui/gui.py @@ -845,6 +845,10 @@ def dialog(self, message): box.setText(message) return box + def _set_dirty(self, dirty): + """Show whether the window has unsaved curation changes.""" + self.setWindowTitle(f'* {self._window_title}' if dirty else self._window_title) + # Status bar # ------------------------------------------------------------------------- From 04db0302a273b79f1cdb9fe15feb5a72e4fab93c Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:48:59 +0200 Subject: [PATCH 029/110] Keep scientific view colors stable --- design/merge-view-architecture.md | 43 ++++++++++++---------------- design/merge-view-workflow.md | 4 +-- docs/clustering.md | 6 ++-- phy/cluster/supervisor.py | 5 ++++ phy/cluster/views/amplitude.py | 4 ++- phy/cluster/views/base.py | 14 +++++++++ phy/cluster/views/cluscatter.py | 5 +++- phy/cluster/views/correlogram.py | 3 +- phy/cluster/views/feature.py | 6 +++- phy/cluster/views/histogram.py | 2 +- phy/cluster/views/probe.py | 4 ++- phy/cluster/views/raster.py | 5 +++- phy/cluster/views/scatter.py | 11 +++++-- phy/cluster/views/template.py | 5 +++- phy/cluster/views/tests/test_base.py | 4 ++- phy/cluster/views/trace.py | 7 ++++- phy/cluster/views/waveform.py | 3 +- phy/utils/color.py | 14 ++++++++- 18 files changed, 102 insertions(+), 43 deletions(-) diff --git a/design/merge-view-architecture.md b/design/merge-view-architecture.md index 9383ce2f..ecd971e5 100644 --- a/design/merge-view-architecture.md +++ b/design/merge-view-architecture.md @@ -17,7 +17,7 @@ table. It introduces: - a third cluster role alongside Cluster and Similarity; - an explicit presentation order derived from the two active roles; - a fixed blue Similarity reference; -- stable workflow-table colors independent of role and row-order changes; +- stable cross-view colors independent of role and row-order changes; - exact cancellation to an entry snapshot; and - restoration of the complete workspace after undoing a committed merge. @@ -78,22 +78,16 @@ This is an intentional correction of the internal model. Characterization tests must document the existing multi-selection behavior before changing it, and the user-visible consequence must be reviewed during implementation. -### 2.4 Scientific-view colors remain positional +### 2.4 Color slots are independent of presentation order -Many scientific views call `selected_cluster_color(index)` or otherwise derive -selection colors from ID order. Replacing every consumer with a new color API -would unnecessarily broaden the first refactor. - -The target architecture instead owns an explicit `presentation_order`. -Scientific views continue receiving an ordered list, preserving the existing -plugin and view contract. Normal-mode order follows the visible role tables; -Merge ordering is modeled separately and takes precedence while active. - -Workflow tables separately retain a Supervisor-owned cluster-to-color order. -Normal selection additions receive a new slot without recoloring existing rows. -In Merge mode, slots are retained across transfers, reordering, temporary -deselection, and reselection. A corresponding scientific-view color API may be -considered later if their positional-color contract needs to change. +The Supervisor owns both an explicit `presentation_order` and an independent +cluster-to-color order. Normal-mode presentation follows the visible role +tables; Merge ordering is modeled separately and takes precedence while active. +Normal selection additions receive a new color slot without recoloring existing +clusters. In Merge mode, slots are retained across transfers, reordering, +temporary deselection, and reselection. Built-in scientific views resolve their +positional palette index through that mapping while retaining presentation order +for layout. ### 2.5 History lacks orchestration context @@ -149,8 +143,8 @@ The refactor should establish the following invariants: 4. The reference occupies the blue presentation slot. 5. Presentation order follows visible Cluster and Similarity row order in Normal mode, with the reference first. In Merge mode it is Merge row order - followed by visible Similarity selection order. Workflow-table color slots - are stable independently of those order changes. + followed by visible Similarity selection order. Color slots are stable across + workflow tables and scientific views independently of those order changes. 6. Moving a cluster between Similarity and Merge does not change membership, but emits a public selection update when it changes presentation order. 7. Related state changes are applied transactionally; observers see only valid @@ -203,7 +197,7 @@ state.is_merge_mode In Normal mode, effective membership is Cluster plus Similarity membership. In Merge mode, it is Merge plus Similarity membership. `presentation_order` is the ordered unique list emitted to scientific views. The Supervisor separately -retains the workflow tables' color-slot order. +retains the cross-view color-slot order. ### 4.3 Merge session @@ -675,12 +669,13 @@ This could unify all state eventually, but the migration surface includes every view and plugin. It is disproportionate to the feature and too risky for the curation path. -### Introduce a full scientific-view color registry immediately +### Derive colors exclusively from presentation order -A full color registry would require changing many scientific views and plugin -assumptions. The narrower Supervisor-owned workflow-table registry satisfies the -interactive table requirement without changing that API. A full registry -remains a possible later evolution. +This initially minimized the migration surface, but it recolored existing +clusters whenever inserting a newly selected row earlier in presentation order +or transferring a Merge candidate between roles. The explicit color-slot order +avoids that cross-view inconsistency while leaving the public selection payload +unchanged. ### Store Merge UI context inside `Clustering` diff --git a/design/merge-view-workflow.md b/design/merge-view-workflow.md index 0cdf7b1d..08fb70ae 100644 --- a/design/merge-view-workflow.md +++ b/design/merge-view-workflow.md @@ -108,8 +108,8 @@ In Merge mode, the presentation order delivered to scientific views is always the Merge View row order followed by selected Similarity View rows in visible table order. Adding, removing, or reordering rows may redraw order-dependent scientific -views, but a cluster's workflow-table color slot remains fixed for the entire -Merge session. +views, but a cluster's color slot remains fixed across workflow tables and +scientific views for the entire Merge session. ## Exploring Similarity diff --git a/docs/clustering.md b/docs/clustering.md index 07503abd..1221c1d6 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -27,9 +27,9 @@ Move candidates between the two views with `Control`-right-click or drag-and-dro Merge View to reorder candidates. The first blue reference cannot be moved or removed, and the selection order shown by scientific views is always the Merge View order followed by selected Similarity View rows in visible table order. Transfers, table sorting, and Merge reordering redraw -order-dependent views, while each cluster keeps the same workflow-table color for the entire Merge -session. Press `Backspace` to clear only the Similarity View selection when the merge should contain -only the staged rows. +order-dependent views, while each cluster keeps the same color across tables and scientific views +for the entire Merge session. Press `Backspace` to clear only the Similarity View selection when the +merge should contain only the staged rows. Press `G` to commit the staged clusters plus the selected Similarity candidates. Press `V` again, use **Cancel Merge Mode**, or close Merge View to cancel and restore the exact state from before diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index db77deae..99926383 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1691,6 +1691,11 @@ def selected(self): """Selected clusters in the cluster and similarity views.""" return list(self.selection.state.presentation_order) + @property + def selection_color_order(self): + """Cluster IDs in their stable selected-color slots.""" + return self._selection_color_order + def n_spikes(self, cluster_id): """Number of spikes in a given cluster.""" return len(self.clustering.spikes_per_cluster.get(cluster_id, [])) diff --git a/phy/cluster/views/amplitude.py b/phy/cluster/views/amplitude.py index 64bb024c..6d3c615f 100644 --- a/phy/cluster/views/amplitude.py +++ b/phy/cluster/views/amplitude.py @@ -228,7 +228,9 @@ def get_clusters_data(self, load_all=None): assert bunch.pos.ndim == 2 bunch.cluster_id = cluster_id bunch.color = ( - selected_cluster_color(i - 1, self.marker_alpha) + selected_cluster_color( + self.cluster_color_index(cluster_id, i - 1), self.marker_alpha + ) # Background amplitude color. if cluster_id is not None else (0.5, 0.5, 0.5, 0.5) diff --git a/phy/cluster/views/base.py b/phy/cluster/views/base.py index 043e1dfd..5d9b9153 100644 --- a/phy/cluster/views/base.py +++ b/phy/cluster/views/base.py @@ -73,6 +73,7 @@ def __init__(self, shortcuts=None, **kwargs): self._dock_visible = True self._pending_selection = None self.cluster_ids = () + self._cluster_color_index_by_id = {} # Load default shortcuts, and override with any user shortcuts. self.shortcuts = self.default_shortcuts.copy() @@ -144,6 +145,17 @@ def on_select(self, cluster_ids=None, **kwargs): return self.plot(**kwargs) + def _update_cluster_color_indices(self, sender): + order = getattr(sender, 'selection_color_order', ()) + if order: + self._cluster_color_index_by_id = { + cluster_id: index for index, cluster_id in enumerate(order) + } + + def cluster_color_index(self, cluster_id, fallback): + """Return the stable selected-color slot for a cluster.""" + return self._cluster_color_index_by_id.get(cluster_id, fallback) + def on_select_threaded(self, sender, cluster_ids, gui=None, **kwargs): # Decide whether the view should react to the select event or not. if not self.auto_update or self._closed: @@ -154,6 +166,7 @@ def on_select_threaded(self, sender, cluster_ids, gui=None, **kwargs): assert isinstance(cluster_ids, list) if not cluster_ids: return + self._update_cluster_color_indices(sender) # Limit the number of displayed clusters for performance reasons. Keep the # selection order so that a large selection still refreshes the view rather # than leaving its previous contents on screen. @@ -461,6 +474,7 @@ def on_select(self, sender=None, cluster_ids=(), **kwargs): assert isinstance(cluster_ids, list) if not cluster_ids: return + self._update_cluster_color_indices(sender) self.cluster_ids = cluster_ids # selected clusters diff --git a/phy/cluster/views/cluscatter.py b/phy/cluster/views/cluscatter.py index 1220b48c..cee6e779 100644 --- a/phy/cluster/views/cluscatter.py +++ b/phy/cluster/views/cluscatter.py @@ -237,7 +237,10 @@ def update_select_color(self): selected_clusters = self.cluster_ids if selected_clusters is not None and len(selected_clusters) > 0: colors = _add_selected_clusters_colors( - selected_clusters, self.all_cluster_ids, self.marker_colors.copy() + selected_clusters, + self.all_cluster_ids, + self.marker_colors.copy(), + self._cluster_color_index_by_id, ) self.visual.set_color(colors) self.canvas.update() diff --git a/phy/cluster/views/correlogram.py b/phy/cluster/views/correlogram.py index 6e5c2046..ec138c0a 100644 --- a/phy/cluster/views/correlogram.py +++ b/phy/cluster/views/correlogram.py @@ -130,7 +130,8 @@ def get_clusters_data(self, load_all=None): b.firing_rate = fr[i, j] if fr is not None else None b.data_bounds = (0, 0, n_bins, m) b.pair_index = i, j - b.color = selected_cluster_color(i, 1) + color_index = self.cluster_color_index(self.cluster_ids[i], i) + b.color = selected_cluster_color(color_index, 1) if i != j: b.color = add_alpha(_override_hsv(b.color[:3], s=0.1, v=1)) bunchs.append(b) diff --git a/phy/cluster/views/feature.py b/phy/cluster/views/feature.py index 891634fc..f294b1da 100644 --- a/phy/cluster/views/feature.py +++ b/phy/cluster/views/feature.py @@ -237,7 +237,11 @@ def _plot_points(self, bunch, clu_idx=None): self.visual.add_batch_data( x=px.data, y=py.data, - color=_get_point_color(clu_idx), + color=_get_point_color( + self.cluster_color_index(cluster_id, clu_idx) + if cluster_id is not None + else None + ), # Reduced marker size for background features size=self._marker_size, masks=_get_point_masks(clu_idx=clu_idx, masks=masks), diff --git a/phy/cluster/views/histogram.py b/phy/cluster/views/histogram.py index c4ac4584..eadf414c 100644 --- a/phy/cluster/views/histogram.py +++ b/phy/cluster/views/histogram.py @@ -173,7 +173,7 @@ def get_clusters_data(self, load_all=None): bunch.histogram = self._compute_histogram(bunch.data) bunch.ylim = bunch.histogram.max() - bunch.color = selected_cluster_color(i) + bunch.color = selected_cluster_color(self.cluster_color_index(cluster_id, i)) bunch.index = i bunch.cluster_id = cluster_id bunchs.append(bunch) diff --git a/phy/cluster/views/probe.py b/phy/cluster/views/probe.py index 60fc0301..9ad0f99f 100644 --- a/phy/cluster/views/probe.py +++ b/phy/cluster/views/probe.py @@ -155,7 +155,9 @@ def _get_clu_positions(self, cluster_ids): x += t alpha = 1.0 if channel_id not in self.dead_channels else self.dead_channel_alpha clu_pos.append((x, y)) - clu_colors.append(selected_cluster_color(clu_idx, alpha=alpha)) + cluster_id = cluster_ids[clu_idx] + color_index = self.cluster_color_index(cluster_id, clu_idx) + clu_colors.append(selected_cluster_color(color_index, alpha=alpha)) return np.array(clu_pos), np.array(clu_colors) def on_select(self, cluster_ids=(), **kwargs): diff --git a/phy/cluster/views/raster.py b/phy/cluster/views/raster.py index 9a1b00aa..d22a112c 100644 --- a/phy/cluster/views/raster.py +++ b/phy/cluster/views/raster.py @@ -125,7 +125,10 @@ def _get_color(self, box_index, selected_clusters=None): # Selected cluster colors. if selected_clusters is not None: cluster_colors = _add_selected_clusters_colors( - selected_clusters, self.all_cluster_ids, cluster_colors + selected_clusters, + self.all_cluster_ids, + cluster_colors, + self._cluster_color_index_by_id, ) return cluster_colors[box_index, :] diff --git a/phy/cluster/views/scatter.py b/phy/cluster/views/scatter.py index 9d5924e3..0324722e 100644 --- a/phy/cluster/views/scatter.py +++ b/phy/cluster/views/scatter.py @@ -71,7 +71,7 @@ def _get_split_cluster_data(self, bunchs): bunch.pos = np.c_[bunch.x, bunch.y] assert bunch.pos.ndim == 2 assert 'spike_ids' in bunch - bunch.color = selected_cluster_color(i, 0.75) + bunch.color = selected_cluster_color(self.cluster_color_index(cluster_id, i), 0.75) return bunchs def _get_collated_cluster_data(self, bunch): @@ -82,7 +82,14 @@ def _get_collated_cluster_data(self, bunch): assert bunch.x.shape == bunch.y.shape bunch.pos = np.c_[bunch.x, bunch.y] assert bunch.pos.ndim == 2 - bunch.color = spike_colors(bunch.spike_clusters, self.cluster_ids) + color_ids = ( + sorted( + self._cluster_color_index_by_id, + key=self._cluster_color_index_by_id.get, + ) + or self.cluster_ids + ) + bunch.color = spike_colors(bunch.spike_clusters, color_ids) return bunch def get_clusters_data(self, load_all=None): diff --git a/phy/cluster/views/template.py b/phy/cluster/views/template.py index c27d1d81..b720adc9 100644 --- a/phy/cluster/views/template.py +++ b/phy/cluster/views/template.py @@ -206,7 +206,10 @@ def update_color(self): selected_clusters = self.cluster_ids if selected_clusters is not None: cluster_colors = _add_selected_clusters_colors( - selected_clusters, self.sorted_cluster_ids, cluster_colors + selected_clusters, + self.sorted_cluster_ids, + cluster_colors, + self._cluster_color_index_by_id, ) # Number of vertices per cluster = number of vertices per signal n_vertices_clu = [ diff --git a/phy/cluster/views/tests/test_base.py b/phy/cluster/views/tests/test_base.py index 6a8c2435..85c559ea 100644 --- a/phy/cluster/views/tests/test_base.py +++ b/phy/cluster/views/tests/test_base.py @@ -70,9 +70,11 @@ def test_manual_clustering_view_2(qtbot, gui): v.attach(gui) class Supervisor: - pass + selection_color_order = (0, 2, 1) emit('select', Supervisor(), cluster_ids=[0, 1]) + assert v.cluster_color_index(0, 0) == 0 + assert v.cluster_color_index(1, 1) == 2 v.actions.get('Change color scheme to myscheme').trigger() v.next_color_scheme() diff --git a/phy/cluster/views/trace.py b/phy/cluster/views/trace.py index d52302a0..86751586 100644 --- a/phy/cluster/views/trace.py +++ b/phy/cluster/views/trace.py @@ -302,7 +302,12 @@ def _plot_spike(self, bunch): i = bunch.select_index c = bunch.spike_cluster cs = self.color_schemes.get() - color = selected_cluster_color(i, alpha=1) if i is not None else cs.get(c, alpha=1) + color_index = self.cluster_color_index(c, i) if i is not None else None + color = ( + selected_cluster_color(color_index, alpha=1) + if color_index is not None + else cs.get(c, alpha=1) + ) # We could tweak the color of each spike waveform depending on the template amplitude # on each of its best channels. diff --git a/phy/cluster/views/waveform.py b/phy/cluster/views/waveform.py index c1257f75..005544d9 100644 --- a/phy/cluster/views/waveform.py +++ b/phy/cluster/views/waveform.py @@ -200,10 +200,11 @@ def get_clusters_data(self): n_clu = max(clu_offsets) + 1 # Offset depending on the overlap. for i, (bunch, offset) in enumerate(zip(bunchs, clu_offsets)): + color_index = self.cluster_color_index(self.cluster_ids[i], i) bunch.index = i bunch.offset = offset bunch.n_clu = n_clu - bunch.color = selected_cluster_color(i, bunch.get('alpha', 0.75)) + bunch.color = selected_cluster_color(color_index, bunch.get('alpha', 0.75)) return bunchs def _plot_cluster(self, bunch): diff --git a/phy/utils/color.py b/phy/utils/color.py index 87b784e2..fac597b9 100644 --- a/phy/utils/color.py +++ b/phy/utils/color.py @@ -181,12 +181,24 @@ def spike_colors(spike_clusters, cluster_ids): return add_alpha(colormaps.default[np.mod(spike_clusters_idx, colormaps.default.shape[0])]) -def _add_selected_clusters_colors(selected_clusters, cluster_ids, cluster_colors=None): +def _add_selected_clusters_colors( + selected_clusters, cluster_ids, cluster_colors=None, color_index_by_id=None +): """Take an array with colors of clusters as input, and add colors of selected clusters.""" # clu_idx contains the index of the selected clusters within cluster_ids # cmap_idx contains 0, 1, 2... as the colormap index, but without the selected clusters # that are missing in cluster_ids. clu_idx, cmap_idx = _selected_cluster_idx(selected_clusters, cluster_ids) + if color_index_by_id: + selected_clusters = np.asarray(selected_clusters, dtype=np.int32) + cluster_ids = np.asarray(cluster_ids, dtype=np.int32) + kept = np.isin(selected_clusters, cluster_ids) + cmap_idx = np.asarray( + [ + color_index_by_id.get(int(cluster_id), index) + for index, cluster_id in enumerate(selected_clusters[kept]) + ] + ) colormap = _categorical_colormap(colormaps.default, cmap_idx, categorize=False) # Inject those colors in cluster_colors. cluster_colors[clu_idx] = add_alpha(colormap, 1) From 9bf050ab7023fd095c2ae868bba5b92dcde525f5 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:50:11 +0200 Subject: [PATCH 030/110] Test stable selected color slots --- phy/utils/tests/test_color.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/phy/utils/tests/test_color.py b/phy/utils/tests/test_color.py index 2e16179c..ed52c1ef 100644 --- a/phy/utils/tests/test_color.py +++ b/phy/utils/tests/test_color.py @@ -157,3 +157,16 @@ def test_add_selected_clusters_colors_2(): ae(cluster_colors_sel[[0, 1, 4]], cluster_colors[[0, 1, 4]]) ae(cluster_colors_sel[2], selected_cluster_color(0)) ae(cluster_colors_sel[3], selected_cluster_color(2)) + + +def test_add_selected_clusters_colors_uses_stable_slots(): + cluster_colors = np.zeros((3, 4)) + colors = _add_selected_clusters_colors( + [5, 2], + [2, 5, 9], + cluster_colors, + color_index_by_id={5: 3, 2: 1}, + ) + + ae(colors[1], selected_cluster_color(3)) + ae(colors[0], selected_cluster_color(1)) From ff0579709700382cd96f41f0633d943a2aff49d2 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:52:00 +0200 Subject: [PATCH 031/110] Disable Save when curation is clean --- phy/cluster/supervisor.py | 12 +++++++++++- phy/gui/gui.py | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 99926383..a190c031 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1633,6 +1633,12 @@ def on_cluster(sender, up): self._is_dirty = True self._update_save_feedback() + @connect(sender=gui) + def on_default_actions_created(sender): + self._update_save_feedback() + + self._update_save_feedback() + gui.add_view(self.cluster_view, position='left', closable=False) gui.add_view(self.similarity_view, position='left', closable=False) @@ -1967,7 +1973,11 @@ def _update_save_feedback(self, saved=False): """Reflect the current curation-save state in the attached GUI.""" if self.gui is None: return - self.gui._set_dirty(not saved and self.is_dirty()) + is_dirty = not saved and self.is_dirty() + self.gui._set_dirty(is_dirty) + save_action = self.gui.file_actions.get('save') + if save_action is not None: + save_action.setEnabled(is_dirty) if saved: self.gui.status_message = 'Curation changes saved.' diff --git a/phy/gui/gui.py b/phy/gui/gui.py index 06c92c33..8fffb655 100644 --- a/phy/gui/gui.py +++ b/phy/gui/gui.py @@ -645,6 +645,8 @@ def about(): # pragma: no cover pass QMessageBox.about(self, 'About', msg) + emit('default_actions_created', self) + # Events # ------------------------------------------------------------------------- From ed82a5f2e354753efa232067ec8332c7c145a769 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:52:28 +0200 Subject: [PATCH 032/110] Test disabled Save state --- docs/changelog.md | 5 +++-- phy/cluster/tests/test_supervisor.py | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 920000f7..12e59a16 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -44,8 +44,9 @@ behavior they verify rather than listed separately. ### Fixed -- Show an unsaved-changes marker in the window title and confirm successful - curation saves in the status bar. +- Show an unsaved-changes marker in the window title, enable the Save action + only when curation changes are pending, and confirm successful saves in the + status bar. - Rename the Help shortcut reference action to **Show shortcuts and commands** and show Enter/Escape guidance when the `:` command prompt is active. - Pressing `:` repeatedly no longer leaves the command prompt visible after diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 9dd6e332..f242a2ab 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -1181,12 +1181,15 @@ def test_supervisor_edge_cases(supervisor): def test_supervisor_save(qtbot, gui, supervisor): assert not gui.windowTitle().startswith('* ') supervisor.label('group', 'noise', [30]) + assert not gui.file_actions.get('save').isEnabled() supervisor.block() assert gui.windowTitle().startswith('* ') + assert gui.file_actions.get('save').isEnabled() emit('request_save', gui) assert gui.status_message == 'Curation changes saved.' assert not gui.windowTitle().startswith('* ') + assert not gui.file_actions.get('save').isEnabled() def test_supervisor_skip(qtbot, gui, supervisor): From 0721fd4f2c4f1865618287aba5300bda19dd4255 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:53:56 +0200 Subject: [PATCH 033/110] Handle empty stable color selections --- phy/utils/color.py | 3 ++- phy/utils/tests/test_color.py | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/phy/utils/color.py b/phy/utils/color.py index fac597b9..a8d2693f 100644 --- a/phy/utils/color.py +++ b/phy/utils/color.py @@ -197,7 +197,8 @@ def _add_selected_clusters_colors( [ color_index_by_id.get(int(cluster_id), index) for index, cluster_id in enumerate(selected_clusters[kept]) - ] + ], + dtype=np.int64, ) colormap = _categorical_colormap(colormaps.default, cmap_idx, categorize=False) # Inject those colors in cluster_colors. diff --git a/phy/utils/tests/test_color.py b/phy/utils/tests/test_color.py index ed52c1ef..cc38a0c7 100644 --- a/phy/utils/tests/test_color.py +++ b/phy/utils/tests/test_color.py @@ -170,3 +170,8 @@ def test_add_selected_clusters_colors_uses_stable_slots(): ae(colors[1], selected_cluster_color(3)) ae(colors[0], selected_cluster_color(1)) + + empty = _add_selected_clusters_colors( + [], [2, 5, 9], np.zeros((3, 4)), color_index_by_id={5: 3, 2: 1} + ) + ae(empty, np.zeros((3, 4))) From 0838dd27b443e5d529d28255984cccaac08e57fb Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:56:48 +0200 Subject: [PATCH 034/110] Preview Merge View row reordering --- docs/changelog.md | 2 + phy/gui/tests/test_widgets.py | 21 ++++++++ phy/gui/widgets.py | 91 +++++++++++++++++++++++++++++++++-- 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 12e59a16..133a648d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -44,6 +44,8 @@ behavior they verify rather than listed separately. ### Fixed +- Dragging Merge View rows now shows a row preview, insertion boundary, and + edge autoscroll without changing the order until the drop completes. - Show an unsaved-changes marker in the window title, enable the Save action only when curation changes are pending, and confirm successful saves in the status bar. diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index e1910b94..c5811076 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -115,6 +115,27 @@ def on_cluster_drop(sender, payload): assert not table.table_view.dragEnabled() +def test_table_drag_preview_marks_an_insertion_boundary_without_reordering(table): + table.configure_cluster_drag_drop('merge', accepted_roles=('merge',)) + view = table.table_view + before = table._visible_ids() + first = view.visualRect(table._proxy_index_for_id(0)) + second = view.visualRect(table._proxy_index_for_id(1)) + + view._update_drop_preview(first.topLeft()) + assert view._drop_insertion == 0 + assert view._drop_indicator.isVisible() + assert table._visible_ids() == before + + view._update_drop_preview(second.center()) + assert view._drop_insertion == 2 + assert table._visible_ids() == before + + view._clear_drop_preview() + assert view._drop_insertion is None + assert not view._drop_indicator.isVisible() + + def test_key_value_1(qtbot): widget = KeyValueWidget() qtbot.addWidget(widget) diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index f84d37ac..de9a7311 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -46,6 +46,7 @@ QObject, QPalette, QPlainTextEdit, + QPoint, QSize, QSortFilterProxyModel, QSpinBox, @@ -534,11 +535,23 @@ def _install_table_filter_focus_watcher(): class _TableView(QTableView): """QTableView adapter for cluster-ID-only drag-and-drop intents.""" + _drag_scroll_margin = 24 + def __init__(self, owner): super().__init__(owner) self._owner = owner self._drag_start_pos = None self._drag_start_index = QModelIndex() + self._drop_insertion = None + self._drag_position = None + self._drag_scroll_direction = 0 + self._drag_scroll_timer = QTimer(self) + self._drag_scroll_timer.setInterval(30) + self._drag_scroll_timer.timeout.connect(self._auto_scroll_drag) + self._drop_indicator = QLabel(self.viewport()) + self._drop_indicator.setAttribute(Qt.WA_TransparentForMouseEvents) + self._drop_indicator.setStyleSheet('background-color: #5ca8ff;') + self._drop_indicator.hide() def startDrag(self, supported_actions): index = self._drag_start_index if self._drag_start_index.isValid() else self.currentIndex() @@ -547,7 +560,13 @@ def startDrag(self, supported_actions): return drag = QDrag(self) drag.setMimeData(self._owner._cluster_ids_to_mime(ids)) + rect = self.visualRect(index) + if rect.isValid(): + drag.setPixmap(self.viewport().grab(rect)) + if self._drag_start_pos is not None: + drag.setHotSpot(self._drag_start_pos - rect.topLeft()) drag.exec_(Qt.MoveAction) + self._clear_drop_preview() def mousePressEvent(self, event): super().mousePressEvent(event) @@ -582,23 +601,89 @@ def dragEnterEvent(self, event): if self._owner._accept_cluster_drop_event(event): event.acceptProposedAction() else: + self._clear_drop_preview() event.ignore() def dragMoveEvent(self, event): - self.dragEnterEvent(event) + if not self._owner._accept_cluster_drop_event(event): + self._clear_drop_preview() + event.ignore() + return + self._update_drop_preview(event.pos()) + event.acceptProposedAction() + + def dragLeaveEvent(self, event): + self._clear_drop_preview() + event.accept() def dropEvent(self, event): source = event.source() source_table = source._owner if isinstance(source, _TableView) else None ids = self._owner.cluster_ids_from_mime(event.mimeData()) if source_table is None or not self._owner.accepts_cluster_drop(source_table, ids): + self._clear_drop_preview() event.ignore() return - index = self.indexAt(event.pos()) - insertion = index.row() if index.isValid() else len(self._owner._visible_ids()) + insertion = self._drop_insertion + if insertion is None: + insertion = self._drop_insertion_for_pos(event.pos()) + self._clear_drop_preview() self._owner.emit_cluster_drop(source_table, ids, insertion) event.acceptProposedAction() + def _drop_insertion_for_pos(self, pos): + """Return the visual row boundary immediately under a drop position.""" + index = self.indexAt(pos) + if not index.isValid(): + return 0 if pos.y() <= 0 else len(self._owner._visible_ids()) + rect = self.visualRect(index) + return index.row() + int(pos.y() >= rect.center().y()) + + def _update_drop_preview(self, pos): + """Show a non-mutating insertion target and keep edge autoscroll active.""" + self._drag_position = QPoint(pos) + self._drop_insertion = self._drop_insertion_for_pos(pos) + self._show_drop_indicator(self._drop_insertion) + height = self.viewport().height() + if pos.y() < self._drag_scroll_margin: + direction = -1 + elif pos.y() >= height - self._drag_scroll_margin: + direction = 1 + else: + direction = 0 + self._drag_scroll_direction = direction + if direction and not self._drag_scroll_timer.isActive(): + self._drag_scroll_timer.start() + elif not direction: + self._drag_scroll_timer.stop() + + def _show_drop_indicator(self, insertion): + if insertion >= len(self._owner._visible_ids()): + y = self.viewport().height() - 2 + else: + index = self.model().index(insertion, 0) + y = self.visualRect(index).top() + self._drop_indicator.setGeometry(0, max(0, y - 1), self.viewport().width(), 3) + self._drop_indicator.show() + self._drop_indicator.raise_() + + def _auto_scroll_drag(self): + if self._drag_position is None or not self._drag_scroll_direction: + self._drag_scroll_timer.stop() + return + bar = self.verticalScrollBar() + step = max(1, bar.singleStep()) * self._drag_scroll_direction + bar.setValue(bar.value() + step) + self._drop_insertion = self._drop_insertion_for_pos(self._drag_position) + self._show_drop_indicator(self._drop_insertion) + + def _clear_drop_preview(self): + self._drop_insertion = None + self._drag_position = None + self._drag_scroll_direction = 0 + self._drag_scroll_timer.stop() + self._drop_indicator.hide() + class Table(QWidget): """A sortable native Qt table with a compatibility API for legacy callers.""" From 61dd64340da9c25e7e0d0bc3c111429a2e18a12b Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 22:59:22 +0200 Subject: [PATCH 035/110] Use ID-only Merge drag previews --- docs/changelog.md | 4 ++-- phy/gui/tests/test_widgets.py | 2 ++ phy/gui/widgets.py | 16 ++++++++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 133a648d..8542962a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -44,8 +44,8 @@ behavior they verify rather than listed separately. ### Fixed -- Dragging Merge View rows now shows a row preview, insertion boundary, and - edge autoscroll without changing the order until the drop completes. +- Dragging Merge View rows now shows the cluster ID preview, insertion boundary, + and edge autoscroll without changing the order until the drop completes. - Show an unsaved-changes marker in the window title, enable the Save action only when curation changes are pending, and confirm successful saves in the status bar. diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index c5811076..92b9bc62 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -70,6 +70,8 @@ def test_table_cluster_drag_drop_policy_and_payload(table, qtbot): assert table._drag_ids_for_index(table._proxy_index_for_id(1)) == (1, 2) assert table._drag_ids_for_index(table._proxy_index_for_id(3)) == (3,) + count_index = table._proxy_index_for_id(3).sibling(3, table.columns.index('count')) + assert table.table_view._drag_preview_index(count_index).column() == table.columns.index('id') assert table._proxy_index_for_id(1).flags() & Qt.ItemIsDragEnabled assert target._proxy_index_for_id(10).flags() & Qt.ItemIsDropEnabled unrelated = Table(columns=['id'], data=[{'id': 20}]) diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index de9a7311..14f54b84 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -560,14 +560,25 @@ def startDrag(self, supported_actions): return drag = QDrag(self) drag.setMimeData(self._owner._cluster_ids_to_mime(ids)) + index = self._drag_preview_index(index) rect = self.visualRect(index) if rect.isValid(): drag.setPixmap(self.viewport().grab(rect)) if self._drag_start_pos is not None: - drag.setHotSpot(self._drag_start_pos - rect.topLeft()) + drag.setHotSpot(QPoint(rect.width() // 2, self._drag_start_pos.y() - rect.top())) drag.exec_(Qt.MoveAction) self._clear_drop_preview() + def _drag_preview_index(self, index): + """Return the ID cell for a row-oriented drag preview.""" + if not index.isValid(): + return index + try: + column = self._owner.columns.index('id') + except ValueError: + column = 0 + return self.model().index(index.row(), column) + def mousePressEvent(self, event): super().mousePressEvent(event) self._drag_start_pos = None @@ -977,9 +988,6 @@ def _apply_dark_style(self): border: 0; background-color: transparent; } - QTableView::item:hover { - background-color: #222; - } QTableView::item:selected { background-color: transparent; color: white; From 93cd59b6d45e948dbc53bc0d85b23fcb90924a3b Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:05:43 +0200 Subject: [PATCH 036/110] Support current Pillow icon rendering APIs --- phy/gui/qt.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/phy/gui/qt.py b/phy/gui/qt.py index da08e5c1..0c26dbea 100644 --- a/phy/gui/qt.py +++ b/phy/gui/qt.py @@ -462,7 +462,8 @@ def _get_icon(icon, size=64, color='black'): draw = ImageDraw.Draw(image) font = ImageFont.truetype(ttf_file, int(size)) - width, height = draw.textsize(hex_icon, font=font) + left, top, right, bottom = draw.textbbox((0, 0), hex_icon, font=font) + width, height = right - left, bottom - top draw.text( (float(size - width) / 2, float(size - height) / 2), @@ -502,7 +503,8 @@ def _get_icon(icon, size=64, color='black'): # If necessary, scale the image to the target size if org_size != size: - out_image = out_image.resize((org_size, org_size), Image.ANTIALIAS) + resampling = getattr(Image, 'Resampling', Image) + out_image = out_image.resize((org_size, org_size), resampling.LANCZOS) # Save file os.makedirs(op.dirname(output_path), exist_ok=True) From 822a06289e18eac614a21f0e53e37a77c2d52318 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:05:50 +0200 Subject: [PATCH 037/110] Add searchable shortcut reference dialog --- docs/api.md | 9 +++ docs/changelog.md | 3 +- docs/gui.md | 2 +- docs/keyboard_customization.md | 4 +- docs/quickstart.md | 6 +- docs/shortcuts.md | 4 +- docs/visualization.md | 4 +- phy/gui/gui.py | 100 +++++++++++++++++++++++++++++++-- phy/gui/qt.py | 2 + phy/gui/static/icons/f128.png | Bin 0 -> 1540 bytes phy/gui/tests/test_actions.py | 11 ++-- phy/gui/tests/test_gui.py | 31 +++++++++- 12 files changed, 154 insertions(+), 22 deletions(-) create mode 100644 phy/gui/static/icons/f128.png diff --git a/docs/api.md b/docs/api.md index a99b1433..96a8aed2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1331,6 +1331,15 @@ Show the window. --- +#### GUI.show_shortcuts_and_commands + + +**`GUI.show_shortcuts_and_commands(self)`** + +Open a searchable reference of the active shortcuts and commands. + +--- + #### GUI.unlock_status diff --git a/docs/changelog.md b/docs/changelog.md index 8542962a..5ddc10de 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -50,7 +50,8 @@ behavior they verify rather than listed separately. only when curation changes are pending, and confirm successful saves in the status bar. - Rename the Help shortcut reference action to **Show shortcuts and commands** - and show Enter/Escape guidance when the `:` command prompt is active. + and show Enter/Escape guidance when the `:` command prompt is active. The + shortcut now opens an in-GUI searchable reference, including plugin actions. - Pressing `:` repeatedly no longer leaves the command prompt visible after Escape closes it. - Keep the disabled Cluster View overlay fixed while scrolling in Merge mode, diff --git a/docs/gui.md b/docs/gui.md index 999ef620..cdd0559a 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -92,7 +92,7 @@ Most graphical views share these controls: View-specific controls appear in the view menu and in the [shortcut reference](shortcuts.md). Press `H` or choose **Help > Show shortcuts and commands** to -print the bindings active in the current session. +open the bindings active in the current session. ## Automatic updates and large selections diff --git a/docs/keyboard_customization.md b/docs/keyboard_customization.md index 74c6f970..3f0b861e 100644 --- a/docs/keyboard_customization.md +++ b/docs/keyboard_customization.md @@ -1,7 +1,7 @@ # Customize keyboard shortcuts -Press `H` or choose **Help > Show shortcuts and commands** to print the effective -shortcuts and command aliases in the console. The generated +Press `H` or choose **Help > Show shortcuts and commands** to open a searchable reference +of the effective shortcuts and command aliases. The generated [keyboard shortcut reference](shortcuts.md) lists the defaults, but `H` is the better source after plugins have changed them. diff --git a/docs/quickstart.md b/docs/quickstart.md index 3134aeda..11cb2786 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -63,9 +63,9 @@ Click one row in the Cluster View. Then click a highly ranked row in the Similarity View. The other views should now compare the two clusters using different colors. -Press `H` or choose **Help > Show shortcuts and commands** to print the effective -keyboard shortcuts and command aliases. Menus also show their shortcuts, and hovering -over an action shows its command name in the status bar. +Press `H` or choose **Help > Show shortcuts and commands** to open a searchable reference +of the effective keyboard shortcuts and command aliases. Menus also show their shortcuts, and +hovering over an action shows its command name in the status bar. ## 3. Inspect one cluster diff --git a/docs/shortcuts.md b/docs/shortcuts.md index efaca7f3..4c5ca762 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -1,7 +1,7 @@ # Keyboard shortcuts and snippets -This page presents the list of shortcuts and snippets in the Template GUI. Press `H` or use the -Help menu to print the shortcuts that are active in the current session. +This page presents the list of shortcuts and snippets in the Template GUI. Press `H` or choose +**Help > Show shortcuts and commands** to open the shortcuts active in the current session. ## List of keyboard shortcuts diff --git a/docs/visualization.md b/docs/visualization.md index 7ea3d55f..a03a1633 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -64,8 +64,8 @@ The GUI is made of several parts: Dock widgets can be moved anywhere in or outside of the GUI (floating mode). They can be closed as well. New views can be added from the `View` menu in the menu bar. -Use the menu, keyboard shortcuts, or snippets to trigger actions. Press `H` or use the Help -menu to print the active keyboard shortcuts and snippets in the terminal. +Use the menu, keyboard shortcuts, or snippets to trigger actions. Press `H` or choose +**Help > Show shortcuts and commands** to open the active keyboard shortcuts and snippets. ### Cluster view diff --git a/phy/gui/gui.py b/phy/gui/gui.py index 8fffb655..36096045 100644 --- a/phy/gui/gui.py +++ b/phy/gui/gui.py @@ -11,13 +11,17 @@ from phylib.utils import connect, emit -from .actions import Actions, Snippets +from .actions import Actions, Snippets, _get_shortcut_string from .qt import ( + QAbstractItemView, QApplication, QCheckBox, + QDialog, QDockWidget, QHBoxLayout, + QHeaderView, QLabel, + QLineEdit, QMainWindow, QMenu, QMessageBox, @@ -26,6 +30,8 @@ QSize, QStatusBar, Qt, + QTableWidget, + QTableWidgetItem, QToolBar, QVBoxLayout, QWidget, @@ -45,6 +51,79 @@ # ----------------------------------------------------------------------------- +class _ShortcutReferenceDialog(QDialog): + """Searchable reference for the GUI's live actions and command aliases.""" + + _headers = ('Scope', 'Action', 'Shortcut', 'Command', 'Description') + + def __init__(self, gui): + super().__init__(gui) + self.gui = gui + self.setWindowTitle('Shortcuts and commands') + self.setModal(False) + self.resize(900, 520) + + self.search = QLineEdit(self) + self.search.setObjectName('shortcut-reference-search') + self.search.setPlaceholderText('Search actions, shortcuts, commands, or descriptions') + self.search.textChanged.connect(self._update_entries) + + self.entries = QTableWidget(self) + self.entries.setObjectName('shortcut-reference-entries') + self.entries.setColumnCount(len(self._headers)) + self.entries.setHorizontalHeaderLabels(self._headers) + self.entries.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.entries.setSelectionBehavior(QAbstractItemView.SelectRows) + self.entries.setSelectionMode(QAbstractItemView.SingleSelection) + self.entries.verticalHeader().hide() + header = self.entries.horizontalHeader() + for column in range(len(self._headers) - 1): + header.setSectionResizeMode(column, QHeaderView.ResizeToContents) + header.setSectionResizeMode(len(self._headers) - 1, QHeaderView.Stretch) + + layout = QVBoxLayout(self) + layout.addWidget(self.search) + layout.addWidget(self.entries) + self._rows = () + + def refresh(self): + """Collect the actions currently registered with the GUI.""" + rows = [] + for actions in self.gui.actions: + for name, action in actions._actions_dict.items(): + if name.startswith('_'): + continue + shortcut = _get_shortcut_string(action.qaction.shortcut()) or '-' + command = f':{action.alias}' if action.alias else '-' + rows.append( + ( + actions.name, + action.qaction.text() or name, + shortcut, + command, + action.docstring.replace('\n', ' '), + ) + ) + self._rows = tuple(sorted(rows, key=lambda row: (row[0].lower(), row[1].lower()))) + self._update_entries() + + def _update_entries(self): + query = self.search.text().strip().lower() + rows = self._rows + if query: + rows = [row for row in rows if query in ' '.join(row).lower()] + self.entries.setRowCount(len(rows)) + for row_index, row in enumerate(rows): + for column, value in enumerate(row): + self.entries.setItem(row_index, column, QTableWidgetItem(value)) + if not rows: + self.entries.setRowCount(1) + self.entries.setSpan(0, 0, 1, len(self._headers)) + self.entries.setItem(0, 0, QTableWidgetItem('No matching shortcuts or commands.')) + else: + self.entries.clearSpans() + + def _try_get_matplotlib_canvas(view): """Get the Qt widget from a matplotlib figure.""" try: @@ -530,6 +609,7 @@ def __init__( # List of attached Actions instances. self.actions = [] + self._shortcut_reference = None # Mapping {name: menuBar}. self._menus = {} @@ -623,11 +703,10 @@ def exit(): self.view_actions.separator() # Help menu. - @self.help_actions.add(shortcut=('HelpContents', 'h')) + @self.help_actions.add(shortcut=('HelpContents', 'h'), icon='f128', toolbar=True) def show_all_shortcuts(): - """Print the active keyboard shortcuts and command aliases.""" - for actions in self.actions: - actions.show_shortcuts() + """Show the active keyboard shortcuts and command aliases.""" + return self.show_shortcuts_and_commands() self.help_actions.get('show_all_shortcuts').setText('Show shortcuts and commands') @@ -847,6 +926,17 @@ def dialog(self, message): box.setText(message) return box + def show_shortcuts_and_commands(self): + """Open a searchable reference of the active shortcuts and commands.""" + if self._shortcut_reference is None: + self._shortcut_reference = _ShortcutReferenceDialog(self) + self._shortcut_reference.refresh() + self._shortcut_reference.show() + self._shortcut_reference.raise_() + self._shortcut_reference.activateWindow() + self._shortcut_reference.search.setFocus() + return self._shortcut_reference + def _set_dirty(self, dirty): """Show whether the window has unsaved curation changes.""" self.setWindowTitle(f'* {self._window_title}' if dirty else self._window_title) diff --git a/phy/gui/qt.py b/phy/gui/qt.py index 0c26dbea..e102c1aa 100644 --- a/phy/gui/qt.py +++ b/phy/gui/qt.py @@ -95,6 +95,8 @@ QOpenGLWidget, QStyle, QTableView, + QTableWidget, + QTableWidgetItem, ) diff --git a/phy/gui/static/icons/f128.png b/phy/gui/static/icons/f128.png new file mode 100644 index 0000000000000000000000000000000000000000..0db44b0befb641213c4efdc8508f0a3ecdd5b4b9 GIT binary patch literal 1540 zcmV+f2K)JmP)m2MV*0_)0K_iZ@1QTMOCQfiNQ|;p1pSn}2uBocNx4Xt4is|X9dtUdPbI(0> zyG2;EfHpAieRm7+kh>;;TY)iPd1mZc;CJ9h;49#B;GlQE10+Bd3aFFuPW3#O|9i;NUH^}%; zX5@%&tH{-K}7EWPJ7IUsk|@l{ip}W98 z*aj?^5k%xY$6&Uk5i}!5ffsV<_NwP6u_C9A5ybOFM=Mwuw16vs-5#?d;UvgGV6{i5 z!=+hTTWH+_+fD8-!6%Pz0(=nC01{HzhIv7k{DdgwzXPs!V@50R(zD~(F0e}85$^+- z^1+uw&Cohvn$!ph3k@<2ta8uRnFUpJ@3K&_vU%VLRuf3>ppbB5z}_4oj$xf2sPBR! znBdRQ+tZ2+a0D%EFu~VT&j4qDn;oqv2FMb=4qM7b zbMDoF=)6O{5IDxd?ol)-v-=38y!IVclAuEC9 zK?578dB}RvM$)Eg)j~+Q;&n%>@doe(oWOd0WeTmL9FP!rLlgskxU2xSV@(aenyae3 z-2V$2)Hct9KP#I^3#uyFzQ_QN=kRF^B-CBNrz{k{pQean|6@l#+S*?iei^+P>*h-F zep_pr`9HAg)aTU*`0==dFz%k6`BS8cuW`z2o4b=R?yeO=2An1>zZ)0#5@$5)B+0`L~;tOtJw5DJ=+ z+ezOa)|q{Oh1d(M@pv@O<$b6Zu!;J!FXc~Q`vW0pt1H&aLA)0B__-o}(4C6e+GfBEQ8 z-Thl`%n|Q%G{g#lHa3&zFeT{-%;xa5#6f~sCq*z5S{`}&x~<-U`^E0jx! zbJ!c>SFsR~Cq+Z-D$0-HD=Bv87PiFA|w5D+# z-ZD_!uf)2wRgb;N6LvW|OD+_&+-;>^GeqzAPLrN78pt7sP*CZ*j(U}xD!{9Nd%f?X zAZC1xQ(tg-!+1Dx@)6SjRgT8xp#@wPLXaZFG{A`fG>!+LQHU4@5Y2Dh-#U4qjU~_T zgGe-BXxX}-^rc3XcLI8M_%#tudkzZn0v&m&@*4v^JLzaFxjF#SJ^d=+Ps&w;iTBJ- zkIs^XgkMYg+rsQmX#Jnd$`E_7cKZLKF(UU}4SYhe2b{!?-TKFHqhk Date: Sun, 2 Aug 2026 23:06:03 +0200 Subject: [PATCH 038/110] Place merge controls beside Save --- phy/cluster/supervisor.py | 22 ++++++++++++++++++++-- phy/cluster/tests/test_supervisor.py | 12 ++++++++++++ phy/gui/static/icons/f0e8.png | Bin 0 -> 1213 bytes 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 phy/gui/static/icons/f0e8.png diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index a190c031..35bef09d 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -538,6 +538,12 @@ def attach(self, gui): self._create_select_actions() self._create_toolbar(gui) + @connect(sender=gui) + def on_default_actions_created(sender): + self._place_merge_actions_before_save(gui) + + self._place_merge_actions_before_save(gui) + def _create_edit_actions(self): w = 'edit' self.add(w, 'undo', set_busy=True, icon='f0e2') @@ -583,7 +589,7 @@ def _create_select_actions(self): docstring='Select the first N eligible clusters shown in the similarity view.', ) self.add(w, 'unselect_similar') - self.add(w, 'toggle_merge_mode') + self.add(w, 'toggle_merge_mode', icon='f0e8') self.add( w, 'skip_noise_and_mua', @@ -632,7 +638,6 @@ def _create_select_actions(self): def _create_toolbar(self, gui): gui._toolbar.addAction(self.edit_actions.get('undo')) gui._toolbar.addAction(self.edit_actions.get('redo')) - gui._toolbar.addSeparator() gui._toolbar.addAction(self.select_actions.get('reset_wizard')) gui._toolbar.addAction(self.select_actions.get('previous_best')) gui._toolbar.addAction(self.select_actions.get('next_best')) @@ -641,6 +646,19 @@ def _create_toolbar(self, gui): gui._toolbar.addSeparator() gui._toolbar.show() + def _place_merge_actions_before_save(self, gui): + """Place merge controls beside Save once the default actions exist.""" + save_action = gui.file_actions.get('save') + if save_action is None: + return + toolbar = gui._toolbar + merge_mode = self.select_actions.get('toggle_merge_mode') + merge = self.edit_actions.get('merge') + toolbar.removeAction(merge_mode) + toolbar.removeAction(merge) + toolbar.insertAction(save_action, merge_mode) + toolbar.insertAction(save_action, merge) + # ----------------------------------------------------------------------------- # Clustering GUI component diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index f242a2ab..dca50494 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -1058,6 +1058,18 @@ def test_supervisor_select_first_similar_config(gui, cluster_ids, similarity): assert ( _get_shortcut_string(supervisor.select_actions.get('toggle_merge_mode').shortcut()) == 'v' ) + toolbar_actions = gui._toolbar.actions() + assert supervisor.action_creator.edit_actions.get('merge') in toolbar_actions + assert supervisor.action_creator.select_actions.get('toggle_merge_mode') in toolbar_actions + assert gui.help_actions.get('show_all_shortcuts') in toolbar_actions + save_index = toolbar_actions.index(gui.file_actions.get('save')) + assert toolbar_actions[save_index - 2 : save_index] == [ + supervisor.action_creator.select_actions.get('toggle_merge_mode'), + supervisor.action_creator.edit_actions.get('merge'), + ] + assert not supervisor.action_creator.select_actions.get('toggle_merge_mode').icon().isNull() + assert not supervisor.action_creator.edit_actions.get('merge').icon().isNull() + assert not gui.help_actions.get('show_all_shortcuts').icon().isNull() with raises(ValueError, match='positive integer'): supervisor.select_first_similar(0) diff --git a/phy/gui/static/icons/f0e8.png b/phy/gui/static/icons/f0e8.png new file mode 100644 index 0000000000000000000000000000000000000000..18da45e9258fbc23ab4b5dfbcd83b50e9d4367e2 GIT binary patch literal 1213 zcmV;u1Va0XP)6vuz}b|W8(Gf_iO&}bAA*kFi>5gvh8apz;`1Gr$}0!UzCbm77e5=dAObf@eH z7!4YQX%S(Vp=a)8aq3Vtm-|t7ZdF$s`kz#~(^Yl;r%u&9Rp-=g2#d^o9tXYyR)KP0 zxet5^yk}(RfprcBRqPn>H^T>BEjNI-5hKr~5mc!k5QQHTZt&~o^9#cHzZ%&= z5XuRD)@-9*1@tJI~7G+!@$six(Sh4^6FR_ zW1W$HE=5)%2VSoEeVO>d*fKWY0>l8{n(yive<$uaU={cp>y1^p34v4I6-JT$%-!t2#91NJx6xoPQ%BiPqJxNS8D z;PdQhn<;WHFogG2Y!dKmtan6HR>(2J%^|F$lo_@wI@49D7XJ|ig*CE;N>?`jLUVPrw-DQ*saoETZN4DFe?FiUOkL@m21~^8NB}(GvxxX1&7$CI*sj)CXY6Vhb zVSv;Mq{hMksTD|#nC}AoFW_qc&F8Pup1#)O>O<_SvO`fINts6;Z8s1dpQgRLkYjV@ zT_s178zs%}>XxS69k#wR?8{LtdQW)C$Z6vpnLZaypAAnA@6hxoB(>EQX=!5O2$EtS znh?1TywQXAfa&8UKLbv;2vuv}13pFm74CCwrEQJ?AJZPoxA8szK0_`}`|utFK0*G< b+sgX{5WRt5U=WK100000NkvXXu0mjfW;HW{ literal 0 HcmV?d00001 From e9849f88538e99d61d8f43d71bf2ac14273ad58d Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:06:08 +0200 Subject: [PATCH 039/110] Check clean Save state before editing --- phy/cluster/tests/test_supervisor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index dca50494..018fee27 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -1192,8 +1192,8 @@ def test_supervisor_edge_cases(supervisor): def test_supervisor_save(qtbot, gui, supervisor): assert not gui.windowTitle().startswith('* ') - supervisor.label('group', 'noise', [30]) assert not gui.file_actions.get('save').isEnabled() + supervisor.label('group', 'noise', [30]) supervisor.block() assert gui.windowTitle().startswith('* ') assert gui.file_actions.get('save').isEnabled() From 7a3c91eaf48d7d2927dc438caf6c6383b03bcd20 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:06:42 +0200 Subject: [PATCH 040/110] Show row-wide table hover feedback --- docs/changelog.md | 3 ++- phy/gui/tests/test_widgets.py | 10 ++++++++++ phy/gui/widgets.py | 20 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 5ddc10de..3d6d1c71 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -45,7 +45,8 @@ behavior they verify rather than listed separately. ### Fixed - Dragging Merge View rows now shows the cluster ID preview, insertion boundary, - and edge autoscroll without changing the order until the drop completes. + edge autoscroll, and a row-wide hover cue without changing the order until + the drop completes. - Show an unsaved-changes marker in the window title, enable the Save action only when curation changes are pending, and confirm successful saves in the status bar. diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index 92b9bc62..3f045d0a 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -138,6 +138,16 @@ def test_table_drag_preview_marks_an_insertion_boundary_without_reordering(table assert not view._drop_indicator.isVisible() +def test_table_hover_is_row_wide_without_selecting(table): + assert table.get_selected_ids() == [] + table._set_hovered_row(3) + assert table._hovered_row_id == 3 + assert table.get_selected_ids() == [] + + table._set_hovered_row(None) + assert table._hovered_row_id is None + + def test_key_value_1(qtbot): widget = KeyValueWidget() qtbot.addWidget(widget) diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index 14f54b84..7c734269 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -485,6 +485,10 @@ def paint(self, painter, option, index): bg = self._table._selection_background(row_id) if fg is None: fg = QColor('#ffffff') + elif row_id == self._table._hovered_row_id: + bg = QColor('#222222') + if fg is None: + fg = QColor('#ffffff') elif fg is None: fg = QColor('#ffffff') @@ -722,6 +726,7 @@ def __init__( self.value_names = list(value_names or self.columns) self.data = list(data or []) self._selected_ids = [] + self._hovered_row_id = None self._selected_index_offset = 0 self._selected_index_by_id = None self._selection_revision = 0 @@ -759,6 +764,8 @@ def __init__( self.table_view.viewport().installEventFilter(self) self.table_view.setSelectionBehavior(QAbstractItemView.SelectRows) self.table_view.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.table_view.setMouseTracking(True) + self.table_view.viewport().setMouseTracking(True) self.table_view.clicked.connect(self._on_row_clicked) self.table_view.horizontalHeader().sectionClicked.connect(self._on_header_clicked) self.table_view.verticalHeader().hide() @@ -913,6 +920,12 @@ def eventFilter(self, obj, event): if obj is self.table_view.viewport() and event.type() == QEvent.Resize: if self._interaction_overlay is not None: self._interaction_overlay.setGeometry(self.table_view.viewport().geometry()) + if obj is self.table_view.viewport() and event.type() == QEvent.MouseMove: + index = self.table_view.indexAt(event.pos()) + row_id = self._visible_ids()[index.row()] if index.isValid() else None + self._set_hovered_row(row_id) + if obj is self.table_view.viewport() and event.type() == QEvent.Leave: + self._set_hovered_row(None) if ( self._interaction_blocked and obj is self.table_view.viewport() @@ -1238,6 +1251,13 @@ def _on_row_clicked(self, index): else: self.select([row_id]) + def _set_hovered_row(self, row_id): + """Update the row-wide hover tint without changing selection.""" + if row_id == self._hovered_row_id: + return + self._hovered_row_id = row_id + self.table_view.viewport().update() + def get_selected_ids(self): visible = set(self._visible_ids()) return [row_id for row_id in self._selected_ids if row_id in visible] From 410dcac04631b0d0d7413a32e2ecfd26b85e90dc Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:07:08 +0200 Subject: [PATCH 041/110] Refine merge toolbar workflow --- phy/cluster/supervisor.py | 29 +++++++++++++++++++++------ phy/cluster/tests/test_supervisor.py | 9 ++++++++- phy/gui/static/icons/f0c1.png | Bin 0 -> 2174 bytes phy/gui/static/icons/f0e8.png | Bin 1213 -> 0 bytes phy/gui/static/icons/f247.png | Bin 1063 -> 0 bytes phy/gui/static/icons/f542.png | Bin 0 -> 1030 bytes 6 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 phy/gui/static/icons/f0c1.png delete mode 100644 phy/gui/static/icons/f0e8.png delete mode 100644 phy/gui/static/icons/f247.png create mode 100644 phy/gui/static/icons/f542.png diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 35bef09d..be9881e3 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -551,7 +551,7 @@ def _create_edit_actions(self): self.edit_actions.separator() # Clustering. - self.add(w, 'merge', set_busy=True, icon='f247') + self.add(w, 'merge', set_busy=True, icon='f0c1') self.add(w, 'split', set_busy=True) self.edit_actions.separator() @@ -589,7 +589,7 @@ def _create_select_actions(self): docstring='Select the first N eligible clusters shown in the similarity view.', ) self.add(w, 'unselect_similar') - self.add(w, 'toggle_merge_mode', icon='f0e8') + self.add(w, 'toggle_merge_mode', icon='f542') self.add( w, 'skip_noise_and_mua', @@ -636,11 +636,10 @@ def _create_select_actions(self): self.select_actions.separator() def _create_toolbar(self, gui): - gui._toolbar.addAction(self.edit_actions.get('undo')) - gui._toolbar.addAction(self.edit_actions.get('redo')) gui._toolbar.addAction(self.select_actions.get('reset_wizard')) gui._toolbar.addAction(self.select_actions.get('previous_best')) gui._toolbar.addAction(self.select_actions.get('next_best')) + gui._toolbar.addSeparator() gui._toolbar.addAction(self.select_actions.get('previous')) gui._toolbar.addAction(self.select_actions.get('next')) gui._toolbar.addSeparator() @@ -654,10 +653,28 @@ def _place_merge_actions_before_save(self, gui): toolbar = gui._toolbar merge_mode = self.select_actions.get('toggle_merge_mode') merge = self.edit_actions.get('merge') - toolbar.removeAction(merge_mode) - toolbar.removeAction(merge) + undo = self.edit_actions.get('undo') + redo = self.edit_actions.get('redo') + for action in ( + merge_mode, + merge, + undo, + redo, + getattr(self, '_merge_history_separator', None), + getattr(self, '_save_separator', None), + getattr(self, '_help_separator', None), + ): + if action is not None: + toolbar.removeAction(action) toolbar.insertAction(save_action, merge_mode) toolbar.insertAction(save_action, merge) + self._merge_history_separator = toolbar.insertSeparator(save_action) + toolbar.insertAction(save_action, undo) + toolbar.insertAction(save_action, redo) + self._save_separator = toolbar.insertSeparator(save_action) + help_action = gui.help_actions.get('show_all_shortcuts') + if help_action is not None: + self._help_separator = toolbar.insertSeparator(help_action) # ----------------------------------------------------------------------------- diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 018fee27..c59bbb68 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -1063,10 +1063,17 @@ def test_supervisor_select_first_similar_config(gui, cluster_ids, similarity): assert supervisor.action_creator.select_actions.get('toggle_merge_mode') in toolbar_actions assert gui.help_actions.get('show_all_shortcuts') in toolbar_actions save_index = toolbar_actions.index(gui.file_actions.get('save')) - assert toolbar_actions[save_index - 2 : save_index] == [ + assert toolbar_actions[save_index - 6 : save_index] == [ supervisor.action_creator.select_actions.get('toggle_merge_mode'), supervisor.action_creator.edit_actions.get('merge'), + toolbar_actions[save_index - 4], + supervisor.action_creator.edit_actions.get('undo'), + supervisor.action_creator.edit_actions.get('redo'), + toolbar_actions[save_index - 1], ] + assert toolbar_actions[save_index - 4].isSeparator() + assert toolbar_actions[save_index - 1].isSeparator() + assert toolbar_actions[save_index + 1].isSeparator() assert not supervisor.action_creator.select_actions.get('toggle_merge_mode').icon().isNull() assert not supervisor.action_creator.edit_actions.get('merge').icon().isNull() assert not gui.help_actions.get('show_all_shortcuts').icon().isNull() diff --git a/phy/gui/static/icons/f0c1.png b/phy/gui/static/icons/f0c1.png new file mode 100644 index 0000000000000000000000000000000000000000..fc0747a2042565a630e6499ca8d792480903e7fc GIT binary patch literal 2174 zcmV-^2!Z#BP)qdDK(^+WgHsqHu0(=%^3ZwrREZ>GDd{#Di0)jd5mdW-si z0snhz;Amh9FbUWj*db`Y9#{^10W1Js1-|jmYCzra>uIW+-#Y_01788nlGkeBW#AOU zudmCx51FK39he6EMh2(>^y9qPARn}CWADEK9PD}5{MB&}e2=uTvxC4-CG&hLh<@5f zN!|<)8x9TN7_ZAFGlKPe8Svrsw;EUid-T)%4BT`% z$}2w>I0(22SXP3sjBru609&%)*Hln)32;(^oUy>eiTwL8cw!;G!XAIA~Z+?kHkIN-kx?Uf@3g#(5qVU^}vgxQ}8Z=-ERR@f9iqZYB2* z)jTioWwNv)Vhh}gP*Y|szkFE%Uz7xX0@b1It;k%?oVMj<_yDd?uo-d?z%!id(m1q!;4-$15GRae;fFE$4k9l7q z`4O$)1@=JTYO2o!;0m1Y19@FDfD}9>L7<*}PwSYsYlXjjn;&p>gxaJG2@S>laZBJL zXLQ5>QvPmQHz4vZJwM9yl>tx3ZDBRZ*6$7A8vi^c5~Ry5q$T2wC2;1F-Di7;_?XlE z=Y4xS=Fc(84w%34-i-mkJQA<*z)IrYmyA!!fUWz>hNB(-O9STZgiiJSv`2S1&hvSd zn~kXReEYme(A3ulyiRfQ)3Z5cz{l>7BU|v~SzY&5F*v!sMm$_)nAO0(Ir`Mh|Bokt zw+=ZX1-ukJ0vEow1EYgydysiA&j5P%4X~Bry&dp7#mP#57LY}vqEF!GkT&kM9Da|4 zg_ateK(?@H0#60c_bLD&1AGpQH2lYry#q)soG$Qv?bLaBB6w@eGpc}J=NWuU>t@0( zhH)$cmI2%PK6P)5w-S|?k-o&O_-e)gFZ$;x-f!ZnL`6~*{PAS__SQ>38X(|G**_u% zunBD+qkIh*gL|KsGInLi)4W_cVMaoKy){~cE4HA`)M~+p>v@4&5A16CjKqbvZQIWJ z*BLoA)Bn{35Zcw9XZit5Bz*{783V7IN7L&0(2`AWAgjcS1psU!+?*@|By$XI4=`mT z<0oJnqxY7;;x_tP@OQ+(Ydjo)D%_+0}s)?eVRH~nSECj#Z$Mc~Z90M`Yw)h_CJ3;xsu@F4(IQYvfD zDo{R*geGqP&hnU^Xn;Asy=H>*{r$CMVXs}^=o{f0&iadpC!kP)k8o=MyH7s?x5<%W zgbiNSV}SuK4dk?w|DbQL958__aa+$vm(nAAbC3RwjY!Ob@InRv}2o-{*UF82L8 za>Qi;X$H^+LRq1qr{sO{(GIS>8=Hk1xP{PY!)r)XCt;5C)Tk&!W7m3IhnQ#K4mI>s ze15OtpWlni0ez(F9;MVFa24f;KuETKYS>%dzQz?w9X;&$BxyaWxwKRn5#^mVfDNvMZsRQe#BJ92xSe{GvGza2b1Wo5$w>p&&wQk z@FE#uKiuL&!=cJ|d@-*#$tug90WXpfw#V%l+l*f23$Jj;c?EE`m(g?JMYhN}7q{1* z7sh#~I~wHA1pTeJ84>WJneY)y?k^lez07*qoM6N<$f?m-q A5&!@I literal 0 HcmV?d00001 diff --git a/phy/gui/static/icons/f0e8.png b/phy/gui/static/icons/f0e8.png deleted file mode 100644 index 18da45e9258fbc23ab4b5dfbcd83b50e9d4367e2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1213 zcmV;u1Va0XP)6vuz}b|W8(Gf_iO&}bAA*kFi>5gvh8apz;`1Gr$}0!UzCbm77e5=dAObf@eH z7!4YQX%S(Vp=a)8aq3Vtm-|t7ZdF$s`kz#~(^Yl;r%u&9Rp-=g2#d^o9tXYyR)KP0 zxet5^yk}(RfprcBRqPn>H^T>BEjNI-5hKr~5mc!k5QQHTZt&~o^9#cHzZ%&= z5XuRD)@-9*1@tJI~7G+!@$six(Sh4^6FR_ zW1W$HE=5)%2VSoEeVO>d*fKWY0>l8{n(yive<$uaU={cp>y1^p34v4I6-JT$%-!t2#91NJx6xoPQ%BiPqJxNS8D z;PdQhn<;WHFogG2Y!dKmtan6HR>(2J%^|F$lo_@wI@49D7XJ|ig*CE;N>?`jLUVPrw-DQ*saoETZN4DFe?FiUOkL@m21~^8NB}(GvxxX1&7$CI*sj)CXY6Vhb zVSv;Mq{hMksTD|#nC}AoFW_qc&F8Pup1#)O>O<_SvO`fINts6;Z8s1dpQgRLkYjV@ zT_s178zs%}>XxS69k#wR?8{LtdQW)C$Z6vpnLZaypAAnA@6hxoB(>EQX=!5O2$EtS znh?1TywQXAfa&8UKLbv;2vuv}13pFm74CCwrEQJ?AJZPoxA8szK0_`}`|utFK0*G< b+sgX{5WRt5U=WK100000NkvXXu0mjfW;HW{ diff --git a/phy/gui/static/icons/f247.png b/phy/gui/static/icons/f247.png deleted file mode 100644 index a4a32ac70ff0fe3cfc486da9d7a83f1359801bd1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1063 zcmV+?1laqDP)gx zfd-<1f{LD=E8e-g&WypEXZQ5YkM5a%J=4y9q3M~P*WFe1s_IqMtKkIOjSQzS8Xx7~ zG7Q=#s$ulT=UM!1H!{40Ck0K27-5Ka@Y;A>S%RC6p1{NNcy|1~IO79+NUr|Vj?1`t zd_$MiCDM8$zBh-*zI?We7|HGb3=VWt0H*~W1_F=0^0>a-5f%{z0EebK$xTM2^=N!P zCGb-gz(U(T#^>X67tzOq*qt>XGpYVvQu9?1Cw)}KyALaj*;Gtb`|^N`c(fIAHVp|{)=j{=aUy_q6R>Vf2BF+>*x@HBo9 zXq}}EF_3x+FI%FPQ8gF1jPs>?nm~Nk8GL0j7r4E~bNcwz{9IqXdQOyF3$dcbjm&#Z*HC9$)32p^{* z$9=fd5&_M!s49RsLpyj6pCzK_exWkmnika2fM}oR@puASA|mr~?M_*SU&;rpO4Pi2 zd*fpu(Do^Qbp0l7V^~H2)A|YOpvt{{AtHQ1UWiR%7HtJWF*d(~7xBB zoPFAl1P}3nz*rT;MyF(GTL78RbaiNP98nB`lVKgXKx|sth}OH4Two3F+^Ae)9X{Yl z-n-M3jmN&6`trzq6XYiYZMTrE6 z6T6SMafj*u82N9*ah9DN;S&BzLS}mGnBR6cj9W?NnT_9f1u0(BLUE!&L|XT7gUPVD zJkCdE$Jp;%V!v%e5DTntna$rPnXn~&x9LoNgTHM^@uDUo&8KmX>HK}$Dn*-!tZ+<2 hn>j)%EaGpw@h?O`V6u>Q4^037002ovPDHLkV1ha8{*3?t diff --git a/phy/gui/static/icons/f542.png b/phy/gui/static/icons/f542.png new file mode 100644 index 0000000000000000000000000000000000000000..fc8bd56a1030214ffb2cb72e015eb2b127ad0376 GIT binary patch literal 1030 zcmV+h1o``kP)369@7ngL05cXCt`g883(|Ee8ZIhXHo?tZ26wMy}SZM zz;a#^K9N^|9l$6OfQpO(5n^R)z>HxwM|C~N-DJk@oBk0orSU||7a`YpMkE@0l4)k3H&?6R&fuK=sS z9K{DhwSaHdZDkc8L`HrN*^cT$s7N8Se$%$Bmcr`>T)e;{1`Htcb6K0AO1MBP{wEeF zM}Su3UKdzIaGyEBKS)f_RM?i$AWn29m2L>|esY#zfI@&4gs@dUrV1$BpY&{^L0P$NZeL%Vu+;#W*u!6#% zf#wkKlX%42gJcs}0#4xXO;KUc5uG623ZA%oYbAh==rA&4B)x@E5w5uBYUK*rnX9B* z!8EXq*nMgNwSorligXcfxaVqV1?Py}pi@8$fiJ*b{7s96K}C2(x)t1Od!`mpzdj25 zX0sK9$fjh?{dUSdS1S>;Gk4fH;_dG69k}oAt>v^hf*g|x+2{%nflI)CWa_LX)C#T< z?+HEwmpj}W7?et)fz0>Q$lKbJ?)sp+Zt3?r1)C#JPB1`%-2^h_y+=B(ff2+7hRB{H z2#~bb-gw}I10P9YCgkaLN*kfnH8+2@|2eyS!v3M`N@Mb)Lu zAqO%lyW3P!&k({ZWJ^+ZD$B0UN!Lc@))neXdw?Ir#H~iZ>B=l|=S^k@D^pT3!^G$I zETLvUgz>pVYhS8VaOwq>3?^lwFMw|XzR?%JHv!-13*ei8Z}bK5O~5z$0{AB28+`$M z6Yvdd0V Date: Sun, 2 Aug 2026 23:07:52 +0200 Subject: [PATCH 042/110] Format shortcut reference test --- phy/gui/tests/test_actions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/phy/gui/tests/test_actions.py b/phy/gui/tests/test_actions.py index 233c6800..fece68c4 100644 --- a/phy/gui/tests/test_actions.py +++ b/phy/gui/tests/test_actions.py @@ -165,7 +165,10 @@ def press(): # Show all action shortcuts and commands in the GUI. reference = gui.help_actions.show_all_shortcuts() rows = [ - [reference.entries.item(row, column).text() for column in range(reference.entries.columnCount())] + [ + reference.entries.item(row, column).text() + for column in range(reference.entries.columnCount()) + ] for row in range(reference.entries.rowCount()) ] assert any(row[1] == 'Press' and row[2] == 'g' for row in rows) From 7fa0493da5a3c10f991bc3d958107f05e18994d4 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:07:56 +0200 Subject: [PATCH 043/110] Regenerate API reference --- docs/api.md | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/docs/api.md b/docs/api.md index 96a8aed2..dbd193fe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -5805,6 +5805,15 @@ Close the view. --- +#### AmplitudeView.cluster_color_index + + +**`AmplitudeView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### AmplitudeView.decrease_marker_size @@ -6207,6 +6216,15 @@ Close the view. --- +#### ClusterScatterView.cluster_color_index + + +**`ClusterScatterView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### ClusterScatterView.decrease_marker_size @@ -7289,6 +7307,15 @@ Close the view. --- +#### CorrelogramView.cluster_color_index + + +**`CorrelogramView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### CorrelogramView.decrease @@ -7548,6 +7575,15 @@ Close the view. --- +#### FeatureView.cluster_color_index + + +**`FeatureView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### FeatureView.decrease @@ -7820,6 +7856,15 @@ Close the view. --- +#### FiringRateView.cluster_color_index + + +**`FiringRateView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### FiringRateView.decrease @@ -8063,6 +8108,15 @@ Close the view. --- +#### HistogramView.cluster_color_index + + +**`HistogramView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### HistogramView.decrease @@ -8299,6 +8353,15 @@ Close the view. --- +#### ISIView.cluster_color_index + + +**`ISIView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### ISIView.decrease @@ -8559,6 +8622,15 @@ Close the view. --- +#### ManualClusteringView.cluster_color_index + + +**`ManualClusteringView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### ManualClusteringView.get_clusters_data @@ -8721,6 +8793,15 @@ Close the view. --- +#### ProbeView.cluster_color_index + + +**`ProbeView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### ProbeView.get_clusters_data @@ -8903,6 +8984,15 @@ Close the view. --- +#### RasterView.cluster_color_index + + +**`RasterView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### RasterView.decrease_marker_size @@ -9199,6 +9289,15 @@ Close the view. --- +#### ScatterView.cluster_color_index + + +**`ScatterView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### ScatterView.decrease_marker_size @@ -10200,6 +10299,15 @@ Selected clusters in the similarity view only. --- +#### Supervisor.selection_color_order + + +**`Supervisor.selection_color_order`** + +Cluster IDs in their stable selected-color slots. + +--- + #### Supervisor.shown_cluster_ids @@ -10275,6 +10383,15 @@ Close the view. --- +#### TemplateView.cluster_color_index + + +**`TemplateView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### TemplateView.decrease @@ -10594,6 +10711,15 @@ Close the view. --- +#### TraceImageView.cluster_color_index + + +**`TraceImageView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### TraceImageView.decrease @@ -11092,6 +11218,15 @@ Close the view. --- +#### TraceView.cluster_color_index + + +**`TraceView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### TraceView.decrease @@ -11614,6 +11749,15 @@ Close the view. --- +#### WaveformView.cluster_color_index + + +**`WaveformView.cluster_color_index(self, cluster_id, fallback)`** + +Return the stable selected-color slot for a cluster. + +--- + #### WaveformView.decrease From bd1af394590c3469abd862c6fd504d672275f96a Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:11:59 +0200 Subject: [PATCH 044/110] Keep drop preview from intercepting drags --- phy/gui/qt.py | 1 + phy/gui/tests/test_widgets.py | 29 ++++++++++++++++++++++++++--- phy/gui/widgets.py | 22 ++++++++++++++-------- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/phy/gui/qt.py b/phy/gui/qt.py index e102c1aa..cf94ef3f 100644 --- a/phy/gui/qt.py +++ b/phy/gui/qt.py @@ -56,6 +56,7 @@ QColor, QPalette, QMouseEvent, + QPainter, QGuiApplication, QFontDatabase, QDrag, diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index 3f045d0a..f66bcf77 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -106,7 +106,30 @@ def test_table_cluster_drag_drop_policy_and_payload(table, qtbot): def on_cluster_drop(sender, payload): drops.append(payload) - target.emit_cluster_drop(table, (1, 2), 1) + class DragEvent: + def __init__(self): + self.accepted = False + + def source(self): + return table.table_view + + def mimeData(self): + return mime + + def pos(self): + return target.table_view.visualRect(target._proxy_index_for_id(10)).center() + + def acceptProposedAction(self): + self.accepted = True + + def ignore(self): + self.accepted = False + + drag_event = DragEvent() + target.table_view.dragEnterEvent(drag_event) + target.table_view.dragMoveEvent(drag_event) + target.table_view.dropEvent(drag_event) + assert drag_event.accepted assert drops == [{'source': table, 'cluster_ids': (1, 2), 'insertion': 1}] unconnect(on_cluster_drop) @@ -126,7 +149,7 @@ def test_table_drag_preview_marks_an_insertion_boundary_without_reordering(table view._update_drop_preview(first.topLeft()) assert view._drop_insertion == 0 - assert view._drop_indicator.isVisible() + assert view._drop_indicator_y is not None assert table._visible_ids() == before view._update_drop_preview(second.center()) @@ -135,7 +158,7 @@ def test_table_drag_preview_marks_an_insertion_boundary_without_reordering(table view._clear_drop_preview() assert view._drop_insertion is None - assert not view._drop_indicator.isVisible() + assert view._drop_indicator_y is None def test_table_hover_is_row_wide_without_selecting(table): diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index 7c734269..9212cc94 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -44,6 +44,7 @@ QMimeData, QModelIndex, QObject, + QPainter, QPalette, QPlainTextEdit, QPoint, @@ -552,10 +553,15 @@ def __init__(self, owner): self._drag_scroll_timer = QTimer(self) self._drag_scroll_timer.setInterval(30) self._drag_scroll_timer.timeout.connect(self._auto_scroll_drag) - self._drop_indicator = QLabel(self.viewport()) - self._drop_indicator.setAttribute(Qt.WA_TransparentForMouseEvents) - self._drop_indicator.setStyleSheet('background-color: #5ca8ff;') - self._drop_indicator.hide() + self._drop_indicator_y = None + + def paintEvent(self, event): + super().paintEvent(event) + if self._drop_indicator_y is None: + return + painter = QPainter(self.viewport()) + painter.fillRect(0, self._drop_indicator_y, self.viewport().width(), 3, QColor('#5ca8ff')) + painter.end() def startDrag(self, supported_actions): index = self._drag_start_index if self._drag_start_index.isValid() else self.currentIndex() @@ -678,9 +684,8 @@ def _show_drop_indicator(self, insertion): else: index = self.model().index(insertion, 0) y = self.visualRect(index).top() - self._drop_indicator.setGeometry(0, max(0, y - 1), self.viewport().width(), 3) - self._drop_indicator.show() - self._drop_indicator.raise_() + self._drop_indicator_y = max(0, y - 1) + self.viewport().update() def _auto_scroll_drag(self): if self._drag_position is None or not self._drag_scroll_direction: @@ -697,7 +702,8 @@ def _clear_drop_preview(self): self._drag_position = None self._drag_scroll_direction = 0 self._drag_scroll_timer.stop() - self._drop_indicator.hide() + self._drop_indicator_y = None + self.viewport().update() class Table(QWidget): From 0af27d518df432fe2fde5573ed2e27a4b9789859 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:19:23 +0200 Subject: [PATCH 045/110] Show table sort direction in headers --- docs/changelog.md | 2 ++ phy/gui/tests/test_widgets.py | 7 +++++++ phy/gui/widgets.py | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 3d6d1c71..9546ca24 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -44,6 +44,8 @@ behavior they verify rather than listed separately. ### Fixed +- Show the active sort column and direction in Cluster and Similarity View + headers. - Dragging Merge View rows now shows the cluster ID preview, insertion boundary, edge autoscroll, and a row-wide hover cue without changing the order until the drop completes. diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index f66bcf77..67249484 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -601,6 +601,9 @@ def on_some_event(sender, items, **kwargs): def test_table_sort(qtbot, table): + header = table.table_view.horizontalHeader() + assert header.isSortIndicatorShown() + table.select([1]) table.next() table.next() @@ -617,6 +620,8 @@ def on_table_sort(sender, row_ids): table.sort_by('count', 'asc') _assert(table.get_current_sort, ['count', 'asc']) + assert header.sortIndicatorSection() == table.columns.index('count') + assert header.sortIndicatorOrder() == Qt.AscendingOrder _assert(table.get_selected, [6]) _assert(table.get_ids, list(range(9, -1, -1))) @@ -624,6 +629,8 @@ def on_table_sort(sender, row_ids): _assert(table.get_selected, [4]) table.sort_by('count', 'desc') + assert header.sortIndicatorSection() == table.columns.index('count') + assert header.sortIndicatorOrder() == Qt.DescendingOrder _assert(table.get_ids, list(range(10))) assert _l == [list(range(9, -1, -1)), list(range(10))] diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index 9212cc94..4f63f861 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -781,6 +781,10 @@ def __init__( self.table_view.setWordWrap(False) self.table_view.horizontalHeader().setStretchLastSection(False) self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) + # Sorting is managed explicitly below rather than through QTableView's + # automatic sorting. Keep the native header indicator in sync so the + # active column and direction remain visible in every table view. + self.table_view.horizontalHeader().setSortIndicatorShown(True) layout = QVBoxLayout(self) layout.setContentsMargins(2, 2, 2, 2) @@ -1332,6 +1336,7 @@ def sort_by(self, name, sort_dir='asc'): column = self.columns.index(name) order = Qt.AscendingOrder if sort_dir == 'asc' else Qt.DescendingOrder self._current_sort = (name, sort_dir) + self.table_view.horizontalHeader().setSortIndicator(column, order) self._proxy.sort(column, order) self._refresh_selection() self._request_fit_columns() From fe16fa5f13c7113707dcf3f3d3033fbd46fc4c75 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:20:21 +0200 Subject: [PATCH 046/110] Document selection ordering and color refactor --- design/selection-order-color-refactor.md | 272 +++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 design/selection-order-color-refactor.md diff --git a/design/selection-order-color-refactor.md b/design/selection-order-color-refactor.md new file mode 100644 index 00000000..867d2569 --- /dev/null +++ b/design/selection-order-color-refactor.md @@ -0,0 +1,272 @@ +# Selection ordering and color-state refactor + +Status: implementation specification + +## 1. Motivation + +Cluster selection currently has three partially independent representations: + +- role membership and `presentation_order` in `CurationSelectionState`; +- a mutable `_selection_color_order` in `Supervisor`; and +- selected rows and color-index mappings projected into tables and scientific + views. + +This split causes subtle inconsistencies. In Normal mode, deselecting one +Similarity row compacts the mutable color order and recolors later clusters. +Color order is also absent from cancellation and history snapshots, so restoring +a Merge workspace can restore the correct cluster IDs without necessarily +restoring their original colors. + +The implementation should correct the state model rather than add more +mode-specific conditions to `Supervisor._update_selection_colors()`. + +## 2. Scope + +This refactor covers: + +- Normal- and Merge-mode role membership; +- the explicit Similarity reference; +- scientific-view presentation order; +- stable selected-cluster color slots; +- table sorting and filtering; +- Merge entry, transfer, reorder, cancellation, commit, undo, and redo; and +- projection into workflow tables and built-in scientific views. + +It does not introduce a global reactive store, change spike-level clustering +algorithms, or require a new public selection-event payload. + +## 3. Authoritative state + +`CurationSelectionState` is the sole authoritative selection and rendering +state. It owns: + +```python +mode +cluster_ids +similar_ids +reference_id +presentation_order +color_order +merge +``` + +`presentation_order` contains the active cluster IDs in the exact order sent to +scientific views. `color_order` is a reference-scoped registry: its tuple +position is the selected-cluster palette slot. It may retain inactive cluster +IDs so deselection and reselection do not change colors. + +### 3.1 State invariants + +1. All role, presentation, and color sequences contain unique cluster IDs. +2. `set(presentation_order) == set(effective_ids)`. +3. `set(effective_ids) <= set(color_order)`. +4. If a reference exists, it belongs to the active primary role and occupies + index zero in both `presentation_order` and `color_order`. +5. Normal mode has no Merge session and its reference belongs to `cluster_ids`. +6. Merge mode has no Cluster role selection, and the reference is the first + Merge member. +7. In Merge mode, `presentation_order` begins with the exact Merge View order; + its tail contains exactly the selected Similarity IDs. +8. A non-empty Similarity selection requires a reference. +9. Selection transitions perform work proportional to cluster IDs, never to + spike count. + +## 4. Color lifecycle + +Color slots are stable for the lifetime of one reference: + +- Selecting a previously unseen cluster appends it to `color_order`. +- Deselecting a cluster removes it from active presentation but retains its + color slot. +- Reselecting a cluster reuses its existing slot. +- Sorting, filtering, Merge transfers, and Merge reordering never change color + slots. +- Editing the Cluster selection while retaining the same reference preserves + existing slots and appends new clusters. +- Changing the reference starts a new color session. The new reference becomes + slot zero, and active clusters receive fresh slots in presentation order. +- Entering Merge mode preserves the Normal color registry. +- Cancelling Merge restores the complete entry registry. +- A committed merge selects a new reference and therefore starts a new registry. +- Undo and redo restore the exact registry stored in their selection snapshots. + +Color slots are not reused before the reference changes. The registry is bounded +by the number of clusters encountered for one reference and remains independent +of the number of spikes. + +## 5. Presentation lifecycle + +Presentation and color order are independent. + +- Normal presentation is the explicit reference first, followed by selected + Cluster rows and selected Similarity rows in their table order, without + duplicates. +- Merge presentation is Merge View order followed by selected Similarity rows + in Similarity View table order. +- A filter does not deselect hidden rows. Visible selected rows come first in + visible order; filtered-out selections remain at the tail in their previous + relative order. +- Sorting and filtering may change presentation and redraw order-dependent + scientific views, but never recolor clusters. + +Because table order is external UI state, `presentation_order` remains stored in +the immutable state and is restored by cancellation and history. + +## 6. Controller transitions + +The selection controller should expose explicit transitions for distinct user +intents: + +```python +set_normal_selection(...) +set_similarity_selection(...) +set_presentation_order(...) +enter_merge_mode(...) +cancel_merge_mode() +add_to_merge(...) +remove_from_merge(...) +reorder_merge(...) +restore(...) +``` + +`set_presentation_order()` validates ordering without changing membership or +colors. Supervisor code must not normalize presentation by invoking a second +membership transition. + +`SelectionChange` should distinguish: + +```python +roles_changed +presentation_changed +colors_changed +reference_changed +mode_changed +render_changed # presentation_changed or colors_changed +``` + +### 6.1 Snapshot simplification + +`NormalWorkflowSnapshot` should store the complete immutable Normal selection +state plus opaque table workflow context, rather than duplicate individual +selection fields. If changing the dataclass layout in the first implementation +step would make the migration unnecessarily risky, adding `color_order` to the +existing snapshot is an acceptable intermediate commit; the duplicated fields +must still be removed before completing the refactor. + +## 7. Supervisor responsibilities + +The Supervisor translates table intents, invokes one controller transition, and +projects the resulting state. It does not independently own color state. + +Required changes: + +1. Remove `Supervisor._selection_color_order`. +2. Make `Supervisor.selection_color_order` delegate to + `selection.state.color_order`. +3. Remove the `reset` policy from `_update_selection_colors()`; projection uses + the state's color order verbatim. +4. Replace `_normalize_presentation_order()` with a pure table-order + calculation followed by `set_presentation_order()`. +5. Canonicalize selection intent before projection so one user operation + produces one authoritative transition. +6. Handle both `table_sort` and `table_filter` through the same presentation + reorder path. +7. Keep one `_apply_selection_change()` path responsible for selected-row + projection, Similarity refresh when required, table colors, Merge View, + task logging, and scientific-view publication. +8. Publish when `render_changed` is true, while retaining the existing public + `emit('select', supervisor, cluster_ids)` positional payload. + +Programmatic table projection must remain non-emitting. Revision checks continue +to reject delayed events from an obsolete table state or workflow mode. + +## 8. View projection + +Workflow tables receive `state.color_order` through +`set_selected_index_order()`. Built-in scientific views obtain an immutable copy +of the same mapping for each selection render and use it only for palette lookup; +layout continues to follow `presentation_order`. + +Standalone views without a Supervisor may fall back to positional colors. +Attached built-in views must not silently fall back when an authoritative color +mapping exists but omits an active cluster; that condition indicates a violated +state invariant and should be covered by tests. + +## 9. Required regression coverage + +### 9.1 Normal mode + +- Select A, then C, then B: presentation follows A/B/C table order while C + retains its color. +- Select a Ctrl+Space batch, deselect a middle row, and verify all remaining + colors are unchanged. +- Reselect the removed row and verify its original color returns. +- Select a new row after a deselection and verify it receives a new slot. +- Sort and filter both role tables without recoloring. +- Modify Cluster selection without changing the reference and retain colors. +- Change the reference and verify the new reference is blue and slots reset. + +### 9.2 Merge mode + +- Enter Merge with the complete Normal presentation and color registry. +- Select and deselect Similarity candidates without recoloring. +- Transfer candidates in both directions without recoloring. +- Reorder Merge rows without recoloring. +- Sort and filter Similarity without recoloring. +- Cancel by shortcut, button, and view close and restore exact entry state. + +### 9.3 History and actions + +- Commit a Merge, undo it, and restore the exact pre-commit roles, + presentation, colors, and blue reference. +- Redo and restore exact post-commit state. +- Preserve selection-only exploration, including its color registry, across + undo/redo. +- Restore exact color state around ordinary merge, split, and metadata actions. + +### 9.4 Cross-view consistency + +- Workflow-table color indices match authoritative color slots. +- Representative per-cluster views such as Waveform and Correlogram match. +- Representative global/vectorized views such as Template and Raster match. +- Trace and scatter views match. +- Each intent emits at most one public selection update. + +## 10. Implementation sequence + +Each commit must leave focused tests passing. + +1. `test: characterize reference-scoped selection colors` + - Add failing Normal deselection/reselection and Merge undo color tests. +2. `refactor: store color order in selection state` + - Add state invariants, transition logic, snapshot/history restoration, and + remove Supervisor-owned color policy. +3. `refactor: make presentation reordering explicit` + - Add `set_presentation_order()`, remove membership-changing normalization, + and cover sorting/filtering. +4. `refactor: centralize selection projection` + - Consolidate Supervisor intent handling and projection/publication. +5. `refactor: simplify selection snapshots and color projection` + - Remove duplicated snapshot fields and tighten built-in view mapping. +6. `test: cover ordering and colors across lifecycle` + - Complete the regression matrix and event-count assertions. +7. `docs: document reference-scoped color behavior` + - Update changelog, clustering documentation, API output, and architecture. + +## 11. Validation + +During implementation, run the narrowest relevant tests. Before handoff run: + +```bash +uv run pytest phy/cluster/tests/test_selection.py +uv run pytest phy/cluster/tests/test_supervisor.py +uv run pytest phy/cluster/views/tests +uv run pytest phy/apps/tests/test_base.py +make lint +make format-check +make doc-check +make test-full +``` + +The working tree may contain concurrent changes. Every commit must inspect the +index explicitly and include only files belonging to its implementation phase. From d0ec58b17ac483bcbc56e8d8fb968d78117adbb9 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:20:52 +0200 Subject: [PATCH 047/110] Use conventional table sort arrows --- phy/gui/tests/test_widgets.py | 4 ++-- phy/gui/widgets.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index 67249484..e2900bb7 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -621,7 +621,7 @@ def on_table_sort(sender, row_ids): _assert(table.get_current_sort, ['count', 'asc']) assert header.sortIndicatorSection() == table.columns.index('count') - assert header.sortIndicatorOrder() == Qt.AscendingOrder + assert header.sortIndicatorOrder() == Qt.DescendingOrder _assert(table.get_selected, [6]) _assert(table.get_ids, list(range(9, -1, -1))) @@ -630,7 +630,7 @@ def on_table_sort(sender, row_ids): table.sort_by('count', 'desc') assert header.sortIndicatorSection() == table.columns.index('count') - assert header.sortIndicatorOrder() == Qt.DescendingOrder + assert header.sortIndicatorOrder() == Qt.AscendingOrder _assert(table.get_ids, list(range(10))) assert _l == [list(range(9, -1, -1)), list(range(10))] diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index 4f63f861..a3b24e6b 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -1336,7 +1336,11 @@ def sort_by(self, name, sort_dir='asc'): column = self.columns.index(name) order = Qt.AscendingOrder if sort_dir == 'asc' else Qt.DescendingOrder self._current_sort = (name, sort_dir) - self.table_view.horizontalHeader().setSortIndicator(column, order) + # QHeaderView's stock arrow describes the next sort direction in the + # active Qt style. Invert it so the displayed arrow describes the + # current data order: up for ascending and down for descending. + indicator_order = Qt.DescendingOrder if sort_dir == 'asc' else Qt.AscendingOrder + self.table_view.horizontalHeader().setSortIndicator(column, indicator_order) self._proxy.sort(column, order) self._refresh_selection() self._request_fit_columns() From becb91afcef817a449466a7090ed6ed01c3d369b Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:23:04 +0200 Subject: [PATCH 048/110] Document installing previous releases --- docs/changelog.md | 5 +++++ docs/installation.md | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 9546ca24..65d70654 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -11,6 +11,11 @@ been included in a stable release. The current entries cover all user-visible changes committed since 23 July 2026; test-only commits are represented by the behavior they verify rather than listed separately. +### Documentation + +- Documented uv-first installation of exact previous phy releases in separate + environments, including how to intentionally replace a tool installation. + ### Added - Stage and order manual merge candidates in the new **Merge View**. Press `V` diff --git a/docs/installation.md b/docs/installation.md index fb5011c9..98cff42b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -25,6 +25,44 @@ To upgrade the stable installation later: uv tool upgrade phy ``` +## Install a previous release + +Prefer an exact release number when reproducing an older workflow, a plugin +setup, or an issue. Choose a release from [PyPI](https://pypi.org/project/phy/#history) +or the [GitHub releases](https://github.com/cortex-lab/phy/releases), and check +that its Python requirement is compatible with the interpreter you select. + +For a persistent historical installation that does not replace your usual +`phy` command, create a dedicated environment. For example, to install 2.1.0: + +```bash +uv venv --python 3.12 phy-2.1.0-env +uv pip install --python phy-2.1.0-env "phy==2.1.0" +``` + +Activate the environment with the command printed by `uv venv`, then verify it: + +```bash +phy --version +``` + +On Linux and macOS this is normally `source phy-2.1.0-env/bin/activate`; on +Windows PowerShell it is normally `.\phy-2.1.0-env\Scripts\Activate.ps1`. +Use a distinct environment for each version you need to retain. This keeps old +dependencies separate from the current release and makes a result easier to +reproduce. + +If you deliberately want the historical release to replace an existing +`uv tool` installation, use an exact pin and `--force` instead: + +```bash +uv tool install --force --python 3.12 "phy==2.1.0" +``` + +Run `uv tool install --force --python 3.12 phy` to restore the latest stable +release later. The tool installation has one `phy` command, so this option does +not keep both versions available at once. + ### Alternative: venv and pip If you prefer Python's standard environment tools, create and activate a virtual From 29ce2800452a1929a08ea9bba284691d1c98692f Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:26:25 +0200 Subject: [PATCH 049/110] refactor: store selection color order in state --- phy/cluster/_selection.py | 61 +++++++++++++++++++++++++++- phy/cluster/supervisor.py | 50 +++++++++++++---------- phy/cluster/tests/test_selection.py | 24 ++++++++++- phy/cluster/tests/test_supervisor.py | 40 ++++++++++++++++++ 4 files changed, 150 insertions(+), 25 deletions(-) diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index e619c2a6..22783db5 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -39,6 +39,7 @@ class NormalWorkflowSnapshot: similar_ids: tuple[int, ...] reference_id: int | None presentation_order: tuple[int, ...] + color_order: tuple[int, ...] | None = None workflow_context: object = None def __post_init__(self): @@ -47,11 +48,13 @@ def __post_init__(self): similar_ids=self.similar_ids, reference_id=self.reference_id, presentation_order=self.presentation_order, + color_order=self.color_order, ) object.__setattr__(self, 'cluster_ids', state.cluster_ids) object.__setattr__(self, 'similar_ids', state.similar_ids) object.__setattr__(self, 'reference_id', state.reference_id) object.__setattr__(self, 'presentation_order', state.presentation_order) + object.__setattr__(self, 'color_order', state.color_order) @property def selection(self): @@ -61,6 +64,7 @@ def selection(self): similar_ids=self.similar_ids, reference_id=self.reference_id, presentation_order=self.presentation_order, + color_order=self.color_order, ) @@ -94,6 +98,7 @@ class CurationSelectionState: similar_ids: tuple[int, ...] = () reference_id: int | None = None presentation_order: tuple[int, ...] | None = None + color_order: tuple[int, ...] | None = None merge: MergeSession | None = None def __post_init__(self): @@ -134,17 +139,27 @@ def __post_init__(self): if set(presentation_order) != set(effective_ids): raise ValueError('Presentation order must contain exactly the effective IDs.') + if similar_ids and reference_id is None: + raise ValueError('Similarity selection requires a reference ID.') if ( presentation_order and reference_id is not None and presentation_order[0] != reference_id ): raise ValueError('The reference ID must occupy the first presentation slot.') + color_order = ( + presentation_order if self.color_order is None else _as_unique_ids(self.color_order) + ) + if not set(effective_ids) <= set(color_order): + raise ValueError('Color order must contain every effective ID.') + if color_order and reference_id is not None and color_order[0] != reference_id: + raise ValueError('The reference ID must occupy the first color slot.') object.__setattr__(self, 'cluster_ids', cluster_ids) object.__setattr__(self, 'similar_ids', similar_ids) object.__setattr__(self, 'reference_id', reference_id) object.__setattr__(self, 'presentation_order', presentation_order) + object.__setattr__(self, 'color_order', color_order) @property def effective_ids(self): @@ -174,6 +189,7 @@ class SelectionChange: after: CurationSelectionState roles_changed: bool presentation_changed: bool + colors_changed: bool reference_changed: bool mode_changed: bool @@ -189,6 +205,7 @@ def create(cls, before, after): or before.merge_ids != after.merge_ids ), presentation_changed=before.presentation_order != after.presentation_order, + colors_changed=before.color_order != after.color_order, reference_changed=before.reference_id != after.reference_id, mode_changed=before.mode is not after.mode, ) @@ -198,6 +215,11 @@ def changed(self): """Whether this transition changes any modeled state.""" return self.before != self.after + @property + def render_changed(self): + """Whether scientific views need an updated selection render.""" + return self.presentation_changed or self.colors_changed + class CurationSelectionController: """Apply validated, atomic curation selection transitions.""" @@ -226,10 +248,17 @@ def set_cluster_selection(self, cluster_ids, reference_id=None): cluster_ids = _as_unique_ids(cluster_ids) if reference_id is None: reference_id = cluster_ids[0] if cluster_ids else None + presentation_order = _ordered_union( + (reference_id,) if reference_id is not None else (), + cluster_ids, + self._state.similar_ids, + ) after = CurationSelectionState( cluster_ids=cluster_ids, similar_ids=self._state.similar_ids, reference_id=reference_id, + presentation_order=presentation_order, + color_order=self._next_color_order(reference_id, presentation_order), ) return self._apply(after) @@ -241,11 +270,20 @@ def set_normal_selection( presentation_order=None, ): """Atomically replace all Normal-mode selection roles and presentation state.""" + cluster_ids = _as_unique_ids(cluster_ids) + similar_ids = _as_unique_ids(similar_ids) + if reference_id is None: + reference_id = cluster_ids[0] if cluster_ids else None + if presentation_order is None: + presentation_order = _ordered_union( + (reference_id,) if reference_id is not None else (), cluster_ids, similar_ids + ) after = CurationSelectionState( - cluster_ids=_as_unique_ids(cluster_ids), - similar_ids=_as_unique_ids(similar_ids), + cluster_ids=cluster_ids, + similar_ids=similar_ids, reference_id=reference_id, presentation_order=presentation_order, + color_order=self._next_color_order(reference_id, presentation_order), ) return self._apply(after) @@ -268,6 +306,7 @@ def set_similarity_selection(self, similar_ids): similar_ids=similar_ids, reference_id=current.reference_id, presentation_order=presentation_order, + color_order=self._next_color_order(current.reference_id, presentation_order), merge=current.merge, ) return self._apply(after) @@ -287,6 +326,7 @@ def enter_merge_mode(self, workflow_context=None): similar_ids=current.similar_ids, reference_id=current.reference_id, presentation_order=current.presentation_order, + color_order=current.color_order, workflow_context=workflow_context, ) ordered_ids = current.presentation_order @@ -295,6 +335,7 @@ def enter_merge_mode(self, workflow_context=None): mode=WorkflowMode.MERGE, reference_id=current.reference_id, presentation_order=current.presentation_order, + color_order=self._next_color_order(current.reference_id, current.presentation_order), merge=merge, ) return self._apply(after) @@ -333,6 +374,9 @@ def add_to_merge(self, cluster_ids, insertion=None): similar_ids=similar_ids, reference_id=current.reference_id, merge=merge, + color_order=self._next_color_order( + current.reference_id, _ordered_union(merge.ordered_ids, similar_ids) + ), ) return self._apply(after) @@ -354,6 +398,9 @@ def remove_from_merge(self, cluster_ids): similar_ids=_ordered_union(current.similar_ids, cluster_ids), reference_id=current.reference_id, merge=merge, + color_order=self._next_color_order( + current.reference_id, _ordered_union(merge.ordered_ids, current.similar_ids) + ), ) return self._apply(after) @@ -378,6 +425,9 @@ def reorder_merge(self, cluster_ids, insertion): similar_ids=current.similar_ids, reference_id=current.reference_id, merge=merge, + color_order=self._next_color_order( + current.reference_id, _ordered_union(merge.ordered_ids, current.similar_ids) + ), ) return self._apply(after) @@ -389,6 +439,13 @@ def _require_merge_mode(self): if not self._state.is_merge_mode: raise RuntimeError('This operation requires Merge mode.') + def _next_color_order(self, reference_id, presentation_order): + """Return the reference-scoped registry for the next selection state.""" + current = self._state + if reference_id != current.reference_id: + return tuple(presentation_order) + return _ordered_union(current.color_order, presentation_order) + def _apply(self, after): before = self._state self._state = after diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index be9881e3..01a33071 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -762,7 +762,6 @@ def __init__( self._merge_close_callback = None self._merge_dock_state = None self._suspend_presentation_order_sync = False - self._selection_color_order = () self._is_dirty = None self._sort = sort # Initial sort requested in the constructor # This is populated alongside the existing TaskLogger-derived selection during the @@ -1126,7 +1125,7 @@ def _clusters_selected(self, sender, obj, **kwargs): self.similarity_view.reset(cluster_ids, reference_id=change.after.reference_id) self.similarity_view.set_selected_ids(()) change = self._normalize_presentation_order(change) - self._update_selection_colors(reset=True) + self._update_selection_colors() # Emit supervisor.select event unless update_views is False. This happens after # a merge event, where the views should not be updated after the first cluster_view.select # event, but instead after the second similarity_view.select event. @@ -1148,8 +1147,27 @@ def _similar_selected(self, sender, obj): next_similar = obj['next'] kwargs = obj.get('kwargs', {}) logger.debug('Similar clusters selected: %s (%s)', similar, next_similar) - change = self.selection.set_similarity_selection(similar) - change = self._normalize_presentation_order(change) + if self.selection.state.is_merge_mode: + change = self.selection.set_similarity_selection(similar) + change = self._normalize_presentation_order(change) + else: + state = self.selection.state + similar_in_table_order = self._ids_in_table_order(self.similarity_view, similar) + presentation_order = tuple( + dict.fromkeys( + ( + *((state.reference_id,) if state.reference_id is not None else ()), + *self._ids_in_table_order(self.cluster_view, state.cluster_ids), + *similar_in_table_order, + ) + ) + ) + change = self.selection.set_normal_selection( + state.cluster_ids, + similar, + reference_id=state.reference_id, + presentation_order=presentation_order, + ) self._update_selection_colors() self._project_merge_view() self.task_logger.log(self.similarity_view, 'select', similar, output=obj) @@ -1209,20 +1227,10 @@ def _table_order_changed(self, sender, row_ids): self._project_merge_view() emit('select', self, list(change.after.presentation_order)) - def _update_selection_colors(self, reset=False): + def _update_selection_colors(self): """Project stable selection-color positions into all workflow tables.""" state = self.selection.state - if reset: - order = tuple(state.presentation_order) - else: - active_ids = set(state.effective_ids) - order = tuple( - cluster_id - for cluster_id in self._selection_color_order - if state.is_merge_mode or cluster_id in active_ids - ) - order = tuple(dict.fromkeys((*order, *state.presentation_order))) - self._selection_color_order = order + order = state.color_order self.cluster_view.set_selected_index_order(order) self.similarity_view.set_selected_index_order(order) if self.merge_view is not None: @@ -1233,7 +1241,7 @@ def _project_merge_view(self): if self.merge_view is None or not state.is_merge_mode: return data = [self.get_cluster_info(cluster_id) for cluster_id in state.merge_ids] - self.merge_view.set_merge_ids(state.merge_ids, data, self._selection_color_order) + self.merge_view.set_merge_ids(state.merge_ids, data, state.color_order) self.merge_view.dock.set_status(self._merge_status_text()) def _merge_status_text(self): @@ -1252,9 +1260,7 @@ def _apply_selection_change(self, change, callback=None, normalize_order=True): if normalize_order: change = self._normalize_presentation_order(change) state = change.after - self._update_selection_colors( - reset=not state.is_merge_mode and change.reference_changed, - ) + self._update_selection_colors() self._project_merge_view() self.task_logger.log( self.cluster_view, @@ -1268,7 +1274,7 @@ def _apply_selection_change(self, change, callback=None, normalize_order=True): list(state.similar_ids), output=similar_payload, ) - if change.presentation_changed: + if change.render_changed: emit('select', self, list(state.presentation_order)) if callback: self.cluster_view._schedule_callback(callback, state) @@ -1735,7 +1741,7 @@ def selected(self): @property def selection_color_order(self): """Cluster IDs in their stable selected-color slots.""" - return self._selection_color_order + return self.selection.state.color_order def n_spikes(self, cluster_id): """Number of spikes in a given cluster.""" diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index c10cc7cb..bbf70ca8 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -35,6 +35,12 @@ def test_state_rejects_invalid_ids_reference_and_presentation(): ) with raises(ValueError, match='requires a merge session'): CurationSelectionState(mode=WorkflowMode.MERGE) + with raises(ValueError, match='Color order'): + CurationSelectionState(cluster_ids=(1, 2), color_order=(1,)) + with raises(ValueError, match='first color'): + CurationSelectionState(cluster_ids=(1, 2), color_order=(2, 1)) + with raises(ValueError, match='Similarity selection'): + CurationSelectionState(similar_ids=(2,)) def test_state_is_immutable(): @@ -78,6 +84,22 @@ def test_set_similarity_and_clear_similarity_selection(): assert change.after.presentation_order == (1,) +def test_similarity_deselection_and_reselection_preserve_color_slots(): + controller = CurationSelectionController( + CurationSelectionState(cluster_ids=(1,), reference_id=1) + ) + + controller.set_similarity_selection((2, 3, 4)) + color_order = controller.state.color_order + change = controller.set_similarity_selection((2, 4)) + + assert change.after.color_order == color_order + assert not change.colors_changed + change = controller.set_similarity_selection((2, 3, 4)) + assert change.after.color_order == color_order + assert not change.colors_changed + + def test_set_normal_selection_replaces_all_roles_atomically(): controller = CurationSelectionController() @@ -171,7 +193,7 @@ def test_enter_merge_mode_stages_normal_presentation_order(): def test_enter_merge_mode_requires_cluster_selection(): - controller = CurationSelectionController(CurationSelectionState(similar_ids=(2,))) + controller = CurationSelectionController() with raises(ValueError, match='Cluster View selection'): controller.enter_merge_mode() diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index c59bbb68..67517779 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -620,6 +620,14 @@ def test_merge_mode_merge_undo_redo_restores_workspace(supervisor): supervisor.similarity_view.select([candidate]) supervisor.block() merge_before = supervisor.selection.snapshot() + colors_before = { + cluster_id: ( + supervisor.merge_view._selected_color_index(cluster_id) + if cluster_id in supervisor.selected_merge + else supervisor.similarity_view._selected_color_index(cluster_id) + ) + for cluster_id in supervisor.selected + } up = supervisor.merge() supervisor.block() @@ -646,6 +654,15 @@ def on_select(sender, cluster_ids): assert supervisor.merge_view is not None assert supervisor.actions.get('redo').isEnabled() assert events[-1] == list(merge_before.presentation_order) + assert supervisor.selection_color_order == merge_before.color_order + assert { + cluster_id: ( + supervisor.merge_view._selected_color_index(cluster_id) + if cluster_id in supervisor.selected_merge + else supervisor.similarity_view._selected_color_index(cluster_id) + ) + for cluster_id in supervisor.selected + } == colors_before supervisor.redo() supervisor.block() @@ -887,6 +904,29 @@ def test_normal_similarity_insertion_does_not_recolor_existing_rows(supervisor): assert similarity_view._selected_color_index(11) > color_before +def test_normal_similarity_deselection_and_reselection_preserve_color_slots(supervisor): + _select(supervisor, [30]) + similarity_view = supervisor.similarity_view + similarity_view.sort_by('id', 'asc') + similarity_view.select([1, 11, 20]) + supervisor.block() + colors_before = { + cluster_id: similarity_view._selected_color_index(cluster_id) for cluster_id in (1, 11, 20) + } + + similarity_view.select([1, 20]) + supervisor.block() + assert { + cluster_id: similarity_view._selected_color_index(cluster_id) for cluster_id in (1, 20) + } == {cluster_id: colors_before[cluster_id] for cluster_id in (1, 20)} + + similarity_view.select([1, 11, 20]) + supervisor.block() + assert { + cluster_id: similarity_view._selected_color_index(cluster_id) for cluster_id in (1, 11, 20) + } == colors_before + + def test_merge_presentation_keeps_merge_order_before_similarity_table_order(supervisor): _select(supervisor, [30]) supervisor.toggle_merge_mode() From 2e708add645359abeeeca45e110ab9aadbe5c8e1 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:29:06 +0200 Subject: [PATCH 050/110] Add configurable axis tick formatters --- phy/plot/axes.py | 48 ++++++++++++++++++++++++++++++++----- phy/plot/plot.py | 12 ++++++++-- phy/plot/tests/test_axes.py | 18 +++++++++++++- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/phy/plot/axes.py b/phy/plot/axes.py index e98ddcb1..f861d4a9 100644 --- a/phy/plot/axes.py +++ b/phy/plot/axes.py @@ -20,6 +20,25 @@ # ------------------------------------------------------------------------------ +def format_number(value): + """Format an axis value with grouping while retaining useful precision.""" + return f'{value:,.9g}' + + +def format_time_ticks(values, unit='s'): + """Format elapsed-time ticks expressed internally in seconds. + + The coordinates are deliberately left in seconds: only their displayed + representation changes. This keeps pan/zoom and data interaction + independent of the chosen display unit. + """ + factors = {'s': 1, 'min': 60, 'h': 3600} + if unit not in factors: + raise ValueError(f'Unknown time unit: {unit!r}') + factor = factors[unit] + return [f'{format_number(value / factor)} {unit}' for value in values] + + class AxisLocator: """Determine the location of ticks in a view. @@ -43,11 +62,13 @@ class AxisLocator: _bins_margin = 5 _default_steps = (1, 2, 2.5, 5, 10) - def __init__(self, nbinsx=None, nbinsy=None, data_bounds=None): + def __init__(self, nbinsx=None, nbinsy=None, data_bounds=None, format_x=None, format_y=None): """data_bounds is the initial bounds of the view in data coordinates.""" self.data_bounds = data_bounds self._tr = Range(from_bounds=NDC, to_bounds=self.data_bounds) self._tri = self._tr.inverse() + self.format_x = format_x or (lambda values: [format_number(value) for value in values]) + self.format_y = format_y or (lambda values: [format_number(value) for value in values]) self.set_nbins(nbinsx, nbinsy) def set_nbins(self, nbinsx=None, nbinsy=None): @@ -93,9 +114,8 @@ def set_view_bounds(self, view_bounds=None): self.xticks_view, self.yticks_view = self._transform_ticks(self.xticks, self.yticks) # Get the text in data coordinates. - fmt = '%.9g' - self.xtext = [fmt % v for v in self.xticks] - self.ytext = [fmt % v for v in self.yticks] + self.xtext = self.format_x(self.xticks) + self.ytext = self.format_y(self.yticks) # ------------------------------------------------------------------------------ @@ -143,9 +163,13 @@ class Axes: default_color = (1, 1, 1, 0.25) - def __init__(self, data_bounds=None, color=None, show_x=True, show_y=True): + def __init__( + self, data_bounds=None, color=None, show_x=True, show_y=True, format_x=None, format_y=None + ): self.show_x = show_x self.show_y = show_y + self.format_x = format_x + self.format_y = format_y self.reset_data_bounds(data_bounds, do_update=False) self._create_visuals() self.color = color or self.default_color @@ -157,13 +181,25 @@ def reset_data_bounds(self, data_bounds, do_update=True): Used when the view is recreated from scratch. """ - self.locator = AxisLocator(data_bounds=data_bounds) + self.locator = AxisLocator( + data_bounds=data_bounds, format_x=self.format_x, format_y=self.format_y + ) self.locator.set_view_bounds(NDC) if do_update: self.update_visuals() self._last_log_zoom = (1, 1) self._last_pan = (0, 0) + def set_x_formatter(self, formatter): + """Set the formatter for x-axis tick labels.""" + self.format_x = formatter + self.locator.format_x = formatter or ( + lambda values: [format_number(value) for value in values] + ) + self.locator.set_view_bounds(self._attached.panzoom.get_range() if self._attached else NDC) + if self._attached: + self.update_visuals() + def _create_visuals(self): """Create the line and text visuals on the x and/or y axes.""" if self.show_x: diff --git a/phy/plot/plot.py b/phy/plot/plot.py index 6d63a4f1..29401de8 100644 --- a/phy/plot/plot.py +++ b/phy/plot/plot.py @@ -219,9 +219,17 @@ def enable_lasso(self): self.lasso = Lasso() self.lasso.attach(self) - def enable_axes(self, data_bounds=None, show_x=True, show_y=True): + def enable_axes( + self, data_bounds=None, show_x=True, show_y=True, format_x=None, format_y=None + ): """Show axes in the canvas.""" - self.axes = Axes(data_bounds=data_bounds, show_x=show_x, show_y=show_y) + self.axes = Axes( + data_bounds=data_bounds, + show_x=show_x, + show_y=show_y, + format_x=format_x, + format_y=format_y, + ) self.axes.attach(self) diff --git a/phy/plot/tests/test_axes.py b/phy/plot/tests/test_axes.py index fb44e17a..864174fa 100644 --- a/phy/plot/tests/test_axes.py +++ b/phy/plot/tests/test_axes.py @@ -7,7 +7,7 @@ import os -from ..axes import Axes +from ..axes import Axes, format_time_ticks from . import show_and_wait # ------------------------------------------------------------------------------ @@ -35,3 +35,19 @@ def test_axes_1(qtbot, canvas_pz): if os.environ.get('PHY_TEST_STOP', None): # pragma: no cover qtbot.stop() c.close() + + +def test_time_tick_formatting(): + assert format_time_ticks([0, 1000, 10000]) == ['0 s', '1,000 s', '10,000 s'] + assert format_time_ticks([0, 3600], unit='h') == ['0 h', '1 h'] + + +def test_axes_x_formatter_survives_reset(qtbot, canvas_pz): + c = canvas_pz + axes = Axes( + data_bounds=(0, 0, 3600, 1), format_x=lambda values: format_time_ticks(values, 'h') + ) + axes.attach(c) + axes.reset_data_bounds((0, 0, 7200, 1)) + assert all(label.endswith(' h') for label in axes.locator.xtext) + c.close() From 28094b8f8b5dbbc7b4e3b26f44777688340cd708 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:33:03 +0200 Subject: [PATCH 051/110] fix: clear orphaned similarity selection --- phy/cluster/_selection.py | 7 +++++-- phy/cluster/supervisor.py | 5 ++++- phy/cluster/tests/test_selection.py | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index 22783db5..be073e92 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -150,6 +150,8 @@ def __post_init__(self): color_order = ( presentation_order if self.color_order is None else _as_unique_ids(self.color_order) ) + if reference_id is None and color_order: + raise ValueError('Color order requires a reference ID.') if not set(effective_ids) <= set(color_order): raise ValueError('Color order must contain every effective ID.') if color_order and reference_id is not None and color_order[0] != reference_id: @@ -248,14 +250,15 @@ def set_cluster_selection(self, cluster_ids, reference_id=None): cluster_ids = _as_unique_ids(cluster_ids) if reference_id is None: reference_id = cluster_ids[0] if cluster_ids else None + similar_ids = self._state.similar_ids if reference_id is not None else () presentation_order = _ordered_union( (reference_id,) if reference_id is not None else (), cluster_ids, - self._state.similar_ids, + similar_ids, ) after = CurationSelectionState( cluster_ids=cluster_ids, - similar_ids=self._state.similar_ids, + similar_ids=similar_ids, reference_id=reference_id, presentation_order=presentation_order, color_order=self._next_color_order(reference_id, presentation_order), diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 01a33071..ce447832 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1512,7 +1512,10 @@ def _after_action(self, sender, up): similar_ids = self.similarity_view.get_selected_ids() if tuple(cluster_ids) != self.selection.state.cluster_ids: self.selection.set_cluster_selection(cluster_ids) - if tuple(similar_ids) != self.selection.state.similar_ids: + if ( + self.selection.state.reference_id is not None + and tuple(similar_ids) != self.selection.state.similar_ids + ): self.selection.set_similarity_selection(similar_ids) # After the action has finished, we process the pending actions, # like selection of new clusters in the tables. diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index bbf70ca8..97abc931 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -41,6 +41,8 @@ def test_state_rejects_invalid_ids_reference_and_presentation(): CurationSelectionState(cluster_ids=(1, 2), color_order=(2, 1)) with raises(ValueError, match='Similarity selection'): CurationSelectionState(similar_ids=(2,)) + with raises(ValueError, match='Color order'): + CurationSelectionState(color_order=(2,)) def test_state_is_immutable(): @@ -67,6 +69,20 @@ def test_set_cluster_selection_uses_blue_first_id_or_explicit_reference(): assert change.after.presentation_order == (2, 1) +def test_empty_cluster_selection_clears_similarity_and_color_session(): + controller = CurationSelectionController( + CurationSelectionState(cluster_ids=(1,), similar_ids=(2,), reference_id=1) + ) + + change = controller.set_cluster_selection(()) + + assert change.after.cluster_ids == () + assert change.after.similar_ids == () + assert change.after.reference_id is None + assert change.after.presentation_order == () + assert change.after.color_order == () + + def test_set_similarity_and_clear_similarity_selection(): controller = CurationSelectionController( CurationSelectionState(cluster_ids=(1,), reference_id=1) From c5bd97e77b766b38ce90c04c5e234c6695b568d0 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:33:29 +0200 Subject: [PATCH 052/110] Add shared recording-time axis units --- docs/api.md | 11 +++++- docs/changelog.md | 3 ++ docs/visualization.md | 6 ++++ phy/apps/base.py | 44 +++++++++++++++++++++-- phy/apps/tests/test_base.py | 22 ++++++++++++ phy/cluster/views/amplitude.py | 4 +-- phy/cluster/views/base.py | 12 +++++++ phy/cluster/views/histogram.py | 4 +-- phy/cluster/views/tests/test_histogram.py | 17 +++++++++ 9 files changed, 116 insertions(+), 7 deletions(-) diff --git a/docs/api.md b/docs/api.md index dbd193fe..95990f00 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2200,6 +2200,15 @@ Used when the view is recreated from scratch. --- +#### Axes.set_x_formatter + + +**`Axes.set_x_formatter(self, formatter)`** + +Set the formatter for x-axis tick labels. + +--- + #### Axes.update_visuals @@ -4228,7 +4237,7 @@ Raise an internal event and call `on_xxx()` on attached objects. #### PlotCanvas.enable_axes -**`PlotCanvas.enable_axes(self, data_bounds=None, show_x=True, show_y=True)`** +**`PlotCanvas.enable_axes(self, data_bounds=None, show_x=True, show_y=True, format_x=None, format_y=None)`** Show axes in the canvas. diff --git a/docs/changelog.md b/docs/changelog.md index 65d70654..5cdcc0ca 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -18,6 +18,9 @@ behavior they verify rather than listed separately. ### Added +- Display elapsed recording time in seconds, minutes, or hours in Amplitude + and Firing Rate views. **View > Set recording time unit** changes the shared + preference, and axis labels now use thousands separators. - Stage and order manual merge candidates in the new **Merge View**. Press `V` to enter or cancel Merge mode, transfer candidates with `Control`-right-click or drag-and-drop, and press `G` to merge every staged diff --git a/docs/visualization.md b/docs/visualization.md index a03a1633..5c28dbd4 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -511,6 +511,12 @@ Choose **View settings** in the Firing Rate or ISI view menu to edit bin size and the displayed range together. These parameters are dataset-local, so values saved for one recording do not clip or coarsen a fresh dataset. +Amplitude and Firing Rate views display elapsed recording time on their x +axes. Choose **View > Set recording time unit** to show that time in seconds, +minutes, or hours; the setting is shared across compatible views and remembered +between sessions. This only changes tick labels: navigation, selection, ranges, +and firing-rate bins remain in seconds. + ![image](https://user-images.githubusercontent.com/1942359/58951704-193e5080-8792-11e9-873f-91a9115a9e7c.png) #### Keyboard shortcuts diff --git a/phy/apps/base.py b/phy/apps/base.py index 657da82e..b6587e91 100644 --- a/phy/apps/base.py +++ b/phy/apps/base.py @@ -38,7 +38,7 @@ WaveformView, select_traces, ) -from phy.cluster.views.base import BaseColorView, ManualClusteringView +from phy.cluster.views.base import BaseColorView, ManualClusteringView, RecordingTimeAxisMixin from phy.cluster.views.trace import _iter_spike_waveforms from phy.gui import GUI from phy.gui.gui import _prompt_save @@ -1036,6 +1036,9 @@ class BaseController: # when using compressed dataset, as random access triggers expensive decompression). n_chunks_kept = 20 + # Unit used to display elapsed recording time in compatible views. + recording_time_unit = 's' + # Controller attributes to load/save in the GUI state. _state_params = ( 'n_spikes_amplitudes', @@ -1044,6 +1047,7 @@ class BaseController: 'n_spikes_correlograms', 'n_spikes_correlograms_total', 'raw_data_filter_name', + 'recording_time_unit', ) # Methods that are cached in memory (and on disk) for performance. @@ -2134,6 +2138,17 @@ def on_gui_ready(sender, gui): gui.create_and_add_view(view_name) def create_misc_actions(self, gui): + @gui.view_actions.add( + name='Set recording time unit', + alias='timeunit', + prompt=True, + prompt_default=lambda: self.recording_time_unit, + show_shortcut=False, + ) + def set_recording_time_unit(unit): + """Set recording-time labels to s, min, or h.""" + self._set_recording_time_unit(unit, gui) + # Toggle spike reorder. @gui.view_actions.add( shortcut=self.default_shortcuts['toggle_spike_reorder'], @@ -2165,6 +2180,28 @@ def switch_raw_data_filter(): gui.view_actions.separator() + def _set_recording_time_unit(self, unit, gui): + """Set the elapsed recording-time display unit in compatible open views.""" + aliases = { + 's': 's', + 'second': 's', + 'seconds': 's', + 'min': 'min', + 'minute': 'min', + 'minutes': 'min', + 'h': 'h', + 'hour': 'h', + 'hours': 'h', + } + try: + unit = aliases[unit.strip().lower()] + except (AttributeError, KeyError) as e: + raise ValueError("Recording time unit must be 's', 'min', or 'h'.") from e + self.recording_time_unit = unit + for view in gui.list_views(): + if isinstance(view, RecordingTimeAxisMixin): + view._set_recording_time_unit(unit) + def _add_default_color_schemes(self, view): """Add the default color schemes to every view.""" group_colors = { @@ -2245,6 +2282,9 @@ def on_view_attached(view, gui_): self._add_default_color_schemes(view) if isinstance(view, ManualClusteringView): + if isinstance(view, RecordingTimeAxisMixin): + view._set_recording_time_unit(self.recording_time_unit) + # Add auto update button. view.dock.add_button( name='auto_update', @@ -2310,7 +2350,7 @@ def on_close(sender): # noqa gui.state.add_local_keys(local_keys) # Update the controller params in the GUI state. - for param in self._state_params: + for param in state_params: gui.state[param] = getattr(self, param, None) # Save the memcache. diff --git a/phy/apps/tests/test_base.py b/phy/apps/tests/test_base.py index 1384b46e..1d516c2a 100644 --- a/phy/apps/tests/test_base.py +++ b/phy/apps/tests/test_base.py @@ -31,6 +31,7 @@ AmplitudeView, CorrelogramView, FeatureView, + FiringRateView, TemplateView, TraceView, WaveformView, @@ -529,6 +530,27 @@ def get_spike_times(self, cluster_id, n=None): assert bunch.x_max == 3.0 +def test_recording_time_unit_updates_compatible_views(qtbot): + controller = object.__new__(BaseController) + controller.recording_time_unit = 's' + amplitude = AmplitudeView(amplitudes=lambda cluster_ids, load_all=False: None) + firing_rate = FiringRateView(cluster_stat=lambda cluster_id: Bunch(data=np.array([0.0]))) + + class GUI: + def list_views(self): + return [amplitude, firing_rate] + + controller._set_recording_time_unit('hours', GUI()) + + assert controller.recording_time_unit == 'h' + assert amplitude.recording_time_unit == firing_rate.recording_time_unit == 'h' + assert all(label.endswith(' h') for label in amplitude.canvas.axes.locator.xtext) + assert all(label.endswith(' h') for label in firing_rate.canvas.axes.locator.xtext) + + amplitude.close() + firing_rate.close() + + def test_amplitude_view_excludes_unavailable_features(qtbot, tempdir): controller = _mock_controller(tempdir, MyControllerFull) controller.model.features = None diff --git a/phy/cluster/views/amplitude.py b/phy/cluster/views/amplitude.py index 6d3c615f..7e109223 100644 --- a/phy/cluster/views/amplitude.py +++ b/phy/cluster/views/amplitude.py @@ -16,7 +16,7 @@ from phy.plot.visuals import HistogramVisual, PatchVisual, ScatterVisual from phy.utils.color import add_alpha, selected_cluster_color -from .base import LassoMixin, ManualClusteringView, MarkerSizeMixin +from .base import LassoMixin, ManualClusteringView, MarkerSizeMixin, RecordingTimeAxisMixin from .histogram import _compute_histogram logger = logging.getLogger(__name__) @@ -27,7 +27,7 @@ # ----------------------------------------------------------------------------- -class AmplitudeView(MarkerSizeMixin, LassoMixin, ManualClusteringView): +class AmplitudeView(RecordingTimeAxisMixin, MarkerSizeMixin, LassoMixin, ManualClusteringView): """This view displays an amplitude plot for all selected clusters. Constructor diff --git a/phy/cluster/views/base.py b/phy/cluster/views/base.py index 5d9b9153..235a7c93 100644 --- a/phy/cluster/views/base.py +++ b/phy/cluster/views/base.py @@ -17,11 +17,23 @@ from phy.gui import Actions from phy.gui.qt import AsyncCaller, Worker, screenshot, screenshot_default_path, thread_pool from phy.plot import NDC, PlotCanvas, extend_bounds +from phy.plot.axes import format_time_ticks from phy.utils.color import ClusterColorSelector logger = logging.getLogger(__name__) +class RecordingTimeAxisMixin: + """Mixin for views whose x axis represents elapsed recording time.""" + + recording_time_unit = 's' + + def _set_recording_time_unit(self, unit): + """Set the displayed unit for the elapsed recording-time x axis.""" + self.recording_time_unit = unit + self.canvas.axes.set_x_formatter(lambda values: format_time_ticks(values, unit=unit)) + + # ----------------------------------------------------------------------------- # Manual clustering view # ----------------------------------------------------------------------------- diff --git a/phy/cluster/views/histogram.py b/phy/cluster/views/histogram.py index eadf414c..56f76215 100644 --- a/phy/cluster/views/histogram.py +++ b/phy/cluster/views/histogram.py @@ -14,7 +14,7 @@ from phy.plot.visuals import HistogramVisual, TextVisual from phy.utils.color import selected_cluster_color -from .base import ManualClusteringView, ScalingMixin +from .base import ManualClusteringView, RecordingTimeAxisMixin, ScalingMixin logger = logging.getLogger(__name__) @@ -394,7 +394,7 @@ class ISIView(HistogramView): } -class FiringRateView(HistogramView): +class FiringRateView(RecordingTimeAxisMixin, HistogramView): """Histogram view showing the time-dependent firing rate.""" n_bins = 200 diff --git a/phy/cluster/views/tests/test_histogram.py b/phy/cluster/views/tests/test_histogram.py index 2f74165d..33422ccd 100644 --- a/phy/cluster/views/tests/test_histogram.py +++ b/phy/cluster/views/tests/test_histogram.py @@ -112,6 +112,23 @@ def test_firing_rate_view_displays_spikes_per_second(qtbot): _stop_and_close(qtbot, v) +def test_firing_rate_view_formats_recording_time_axis(qtbot): + v = FiringRateView( + cluster_stat=lambda cluster_id: Bunch( + data=np.array([1.0, 3600.0]), + x_min=0.0, + x_max=7200.0, + ) + ) + v.on_select(cluster_ids=[0]) + v._set_recording_time_unit('h') + + assert v.recording_time_unit == 'h' + assert all(label.endswith(' h') for label in v.canvas.axes.locator.xtext) + + _stop_and_close(qtbot, v) + + def test_histogram_view_settings(qtbot, gui, monkeypatch): v = ISIView( cluster_stat=lambda cluster_id: Bunch( From ad488f719ebf23f53a331884d990903c55875467 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:39:37 +0200 Subject: [PATCH 053/110] docs: plan amplitude threshold splitting --- design/README.md | 7 + design/amplitude-threshold-splitting.md | 481 ++++++++++++++++++++++++ 2 files changed, 488 insertions(+) create mode 100644 design/amplitude-threshold-splitting.md diff --git a/design/README.md b/design/README.md index 08b05096..a49b01c3 100644 --- a/design/README.md +++ b/design/README.md @@ -29,3 +29,10 @@ both Merge View documents completely. Merge, selection, undo/redo, saved cluster assignments, colors, and cross-view consistency are safety-sensitive; do not declare the feature complete without the regression coverage and verification listed in the architecture proposal. + +## Amplitude-threshold splitting + +The [amplitude-threshold splitting implementation plan](amplitude-threshold-splitting.md) +defines the user interaction, safety invariants, controller/view boundaries, +delegable work packages, and verification required for amplitude-based split +previews in Amplitude View and Waveform View. diff --git a/design/amplitude-threshold-splitting.md b/design/amplitude-threshold-splitting.md new file mode 100644 index 00000000..7c6e02fd --- /dev/null +++ b/design/amplitude-threshold-splitting.md @@ -0,0 +1,481 @@ +# Amplitude-threshold splitting implementation plan + +Status: proposed implementation plan + +## 1. Goal + +Add a fast way to split the low-amplitude part of exactly one selected cluster. +The curator places a horizontal threshold in Amplitude View, previews the same +spike subset in Amplitude View and Waveform View, and presses `K` to perform the +ordinary phy split. + +This is a curation-integrity feature. The preview and committed spike IDs must +be derived from the same amplitude definition, and the committed selection must +be evaluated over every eligible spike rather than only the displayed sample. + +## 2. Locked user-visible behavior + +These decisions are implementation inputs, not questions for delegated agents. + +1. The interaction is available only when exactly one cluster is selected and + Merge mode is inactive. +2. `Alt`-right-drag in Amplitude View creates or moves a horizontal threshold. + Plain right-drag remains zoom. +3. The existing `Alt` time-selection gesture must respond only to left-click so + the two interactions do not overlap. +4. Spikes with finite amplitude strictly below the threshold are the pending + split subset. Equality remains in the upper group. +5. Below-threshold Amplitude View points and corresponding individual waveform + traces use one dedicated preview color. Above-threshold spikes retain the + selected cluster color; background spikes are unchanged. +6. Releasing the mouse leaves the preview active. Another `Alt`-right-drag + adjusts it. +7. `Control`-right-click clears both the Amplitude View lasso and threshold. + A view-menu action named `Clear amplitude split threshold` provides a + discoverable alternative. +8. Pressing `K` evaluates all eligible spikes in the cluster with the current + amplitude type and context, then calls the existing split path. +9. A threshold selecting zero spikes or the entire cluster does not call the + clustering model. It remains visible so the curator can adjust it, and a + status message explains why the split was rejected. +10. The threshold is transient. It is never saved in GUI state and is cleared + after a successful request, selection change, cluster update, amplitude-type + change, or a channel/PC change that alters the amplitude definition. +11. Only individual waveform mode receives per-spike highlighting in the first + version. Mean and template waveform modes remain unchanged. +12. Only the most recently edited built-in split selection is active. Starting + a threshold preview clears built-in lassos; starting a lasso clears the + threshold and other built-in lassos. Existing third-party `request_split` + listeners remain compatible. + +The initial version selects the lower side only. Inverting the selected side, +selecting an amplitude band, coloring histogram bins, and displaying separate +above/below mean waveforms are explicitly deferred. + +## 3. Current implementation boundaries + +- `phy/cluster/views/amplitude.py` already owns amplitude positions, spike IDs, + per-point colors, histograms, time selection, and lasso splitting. +- `LassoMixin.on_request_split()` in `phy/cluster/views/base.py` already reloads + all spikes for an exact split. +- `Supervisor.split()` in `phy/cluster/supervisor.py` gathers + `request_split` results and invokes the existing clustering/history path. +- `BaseController._amplitude_getter()` in `phy/apps/base.py` is the current + authority for choosing spike IDs and evaluating the active amplitude type, + channel, and PC. +- `WaveformMixin._get_waveforms_with_n_spikes()` chooses waveform spike IDs but + does not currently return those IDs. +- `WaveformView` currently assigns one cluster color to every trace, although + `PlotVisual` accepts one color per signal. + +Do not add a second split implementation to `Clustering`, write spike-cluster +arrays directly, or make the displayed amplitude sample authoritative. + +## 4. State and event contract + +### 4.1 Amplitude View transient state + +`AmplitudeView` should own only view-level preview state: + +```python +split_threshold: float | None +split_preview_color: tuple[float, float, float, float] +``` + +It may cache its current displayed bunches for recoloring. It must not cache all +cluster amplitudes merely to support dragging. + +The view constructor may accept an optional eligibility predicate supplied by +the controller. Standalone views default to checking their local cluster count; +the application predicate additionally checks the Supervisor's complete +selection and Merge-mode state. This prevents a truncated view selection from +becoming the authority for whether the gesture is allowed. + +### 4.2 Preview context + +Every threshold-preview update must identify: + +```text +cluster_id +amplitudes_type +threshold (or None when clearing) +``` + +The controller remains responsible for resolving the amplitude type to the +same channel IDs, selected channel, selected PC, and first cluster used by the +Amplitude View. Do not duplicate that context-selection logic in Waveform View. + +Use a narrowly named event such as `amplitude_split_preview_changed`. The event +is transient UI coordination and must not enter curation history or GUI state. + +### 4.3 Exact versus sampled work + +- During mouse movement, recolor only points and waveforms already displayed. +- Resolve amplitudes only for the displayed waveform spike IDs. Cache those + amplitude values for the unchanged preview context so moving the threshold + performs comparisons, not repeated data loads. +- On `K`, call the existing amplitude provider with `load_all=True` exactly + once, filter finite amplitudes with `< threshold`, and validate the result. + +### 4.4 Exclusive built-in split preview + +Add a small shared coordination layer for built-in lasso and threshold views: + +- a view announces when it activates a split selection; +- other built-in split-capable views clear their transient selection; +- clearing is visual/state cleanup only and never changes cluster assignments; +- the ordinary `request_split` event remains the commit boundary. + +Implement this in the split-view mixins or a small helper in +`phy/cluster/views/base.py`; do not put transient view state in +`CurationSelectionState`. Feature View has a custom split implementation and +must join the same coordination contract explicitly. + +## 5. Delegation map + +Each work package is intentionally bounded enough for a cheaper coding model at +low reasoning effort. Agents must read `AGENTS.md` and this document completely +before editing. They must inspect `git status`, preserve concurrent work, stage +only listed files, and report any unexpected overlap instead of rewriting it. + +The integration owner should assign one package per branch or worktree. Packages +B and C may run in parallel after A. Package D depends on both B and C. Package +E depends on D. Package F is the final integration audit. + +```text +A: split-preview coordination + |\ + | +--> B: Amplitude View threshold core --+ + | | + +----> C: Waveform identity/highlight --+--> D: controller bridge + | + v + E: integration/docs + | + v + F: final audit +``` + +No package may edit `phy/cluster/_selection.py` or +`phy/cluster/supervisor.py`; those files may contain concurrent selection work, +and this feature does not require changes to either file. + +## 6. Work package A: exclusive built-in split previews + +Suggested agent: cheaper coding model, low reasoning. + +Dependencies: none. + +Owned files: + +- `phy/cluster/views/base.py` +- `phy/cluster/views/feature.py` +- `phy/cluster/views/tests/test_base.py` +- `phy/cluster/views/tests/test_feature.py` +- `phy/cluster/views/tests/test_scatter.py` only if needed + +Tasks: + +1. Introduce the smallest shared mixin/helper that can announce activation and + clear a transient split selection. +2. Make non-empty lasso updates activate their owning built-in view. +3. When another built-in view activates, clear the current view's lasso. +4. Include Feature View despite its custom `on_request_split()` method. +5. Ensure connection cleanup follows existing view lifecycle patterns and does + not retain closed views. +6. Preserve standalone view behavior and the public `request_split` event. + +Acceptance tests: + +- Drawing a lasso in view A and then view B clears A. +- Redrawing in A clears B. +- The latest lasso still returns the expected unique spike IDs. +- Closing either view leaves no callback into the closed canvas. +- A standalone lasso view continues to split without a Supervisor. + +Run: + +```bash +uv run pytest phy/cluster/views/tests/test_base.py +uv run pytest phy/cluster/views/tests/test_feature.py +uv run pytest phy/cluster/views/tests/test_scatter.py +``` + +Suggested commit: `refactor: coordinate transient split selections` + +## 7. Work package B: Amplitude View threshold core + +Suggested agent: cheaper coding model, low reasoning. + +Dependencies: package A. + +Owned files: + +- `phy/cluster/views/amplitude.py` +- `phy/cluster/views/tests/test_amplitude.py` + +Tasks: + +1. Add transient threshold state and a horizontal line visual. Keep the line in + amplitude data coordinates so pan and zoom do not alter its meaning. +2. Restrict `Alt` time selection to left-click. +3. Implement `Alt`-right press/move/release. Convert the pointer Y coordinate + through the same NDC-to-data transform used by the plotted amplitudes and + clamp only if existing view bounds require it. +4. Require exactly one selected cluster. On invalid activation, leave state + unchanged and log a concise warning/status message. + Expose an optional eligibility predicate for package D to enforce the full + application selection and Merge-mode condition without importing Supervisor + state into the view. +5. Recolor displayed selected-cluster points below the threshold with a + per-point color array. Never recolor background points. +6. Emit the preview event after threshold changes and when it is cleared. +7. Make threshold activation participate in package A's exclusive split-preview + contract. `Control`-right-click clears both lasso and threshold. +8. Override `on_request_split()`: + - delegate to the lasso implementation when no threshold exists; + - otherwise reload the single cluster with `load_all=True` once; + - ignore non-finite amplitudes; + - select strict `< threshold` spike IDs; + - reject empty and whole-cluster results without clearing the threshold; + - return unique `int64` spike IDs and clear after a valid request. +9. Clear the preview on selection, cluster, and amplitude-type changes. Do not + persist it in `state_attrs` or `local_state_attrs`. +10. Add the view action and shortcut metadata needed for help generation. + +Acceptance tests: + +- Gesture-to-data conversion remains correct after pan/zoom. +- `Alt`-right interaction does not emit `select_time`; `Alt`-left still does. +- Background colors remain unchanged. +- Multi-cluster activation is rejected. +- Exact request uses spikes absent from the displayed sample. +- Equality, NaN, empty-side, whole-cluster, and valid-side cases are explicit. +- Threshold state clears on every context change listed in section 2. +- Existing lasso splitting tests still pass. + +Run: + +```bash +uv run pytest phy/cluster/views/tests/test_amplitude.py +``` + +Suggested commit: `feat: preview amplitude threshold splits` + +## 8. Work package C: waveform spike identity and recoloring + +Suggested agent: cheaper coding model, low reasoning. + +Dependencies: package A. May run in parallel with B. + +Owned files: + +- `phy/apps/base.py`, limited to waveform-provider changes +- `phy/cluster/views/waveform.py` +- `phy/cluster/views/tests/test_waveform.py` +- `phy/apps/tests/test_base.py`, limited to waveform-provider tests + +Tasks: + +1. Include the sampled `spike_ids` in the individual-waveform `Bunch` returned + by `_get_waveforms_with_n_spikes()`. +2. Keep mean/template waveform contracts valid. An aggregated waveform must not + pretend that many source IDs map one-to-one to its single trace. +3. Refactor `WaveformView.plot()` only as much as needed to retain its current + displayed bunches and rerender colors without calling the waveform provider + again. +4. Add a method accepting highlighted spike IDs or an empty value. It must: + - operate only in individual waveform mode; + - intersect against each bunch's displayed spike IDs; + - create one color per spike and repeat it across that spike's channels in + the exact signal order produced by the current transpose/reshape; + - preserve masks, box indices, overlap behavior, axes, and base colors. +5. Clear highlighted IDs on selection/cluster changes and when leaving + individual waveform mode. +6. Avoid allocations proportional to the complete recording or cluster. + +Acceptance tests: + +- Waveform bunch spike IDs exactly match the sampled data's first dimension. +- Only matching traces change color, on every channel belonging to the spike. +- Nonmatching traces retain their cluster color. +- Highlight updates do not invoke the waveform provider again. +- Multi-cluster rendering, overlap, masks, channel labels, and mean-waveform + toggling retain existing behavior. +- Missing `spike_ids` from a custom waveform provider disables highlighting + gracefully rather than guessing. + +Run: + +```bash +uv run pytest phy/cluster/views/tests/test_waveform.py +uv run pytest phy/apps/tests/test_base.py -k waveform +``` + +Suggested commit: `feat: support transient waveform spike highlights` + +## 9. Work package D: shared amplitude resolver and controller bridge + +Suggested agent: cheaper coding model, low reasoning, with the merged outputs of +B and C available. + +Dependencies: packages B and C. + +Owned files: + +- `phy/apps/base.py` +- `phy/apps/tests/test_base.py` + +Tasks: + +1. Extract a private helper from `_amplitude_getter()` that evaluates a supplied + spike-ID array using one named amplitude type and the canonical first-cluster, + channel IDs, selected channel, and selected PC context. +2. Make `_amplitude_getter()` use that helper so Amplitude View plotting, + threshold commit, and waveform preview cannot drift semantically. +3. In each Waveform View created by the controller, listen for amplitude split + preview changes from the matching controller/view only. +4. Supply Amplitude View's eligibility predicate from the complete Supervisor + state: exactly one selected cluster and Normal mode. Clear any active preview + when that predicate becomes false. +5. For an active preview matching the Waveform View's sole selected cluster: + obtain only its displayed individual-waveform spike IDs, resolve their + amplitudes through the shared helper, cache those values for the unchanged + `(cluster, amplitude type, channel context, PC context, spike IDs)` key, and + pass the below-threshold IDs to Waveform View. +6. A threshold-only change must reuse the cached values. Selection, amplitude + type, channel, PC, waveform IDs, filter changes, or cluster updates must + invalidate them. +7. A clear/mismatched preview, closed view, unavailable amplitude data, or + non-individual waveform mode must clear highlighting without raising. +8. Disconnect every added callback when either relevant view closes. + +Acceptance tests: + +- Feature and template amplitude previews use the same values as Amplitude View. +- Waveform IDs not present in the Amplitude View display sample are still + classified correctly. +- Repeated threshold movement performs no repeated amplitude-provider load for + unchanged context. +- Channel and PC changes invalidate both threshold semantics and preview cache. +- Events from another controller or Amplitude View do not affect this view. +- Closing and reopening either view does not duplicate callbacks. + +Run: + +```bash +uv run pytest phy/apps/tests/test_base.py -k 'amplitude or waveform or split' +uv run pytest phy/cluster/views/tests/test_amplitude.py +uv run pytest phy/cluster/views/tests/test_waveform.py +``` + +Suggested commit: `feat: link amplitude split previews to waveforms` + +## 10. Work package E: application regression and documentation + +Suggested agent: cheaper coding model, low reasoning. + +Dependencies: package D. + +Owned files: + +- `phy/apps/tests/test_base.py` +- `phy/apps/template/tests/test_gui.py` if the existing fixture is suitable +- `docs/visualization.md` +- `docs/clustering.md` +- `docs/changelog.md` +- generated documentation changed by `make doc-check` + +Tasks: + +1. Add one end-to-end controller regression using deterministic spike IDs and + amplitudes: select one cluster, set a threshold, verify both previews, press + `K`, and verify the two new clusters contain exactly the expected spikes. +2. Verify undo restores the original assignments and clears transient preview + state. If practical in the fixture, verify redo as well. +3. Verify a displayed waveform spike missing from the Amplitude View sample is + highlighted from its actual amplitude. +4. Document the gesture, single-cluster requirement, lower-side semantics, + clear action, `K` commit, individual-waveform limitation, and exact-all-spike + commit behavior. +5. Add the user-visible feature to the unreleased changelog. +6. Regenerate shortcut/API documentation through the repository command; do not + hand-edit generated sections unless the generator requires source changes. + +Run: + +```bash +uv run pytest phy/apps/tests/test_base.py -k split +uv run pytest phy/apps/template/tests/test_gui.py -k amplitude +make doc-check +``` + +Suggested commit: `docs: cover amplitude threshold splitting` + +## 11. Work package F: integration and safety audit + +Suggested owner: integration agent, medium reasoning. This is review and repair, +not a redesign package. + +Dependencies: package E. + +Owned files: only files already touched by packages A-E, and only for necessary +integration fixes. Do not absorb unrelated working-tree changes. + +Checklist: + +1. Review the complete diff against the locked behavior in section 2. +2. Confirm preview and commit share the canonical amplitude resolver. +3. Confirm no interactive drag path requests all cluster spikes. +4. Confirm `K` evaluates all eligible spikes exactly once. +5. Confirm zero/all/NaN cases cannot mutate clustering. +6. Confirm a successful split, undo, redo, save, and reload retain ordinary phy + clustering semantics. +7. Confirm built-in stale lassos cannot be silently unioned with the threshold. +8. Confirm callbacks and OpenGL visuals are cleaned up when views close. +9. Inspect allocations for dependence on displayed spikes only during preview. +10. Inspect the index before every commit and exclude pre-existing changes. + +Final validation: + +```bash +uv run pytest phy/cluster/views/tests/test_base.py +uv run pytest phy/cluster/views/tests/test_feature.py +uv run pytest phy/cluster/views/tests/test_scatter.py +uv run pytest phy/cluster/views/tests/test_amplitude.py +uv run pytest phy/cluster/views/tests/test_waveform.py +uv run pytest phy/apps/tests/test_base.py +uv run pytest phy/apps/template/tests/test_gui.py +make lint +make format-check +make doc-check +make test-full +``` + +Because this changes GUI interaction, split selection, and cross-view spike +identity, passing only unit tests is not sufficient. The integration owner must +also perform a manual smoke test on a cluster larger than both display budgets: + +1. Place and move the threshold while zoomed. +2. Confirm Amplitude and Waveform previews agree. +3. Commit and inspect both descendants. +4. Undo and redo. +5. Save, reopen, and verify the saved cluster assignments. + +## 12. Handoff template for every delegated package + +Each agent should return: + +```text +Package: +Commit: +Files changed: +Behavior implemented: +Tests run and results: +Known limitations or follow-up: +Unexpected pre-existing changes left untouched: +``` + +An agent must not claim completion if required tests were skipped because of an +environment failure. Report the exact failure and leave the package for the +integration owner to verify. From 4561e2edda3f82785912d6506943c7422db7c445 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:40:35 +0200 Subject: [PATCH 054/110] Fix recording-time unit selection --- docs/changelog.md | 4 ++-- docs/visualization.md | 8 ++++---- phy/apps/base.py | 39 +++++++++++++++++++++++++------------ phy/apps/tests/test_base.py | 25 ++++++++++++++++++++++-- phy/gui/qt.py | 1 + 5 files changed, 57 insertions(+), 20 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 5cdcc0ca..05dd0e5d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -19,8 +19,8 @@ behavior they verify rather than listed separately. ### Added - Display elapsed recording time in seconds, minutes, or hours in Amplitude - and Firing Rate views. **View > Set recording time unit** changes the shared - preference, and axis labels now use thousands separators. + and Firing Rate views. Choose the shared preference from **View > Recording + time unit**, and axis labels now use thousands separators. - Stage and order manual merge candidates in the new **Merge View**. Press `V` to enter or cancel Merge mode, transfer candidates with `Control`-right-click or drag-and-drop, and press `G` to merge every staged diff --git a/docs/visualization.md b/docs/visualization.md index 5c28dbd4..1f625f7c 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -512,10 +512,10 @@ and the displayed range together. These parameters are dataset-local, so values saved for one recording do not clip or coarsen a fresh dataset. Amplitude and Firing Rate views display elapsed recording time on their x -axes. Choose **View > Set recording time unit** to show that time in seconds, -minutes, or hours; the setting is shared across compatible views and remembered -between sessions. This only changes tick labels: navigation, selection, ranges, -and firing-rate bins remain in seconds. +axes. Choose **View > Recording time unit > Seconds**, **Minutes**, or **Hours**; +the setting is shared across compatible views and remembered between sessions. +This only changes tick labels: navigation, selection, ranges, and firing-rate +bins remain in seconds. ![image](https://user-images.githubusercontent.com/1942359/58951704-193e5080-8792-11e9-873f-91a9115a9e7c.png) diff --git a/phy/apps/base.py b/phy/apps/base.py index b6587e91..36062a26 100644 --- a/phy/apps/base.py +++ b/phy/apps/base.py @@ -42,7 +42,7 @@ from phy.cluster.views.trace import _iter_spike_waveforms from phy.gui import GUI from phy.gui.gui import _prompt_save -from phy.gui.qt import AsyncCaller +from phy.gui.qt import AsyncCaller, QActionGroup from phy.gui.state import _gui_state_path from phy.gui.widgets import IPythonView, view_settings_dialog from phy.utils.context import Context, _cache_methods @@ -2138,16 +2138,29 @@ def on_gui_ready(sender, gui): gui.create_and_add_view(view_name) def create_misc_actions(self, gui): - @gui.view_actions.add( - name='Set recording time unit', - alias='timeunit', - prompt=True, - prompt_default=lambda: self.recording_time_unit, - show_shortcut=False, - ) - def set_recording_time_unit(unit): - """Set recording-time labels to s, min, or h.""" - self._set_recording_time_unit(unit, gui) + self._recording_time_actions = {} + self._recording_time_action_group = QActionGroup(gui) + self._recording_time_action_group.setExclusive(True) + + for label, unit in (('Seconds', 's'), ('Minutes', 'min'), ('Hours', 'h')): + + def set_recording_time_unit(checked, unit=unit): + """Set the unit used for elapsed recording-time labels.""" + if checked: + self._set_recording_time_unit(unit, gui) + + gui.view_actions.add( + set_recording_time_unit, + name=label, + alias=f'time_{unit}', + submenu='Recording time unit', + checkable=True, + checked=self.recording_time_unit == unit, + show_shortcut=False, + ) + action = gui.view_actions.get(label) + self._recording_time_action_group.addAction(action) + self._recording_time_actions[unit] = action # Toggle spike reorder. @gui.view_actions.add( @@ -2198,7 +2211,9 @@ def _set_recording_time_unit(self, unit, gui): except (AttributeError, KeyError) as e: raise ValueError("Recording time unit must be 's', 'min', or 'h'.") from e self.recording_time_unit = unit - for view in gui.list_views(): + for action_unit, action in getattr(self, '_recording_time_actions', {}).items(): + action.setChecked(action_unit == unit) + for view in gui.views: if isinstance(view, RecordingTimeAxisMixin): view._set_recording_time_unit(unit) diff --git a/phy/apps/tests/test_base.py b/phy/apps/tests/test_base.py index 1d516c2a..ca5b0713 100644 --- a/phy/apps/tests/test_base.py +++ b/phy/apps/tests/test_base.py @@ -36,6 +36,7 @@ TraceView, WaveformView, ) +from phy.gui import GUI from phy.gui.qt import Debouncer, create_app from phy.gui.widgets import Barrier from phy.plot.tests import mouse_click @@ -537,8 +538,7 @@ def test_recording_time_unit_updates_compatible_views(qtbot): firing_rate = FiringRateView(cluster_stat=lambda cluster_id: Bunch(data=np.array([0.0]))) class GUI: - def list_views(self): - return [amplitude, firing_rate] + views = [amplitude, firing_rate] controller._set_recording_time_unit('hours', GUI()) @@ -551,6 +551,27 @@ def list_views(self): firing_rate.close() +def test_recording_time_unit_menu(qtbot, tempdir): + controller = object.__new__(BaseController) + controller.recording_time_unit = 's' + gui = GUI(name='RecordingTimeTest', config_dir=tempdir) + controller.create_misc_actions(gui) + + seconds = gui.view_actions.get('Seconds') + minutes = gui.view_actions.get('Minutes') + hours = gui.view_actions.get('Hours') + assert seconds.isCheckable() and minutes.isCheckable() and hours.isCheckable() + assert seconds.isChecked() + + hours.trigger() + + assert controller.recording_time_unit == 'h' + assert hours.isChecked() + assert not seconds.isChecked() + assert not minutes.isChecked() + gui.close() + + def test_amplitude_view_excludes_unavailable_features(qtbot, tempdir): controller = _mock_controller(tempdir, MyControllerFull) controller.model.features = None diff --git a/phy/gui/qt.py b/phy/gui/qt.py index cf94ef3f..3486ca94 100644 --- a/phy/gui/qt.py +++ b/phy/gui/qt.py @@ -65,6 +65,7 @@ ) from PyQt5.QtWidgets import ( # noqa QAction, + QActionGroup, QAbstractItemView, QHeaderView, QStatusBar, From 8390eab6c806ba0076dd2e71b59b233a74b86078 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:43:42 +0200 Subject: [PATCH 055/110] refactor: make presentation reordering explicit --- phy/cluster/_selection.py | 41 +++++++++--- phy/cluster/supervisor.py | 95 ++++++++++++---------------- phy/cluster/tests/test_selection.py | 34 ++++++++++ phy/cluster/tests/test_supervisor.py | 48 ++++++++++++++ 4 files changed, 152 insertions(+), 66 deletions(-) diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index be073e92..923b06b6 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -133,7 +133,7 @@ def __post_init__(self): ) presentation_order = ( default_presentation - if self.presentation_order is None or self.mode is WorkflowMode.MERGE + if self.presentation_order is None else _as_unique_ids(self.presentation_order) ) @@ -147,6 +147,12 @@ def __post_init__(self): and presentation_order[0] != reference_id ): raise ValueError('The reference ID must occupy the first presentation slot.') + if self.mode is WorkflowMode.MERGE: + merge_ids = merge.ordered_ids + if presentation_order[: len(merge_ids)] != merge_ids: + raise ValueError('Merge presentation must begin with the staged merge order.') + if set(presentation_order[len(merge_ids) :]) != set(similar_ids): + raise ValueError('Merge presentation tail must contain the Similarity selection.') color_order = ( presentation_order if self.color_order is None else _as_unique_ids(self.color_order) ) @@ -290,19 +296,20 @@ def set_normal_selection( ) return self._apply(after) - def set_similarity_selection(self, similar_ids): + def set_similarity_selection(self, similar_ids, presentation_order=None): """Set Similarity View IDs without changing the current reference.""" current = self._state similar_ids = _as_unique_ids(similar_ids) effective_ids = _ordered_union(current.merge_ids, similar_ids) - presentation_order = _ordered_union( - tuple( - cluster_id - for cluster_id in current.presentation_order - if cluster_id in effective_ids - ), - effective_ids, - ) + if presentation_order is None: + presentation_order = _ordered_union( + tuple( + cluster_id + for cluster_id in current.presentation_order + if cluster_id in effective_ids + ), + effective_ids, + ) after = CurationSelectionState( mode=current.mode, cluster_ids=current.cluster_ids, @@ -314,6 +321,20 @@ def set_similarity_selection(self, similar_ids): ) return self._apply(after) + def set_presentation_order(self, presentation_order): + """Set scientific-view order without changing roles or color slots.""" + current = self._state + after = CurationSelectionState( + mode=current.mode, + cluster_ids=current.cluster_ids, + similar_ids=current.similar_ids, + reference_id=current.reference_id, + presentation_order=_as_unique_ids(presentation_order), + color_order=current.color_order, + merge=current.merge, + ) + return self._apply(after) + def clear_similarity_selection(self): """Clear only the Similarity View selection.""" return self.set_similarity_selection(()) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index ce447832..6849dbf4 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -998,6 +998,7 @@ def _create_views(self, gui=None, sort=None): # Update the action flow and similarity view when selection changes. connect(self._clusters_selected, event='select', sender=self.cluster_view) connect(self._table_order_changed, event='table_sort', sender=self.cluster_view) + connect(self._table_order_changed, event='table_filter', sender=self.cluster_view) # Create the similarity view. self.similarity_view = SimilarityView( @@ -1013,6 +1014,7 @@ def _create_views(self, gui=None, sort=None): ) connect(self._similar_selected, event='select', sender=self.similarity_view) connect(self._table_order_changed, event='table_sort', sender=self.similarity_view) + connect(self._table_order_changed, event='table_filter', sender=self.similarity_view) connect( self._add_similar_to_merge_on_right_click, event='row_right_click', @@ -1120,12 +1122,13 @@ def _clusters_selected(self, sender, obj, **kwargs): kwargs = obj.get('kwargs', {}) logger.debug('Clusters selected: %s (%s)', cluster_ids, next_cluster) change = self.selection.set_normal_selection(cluster_ids) + change = self._set_table_presentation_order(change) self.task_logger.log(self.cluster_view, 'select', cluster_ids, output=obj) - # Update the similarity view when the cluster view selection changes. + # Reset candidates for the newly selected reference without emitting. self.similarity_view.reset(cluster_ids, reference_id=change.after.reference_id) self.similarity_view.set_selected_ids(()) - change = self._normalize_presentation_order(change) self._update_selection_colors() + self._project_merge_view() # Emit supervisor.select event unless update_views is False. This happens after # a merge event, where the views should not be updated after the first cluster_view.select # event, but instead after the second similarity_view.select event. @@ -1147,27 +1150,10 @@ def _similar_selected(self, sender, obj): next_similar = obj['next'] kwargs = obj.get('kwargs', {}) logger.debug('Similar clusters selected: %s (%s)', similar, next_similar) - if self.selection.state.is_merge_mode: - change = self.selection.set_similarity_selection(similar) - change = self._normalize_presentation_order(change) - else: - state = self.selection.state - similar_in_table_order = self._ids_in_table_order(self.similarity_view, similar) - presentation_order = tuple( - dict.fromkeys( - ( - *((state.reference_id,) if state.reference_id is not None else ()), - *self._ids_in_table_order(self.cluster_view, state.cluster_ids), - *similar_in_table_order, - ) - ) - ) - change = self.selection.set_normal_selection( - state.cluster_ids, - similar, - reference_id=state.reference_id, - presentation_order=presentation_order, - ) + presentation_order = self._presentation_order_from_tables( + self.selection.state, similar_ids=similar + ) + self.selection.set_similarity_selection(similar, presentation_order) self._update_selection_colors() self._project_merge_view() self.task_logger.log(self.similarity_view, 'select', similar, output=obj) @@ -1187,29 +1173,28 @@ def _ids_in_table_order(view, cluster_ids): cluster_id for cluster_id in cluster_ids if cluster_id not in visible_set ) - def _normalize_presentation_order(self, change): - """Derive scientific-view order from the active workflow's visible role order.""" - state = change.after - similar_ids = self._ids_in_table_order(self.similarity_view, state.similar_ids) + def _presentation_order_from_tables(self, state, similar_ids=None): + """Return the active roles in table order without changing their membership.""" + similar_ids = self._ids_in_table_order( + self.similarity_view, state.similar_ids if similar_ids is None else similar_ids + ) if state.is_merge_mode: - normalized = self.selection.set_similarity_selection(similar_ids) - else: - cluster_ids = self._ids_in_table_order(self.cluster_view, state.cluster_ids) - presentation_order = tuple( - dict.fromkeys( - ( - *((state.reference_id,) if state.reference_id is not None else ()), - *cluster_ids, - *similar_ids, - ) + return state.merge_ids + similar_ids + cluster_ids = self._ids_in_table_order(self.cluster_view, state.cluster_ids) + return tuple( + dict.fromkeys( + ( + *((state.reference_id,) if state.reference_id is not None else ()), + *cluster_ids, + *similar_ids, ) ) - normalized = self.selection.set_normal_selection( - state.cluster_ids, - state.similar_ids, - reference_id=state.reference_id, - presentation_order=presentation_order, - ) + ) + + def _set_table_presentation_order(self, change): + """Apply the current table ordering through the presentation-only transition.""" + presentation_order = self._presentation_order_from_tables(change.after) + normalized = self.selection.set_presentation_order(presentation_order) return SelectionChange.create(change.before, normalized.after) def _table_order_changed(self, sender, row_ids): @@ -1219,13 +1204,10 @@ def _table_order_changed(self, sender, row_ids): if sender is self.cluster_view and self.selection.state.is_merge_mode: return state = self.selection.state - change = SelectionChange.create(state, state) - change = self._normalize_presentation_order(change) + change = self._set_table_presentation_order(SelectionChange.create(state, state)) if not change.presentation_changed: return - self._update_selection_colors() - self._project_merge_view() - emit('select', self, list(change.after.presentation_order)) + self._apply_selection_change(change, refresh_similarity=False) def _update_selection_colors(self): """Project stable selection-color positions into all workflow tables.""" @@ -1250,16 +1232,17 @@ def _merge_status_text(self): similar = len(state.similar_ids) return f'MERGE MODE — {staged} staged + {similar} selected similar = {staged + similar} clusters' - def _apply_selection_change(self, change, callback=None, normalize_order=True): + def _apply_selection_change( + self, change, callback=None, refresh_similarity=True, publish=True, sync_presentation=True + ): """Project one complete controller transition and publish it atomically.""" + if sync_presentation: + change = self._set_table_presentation_order(change) state = change.after cluster_payload = self.cluster_view.set_selected_ids(state.cluster_ids) - if state.reference_id is not None: + if refresh_similarity and state.reference_id is not None: self.similarity_view.reset(state.merge_ids, reference_id=state.reference_id) similar_payload = self.similarity_view.set_selected_ids(state.similar_ids) - if normalize_order: - change = self._normalize_presentation_order(change) - state = change.after self._update_selection_colors() self._project_merge_view() self.task_logger.log( @@ -1274,7 +1257,7 @@ def _apply_selection_change(self, change, callback=None, normalize_order=True): list(state.similar_ids), output=similar_payload, ) - if change.render_changed: + if publish and change.render_changed: emit('select', self, list(state.presentation_order)) if callback: self.cluster_view._schedule_callback(callback, state) @@ -1367,7 +1350,7 @@ def _cancel_merge_mode(self, close_view=True): context = self.selection.state.merge.entry_snapshot.workflow_context change = self.selection.cancel_merge_mode() self._set_merge_mode_ui(False) - self._apply_selection_change(change, normalize_order=False) + self._apply_selection_change(change, refresh_similarity=False, sync_presentation=False) self._restore_workflow_context(context) if close_view: self._close_merge_view() @@ -1380,7 +1363,7 @@ def _restore_history_context(self, selection, workflow_context, direction): elif not selection.is_merge_mode: self._set_merge_mode_ui(False) change = self.selection.restore(selection) - self._apply_selection_change(change, normalize_order=False) + self._apply_selection_change(change, refresh_similarity=False, sync_presentation=False) if selection.is_merge_mode: context = ( workflow_context.get('tables') diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index 97abc931..c4ca09e7 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -253,3 +253,37 @@ def test_merge_candidate_guards_reference_and_duplicate_membership(): controller.reorder_merge((1,), 1) with raises(ValueError, match='merge session'): controller.remove_from_merge((9,)) + + +def test_presentation_order_transition_preserves_roles_and_colors(): + controller = CurationSelectionController( + CurationSelectionState( + cluster_ids=(1, 2), similar_ids=(3,), reference_id=1, color_order=(1, 2, 3) + ) + ) + + change = controller.set_presentation_order((1, 3, 2)) + + assert change.presentation_changed + assert not change.roles_changed + assert not change.colors_changed + assert change.after.cluster_ids == (1, 2) + assert change.after.similar_ids == (3,) + assert change.after.color_order == (1, 2, 3) + with raises(ValueError, match='exactly'): + controller.set_presentation_order((1, 2)) + + +def test_merge_presentation_order_requires_merge_prefix_and_similarity_tail(): + controller = CurationSelectionController(CurationSelectionState(cluster_ids=(1, 2))) + controller.enter_merge_mode() + controller.set_similarity_selection((3,)) + + change = controller.set_presentation_order((1, 2, 3)) + + assert not change.roles_changed + assert not change.colors_changed + with raises(ValueError, match='begin'): + controller.set_presentation_order((1, 3, 2)) + with raises(ValueError, match='exactly'): + controller.set_presentation_order((1, 2, 4)) diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 67517779..d8cf5083 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -946,6 +946,54 @@ def test_merge_presentation_keeps_merge_order_before_similarity_table_order(supe assert supervisor.selected == [30, 11, 20, 1] +def test_table_filter_reorders_normal_presentation_without_recoloring(supervisor): + _select(supervisor, [30]) + similarity_view = supervisor.similarity_view + similarity_view.sort_by('id', 'asc') + similarity_view.select([1, 11, 20]) + supervisor.block() + colors = supervisor.selection_color_order + roles = (supervisor.selected_clusters, supervisor.selected_similar) + events = [] + + @connect(sender=supervisor) + def on_select(sender, cluster_ids): + events.append(cluster_ids) + + similarity_view.filter('id >= 11') + + assert supervisor.selected == [30, 11, 20, 1] + assert supervisor.selection_color_order == colors + assert (supervisor.selected_clusters, supervisor.selected_similar) == roles + assert events == [[30, 11, 20, 1]] + unconnect(on_select) + + +def test_table_filter_reorders_merge_similarity_tail_without_recoloring(supervisor): + _select(supervisor, [30]) + supervisor.toggle_merge_mode() + similarity_view = supervisor.similarity_view + similarity_view.sort_by('id', 'asc') + similarity_view.select([1, 11, 20]) + supervisor.block() + supervisor.add_to_merge((11,), insertion=1) + colors = supervisor.selection_color_order + roles = (supervisor.selected_merge, supervisor.selected_similar) + events = [] + + @connect(sender=supervisor) + def on_select(sender, cluster_ids): + events.append(cluster_ids) + + similarity_view.filter('id >= 20') + + assert supervisor.selected == [30, 11, 20, 1] + assert supervisor.selection_color_order == colors + assert (supervisor.selected_merge, supervisor.selected_similar) == roles + assert events == [[30, 11, 20, 1]] + unconnect(on_select) + + def test_supervisor_select_event_has_legacy_payload_and_suppression(supervisor): events = [] From f26e790343064a7e6f1a8db4a5910386844093c0 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:45:57 +0200 Subject: [PATCH 056/110] Control recording-time label precision --- docs/changelog.md | 3 +- docs/visualization.md | 6 ++-- phy/apps/base.py | 44 +++++++++++++++++++++-- phy/apps/tests/test_base.py | 16 ++++++++- phy/cluster/views/base.py | 10 ++++-- phy/cluster/views/tests/test_histogram.py | 2 +- phy/plot/axes.py | 15 ++++++-- phy/plot/tests/test_axes.py | 1 + 8 files changed, 85 insertions(+), 12 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 05dd0e5d..ceb82eda 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -20,7 +20,8 @@ behavior they verify rather than listed separately. - Display elapsed recording time in seconds, minutes, or hours in Amplitude and Firing Rate views. Choose the shared preference from **View > Recording - time unit**, and axis labels now use thousands separators. + time unit**, control precision from **View > Recording time decimals**, and + see open views update immediately. Axis labels now use thousands separators. - Stage and order manual merge candidates in the new **Merge View**. Press `V` to enter or cancel Merge mode, transfer candidates with `Control`-right-click or drag-and-drop, and press `G` to merge every staged diff --git a/docs/visualization.md b/docs/visualization.md index 1f625f7c..57436938 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -514,8 +514,10 @@ values saved for one recording do not clip or coarsen a fresh dataset. Amplitude and Firing Rate views display elapsed recording time on their x axes. Choose **View > Recording time unit > Seconds**, **Minutes**, or **Hours**; the setting is shared across compatible views and remembered between sessions. -This only changes tick labels: navigation, selection, ranges, and firing-rate -bins remain in seconds. +Choose **View > Recording time decimals** to control the maximum number of +decimal places shown. These options update open views immediately and only +change tick labels: navigation, selection, ranges, and firing-rate bins remain +in seconds. ![image](https://user-images.githubusercontent.com/1942359/58951704-193e5080-8792-11e9-873f-91a9115a9e7c.png) diff --git a/phy/apps/base.py b/phy/apps/base.py index 36062a26..aec72ae6 100644 --- a/phy/apps/base.py +++ b/phy/apps/base.py @@ -1038,6 +1038,7 @@ class BaseController: # Unit used to display elapsed recording time in compatible views. recording_time_unit = 's' + recording_time_decimals = 2 # Controller attributes to load/save in the GUI state. _state_params = ( @@ -1048,6 +1049,7 @@ class BaseController: 'n_spikes_correlograms_total', 'raw_data_filter_name', 'recording_time_unit', + 'recording_time_decimals', ) # Methods that are cached in memory (and on disk) for performance. @@ -2162,6 +2164,31 @@ def set_recording_time_unit(checked, unit=unit): self._recording_time_action_group.addAction(action) self._recording_time_actions[unit] = action + self._recording_time_decimal_actions = {} + self._recording_time_decimal_action_group = QActionGroup(gui) + self._recording_time_decimal_action_group.setExclusive(True) + + for decimals in range(5): + + def set_recording_time_decimals(checked, decimals=decimals): + """Set the maximum decimal places used in recording-time labels.""" + if checked: + self._set_recording_time_decimals(decimals, gui) + + label = f'{decimals} decimal' if decimals == 1 else f'{decimals} decimals' + gui.view_actions.add( + set_recording_time_decimals, + name=label, + alias=f'time_decimals_{decimals}', + submenu='Recording time decimals', + checkable=True, + checked=self.recording_time_decimals == decimals, + show_shortcut=False, + ) + action = gui.view_actions.get(label) + self._recording_time_decimal_action_group.addAction(action) + self._recording_time_decimal_actions[decimals] = action + # Toggle spike reorder. @gui.view_actions.add( shortcut=self.default_shortcuts['toggle_spike_reorder'], @@ -2215,7 +2242,18 @@ def _set_recording_time_unit(self, unit, gui): action.setChecked(action_unit == unit) for view in gui.views: if isinstance(view, RecordingTimeAxisMixin): - view._set_recording_time_unit(unit) + view._set_recording_time_format(unit, self.recording_time_decimals) + + def _set_recording_time_decimals(self, decimals, gui): + """Set the maximum decimal places in elapsed recording-time labels.""" + if not isinstance(decimals, int) or not 0 <= decimals <= 4: + raise ValueError('Recording time decimals must be an integer between 0 and 4.') + self.recording_time_decimals = decimals + for value, action in getattr(self, '_recording_time_decimal_actions', {}).items(): + action.setChecked(value == decimals) + for view in gui.views: + if isinstance(view, RecordingTimeAxisMixin): + view._set_recording_time_format(self.recording_time_unit, decimals) def _add_default_color_schemes(self, view): """Add the default color schemes to every view.""" @@ -2298,7 +2336,9 @@ def on_view_attached(view, gui_): if isinstance(view, ManualClusteringView): if isinstance(view, RecordingTimeAxisMixin): - view._set_recording_time_unit(self.recording_time_unit) + view._set_recording_time_format( + self.recording_time_unit, self.recording_time_decimals + ) # Add auto update button. view.dock.add_button( diff --git a/phy/apps/tests/test_base.py b/phy/apps/tests/test_base.py index ca5b0713..2fe5deb8 100644 --- a/phy/apps/tests/test_base.py +++ b/phy/apps/tests/test_base.py @@ -534,18 +534,25 @@ def get_spike_times(self, cluster_id, n=None): def test_recording_time_unit_updates_compatible_views(qtbot): controller = object.__new__(BaseController) controller.recording_time_unit = 's' + controller.recording_time_decimals = 2 amplitude = AmplitudeView(amplitudes=lambda cluster_ids, load_all=False: None) firing_rate = FiringRateView(cluster_stat=lambda cluster_id: Bunch(data=np.array([0.0]))) class GUI: views = [amplitude, firing_rate] - controller._set_recording_time_unit('hours', GUI()) + with ( + patch.object(amplitude.canvas, 'update') as amplitude_update, + patch.object(firing_rate.canvas, 'update') as firing_rate_update, + ): + controller._set_recording_time_unit('hours', GUI()) assert controller.recording_time_unit == 'h' assert amplitude.recording_time_unit == firing_rate.recording_time_unit == 'h' assert all(label.endswith(' h') for label in amplitude.canvas.axes.locator.xtext) assert all(label.endswith(' h') for label in firing_rate.canvas.axes.locator.xtext) + amplitude_update.assert_called() + firing_rate_update.assert_called() amplitude.close() firing_rate.close() @@ -554,6 +561,7 @@ class GUI: def test_recording_time_unit_menu(qtbot, tempdir): controller = object.__new__(BaseController) controller.recording_time_unit = 's' + controller.recording_time_decimals = 2 gui = GUI(name='RecordingTimeTest', config_dir=tempdir) controller.create_misc_actions(gui) @@ -569,6 +577,12 @@ def test_recording_time_unit_menu(qtbot, tempdir): assert hours.isChecked() assert not seconds.isChecked() assert not minutes.isChecked() + + four_decimals = gui.view_actions.get('4 decimals') + four_decimals.trigger() + assert controller.recording_time_decimals == 4 + assert four_decimals.isChecked() + assert not gui.view_actions.get('2 decimals').isChecked() gui.close() diff --git a/phy/cluster/views/base.py b/phy/cluster/views/base.py index 235a7c93..956586ca 100644 --- a/phy/cluster/views/base.py +++ b/phy/cluster/views/base.py @@ -27,11 +27,15 @@ class RecordingTimeAxisMixin: """Mixin for views whose x axis represents elapsed recording time.""" recording_time_unit = 's' + recording_time_decimals = 2 - def _set_recording_time_unit(self, unit): - """Set the displayed unit for the elapsed recording-time x axis.""" + def _set_recording_time_format(self, unit, decimals): + """Set the displayed format for the elapsed recording-time x axis.""" self.recording_time_unit = unit - self.canvas.axes.set_x_formatter(lambda values: format_time_ticks(values, unit=unit)) + self.recording_time_decimals = decimals + self.canvas.axes.set_x_formatter( + lambda values: format_time_ticks(values, unit=unit, decimals=decimals) + ) # ----------------------------------------------------------------------------- diff --git a/phy/cluster/views/tests/test_histogram.py b/phy/cluster/views/tests/test_histogram.py index 33422ccd..d92b78c7 100644 --- a/phy/cluster/views/tests/test_histogram.py +++ b/phy/cluster/views/tests/test_histogram.py @@ -121,7 +121,7 @@ def test_firing_rate_view_formats_recording_time_axis(qtbot): ) ) v.on_select(cluster_ids=[0]) - v._set_recording_time_unit('h') + v._set_recording_time_format('h', 2) assert v.recording_time_unit == 'h' assert all(label.endswith(' h') for label in v.canvas.axes.locator.xtext) diff --git a/phy/plot/axes.py b/phy/plot/axes.py index f861d4a9..23ce715d 100644 --- a/phy/plot/axes.py +++ b/phy/plot/axes.py @@ -25,7 +25,7 @@ def format_number(value): return f'{value:,.9g}' -def format_time_ticks(values, unit='s'): +def format_time_ticks(values, unit='s', decimals=2): """Format elapsed-time ticks expressed internally in seconds. The coordinates are deliberately left in seconds: only their displayed @@ -35,8 +35,18 @@ def format_time_ticks(values, unit='s'): factors = {'s': 1, 'min': 60, 'h': 3600} if unit not in factors: raise ValueError(f'Unknown time unit: {unit!r}') + if not isinstance(decimals, int) or not 0 <= decimals <= 6: + raise ValueError('Time tick decimals must be an integer between 0 and 6.') factor = factors[unit] - return [f'{format_number(value / factor)} {unit}' for value in values] + labels = [] + for value in values: + number = f'{value / factor:,.{decimals}f}' + if decimals: + number = number.rstrip('0').rstrip('.') + if number in ('-0', ''): + number = '0' + labels.append(f'{number} {unit}') + return labels class AxisLocator: @@ -199,6 +209,7 @@ def set_x_formatter(self, formatter): self.locator.set_view_bounds(self._attached.panzoom.get_range() if self._attached else NDC) if self._attached: self.update_visuals() + self._attached.update() def _create_visuals(self): """Create the line and text visuals on the x and/or y axes.""" diff --git a/phy/plot/tests/test_axes.py b/phy/plot/tests/test_axes.py index 864174fa..5f558f76 100644 --- a/phy/plot/tests/test_axes.py +++ b/phy/plot/tests/test_axes.py @@ -40,6 +40,7 @@ def test_axes_1(qtbot, canvas_pz): def test_time_tick_formatting(): assert format_time_ticks([0, 1000, 10000]) == ['0 s', '1,000 s', '10,000 s'] assert format_time_ticks([0, 3600], unit='h') == ['0 h', '1 h'] + assert format_time_ticks([4000, 4800], unit='h', decimals=2) == ['1.11 h', '1.33 h'] def test_axes_x_formatter_survives_reset(qtbot, canvas_pz): From 19e0443498526d1e7722eb23509ffdb95e041a9b Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:47:49 +0200 Subject: [PATCH 057/110] Remove recording-time decimal menu --- docs/changelog.md | 4 ++-- docs/visualization.md | 7 +++---- phy/apps/base.py | 37 ------------------------------------- phy/apps/tests/test_base.py | 7 +------ 4 files changed, 6 insertions(+), 49 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index ceb82eda..d2798e39 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -20,8 +20,8 @@ behavior they verify rather than listed separately. - Display elapsed recording time in seconds, minutes, or hours in Amplitude and Firing Rate views. Choose the shared preference from **View > Recording - time unit**, control precision from **View > Recording time decimals**, and - see open views update immediately. Axis labels now use thousands separators. + time unit** and see open views update immediately. Axis labels use thousands + separators and at most two decimal places. - Stage and order manual merge candidates in the new **Merge View**. Press `V` to enter or cancel Merge mode, transfer candidates with `Control`-right-click or drag-and-drop, and press `G` to merge every staged diff --git a/docs/visualization.md b/docs/visualization.md index 57436938..b907333c 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -514,10 +514,9 @@ values saved for one recording do not clip or coarsen a fresh dataset. Amplitude and Firing Rate views display elapsed recording time on their x axes. Choose **View > Recording time unit > Seconds**, **Minutes**, or **Hours**; the setting is shared across compatible views and remembered between sessions. -Choose **View > Recording time decimals** to control the maximum number of -decimal places shown. These options update open views immediately and only -change tick labels: navigation, selection, ranges, and firing-rate bins remain -in seconds. +Labels show no more than two decimal places and discard unnecessary trailing +zeroes. Unit selections update open views immediately and only change tick +labels: navigation, selection, ranges, and firing-rate bins remain in seconds. ![image](https://user-images.githubusercontent.com/1942359/58951704-193e5080-8792-11e9-873f-91a9115a9e7c.png) diff --git a/phy/apps/base.py b/phy/apps/base.py index aec72ae6..dab03ecc 100644 --- a/phy/apps/base.py +++ b/phy/apps/base.py @@ -1049,7 +1049,6 @@ class BaseController: 'n_spikes_correlograms_total', 'raw_data_filter_name', 'recording_time_unit', - 'recording_time_decimals', ) # Methods that are cached in memory (and on disk) for performance. @@ -2164,31 +2163,6 @@ def set_recording_time_unit(checked, unit=unit): self._recording_time_action_group.addAction(action) self._recording_time_actions[unit] = action - self._recording_time_decimal_actions = {} - self._recording_time_decimal_action_group = QActionGroup(gui) - self._recording_time_decimal_action_group.setExclusive(True) - - for decimals in range(5): - - def set_recording_time_decimals(checked, decimals=decimals): - """Set the maximum decimal places used in recording-time labels.""" - if checked: - self._set_recording_time_decimals(decimals, gui) - - label = f'{decimals} decimal' if decimals == 1 else f'{decimals} decimals' - gui.view_actions.add( - set_recording_time_decimals, - name=label, - alias=f'time_decimals_{decimals}', - submenu='Recording time decimals', - checkable=True, - checked=self.recording_time_decimals == decimals, - show_shortcut=False, - ) - action = gui.view_actions.get(label) - self._recording_time_decimal_action_group.addAction(action) - self._recording_time_decimal_actions[decimals] = action - # Toggle spike reorder. @gui.view_actions.add( shortcut=self.default_shortcuts['toggle_spike_reorder'], @@ -2244,17 +2218,6 @@ def _set_recording_time_unit(self, unit, gui): if isinstance(view, RecordingTimeAxisMixin): view._set_recording_time_format(unit, self.recording_time_decimals) - def _set_recording_time_decimals(self, decimals, gui): - """Set the maximum decimal places in elapsed recording-time labels.""" - if not isinstance(decimals, int) or not 0 <= decimals <= 4: - raise ValueError('Recording time decimals must be an integer between 0 and 4.') - self.recording_time_decimals = decimals - for value, action in getattr(self, '_recording_time_decimal_actions', {}).items(): - action.setChecked(value == decimals) - for view in gui.views: - if isinstance(view, RecordingTimeAxisMixin): - view._set_recording_time_format(self.recording_time_unit, decimals) - def _add_default_color_schemes(self, view): """Add the default color schemes to every view.""" group_colors = { diff --git a/phy/apps/tests/test_base.py b/phy/apps/tests/test_base.py index 2fe5deb8..325c8609 100644 --- a/phy/apps/tests/test_base.py +++ b/phy/apps/tests/test_base.py @@ -577,12 +577,7 @@ def test_recording_time_unit_menu(qtbot, tempdir): assert hours.isChecked() assert not seconds.isChecked() assert not minutes.isChecked() - - four_decimals = gui.view_actions.get('4 decimals') - four_decimals.trigger() - assert controller.recording_time_decimals == 4 - assert four_decimals.isChecked() - assert not gui.view_actions.get('2 decimals').isChecked() + assert gui.view_actions.get('2 decimals') is None gui.close() From 0ed062c3d82d727a5583f33e69f32dff23bf9808 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:52:16 +0200 Subject: [PATCH 058/110] refactor: store complete normal selection snapshot --- phy/cluster/_selection.py | 42 +++++------------------------ phy/cluster/tests/test_selection.py | 13 ++++++++- 2 files changed, 18 insertions(+), 37 deletions(-) diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index 923b06b6..733c8747 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -35,37 +35,14 @@ def _ordered_union(*cluster_id_lists) -> tuple[int, ...]: class NormalWorkflowSnapshot: """Normal-mode selection plus opaque view state needed for cancellation.""" - cluster_ids: tuple[int, ...] - similar_ids: tuple[int, ...] - reference_id: int | None - presentation_order: tuple[int, ...] - color_order: tuple[int, ...] | None = None + selection: CurationSelectionState workflow_context: object = None def __post_init__(self): - state = CurationSelectionState( - cluster_ids=self.cluster_ids, - similar_ids=self.similar_ids, - reference_id=self.reference_id, - presentation_order=self.presentation_order, - color_order=self.color_order, - ) - object.__setattr__(self, 'cluster_ids', state.cluster_ids) - object.__setattr__(self, 'similar_ids', state.similar_ids) - object.__setattr__(self, 'reference_id', state.reference_id) - object.__setattr__(self, 'presentation_order', state.presentation_order) - object.__setattr__(self, 'color_order', state.color_order) - - @property - def selection(self): - """Return the Normal-mode selection represented by this snapshot.""" - return CurationSelectionState( - cluster_ids=self.cluster_ids, - similar_ids=self.similar_ids, - reference_id=self.reference_id, - presentation_order=self.presentation_order, - color_order=self.color_order, - ) + if not isinstance(self.selection, CurationSelectionState): + raise TypeError('Snapshot selection must be a CurationSelectionState.') + if self.selection.mode is not WorkflowMode.NORMAL: + raise ValueError('Normal workflow snapshots require a Normal-mode selection.') @dataclass(frozen=True) @@ -345,14 +322,7 @@ def enter_merge_mode(self, workflow_context=None): current = self._state if not current.cluster_ids: raise ValueError('Merge mode requires a Cluster View selection.') - snapshot = NormalWorkflowSnapshot( - cluster_ids=current.cluster_ids, - similar_ids=current.similar_ids, - reference_id=current.reference_id, - presentation_order=current.presentation_order, - color_order=current.color_order, - workflow_context=workflow_context, - ) + snapshot = NormalWorkflowSnapshot(current, workflow_context=workflow_context) ordered_ids = current.presentation_order merge = MergeSession(current.reference_id, ordered_ids, snapshot) after = CurationSelectionState( diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index c4ca09e7..468f41ed 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -146,7 +146,7 @@ def test_snapshot_restore_and_noop_change_classification(): def test_merge_session_validates_reference_and_state_roles(): - snapshot = NormalWorkflowSnapshot((1,), (), 1, (1,)) + snapshot = NormalWorkflowSnapshot(CurationSelectionState(cluster_ids=(1,))) with raises(ValueError, match='first staged'): MergeSession(1, (2, 1), snapshot) merge = MergeSession(1, (1, 2), snapshot) @@ -186,6 +186,7 @@ def test_enter_and_cancel_merge_mode_restore_exact_entry_selection(): assert change.after.similar_ids == () assert set(change.after.effective_ids) == set(initial.effective_ids) assert change.after.merge.entry_snapshot.workflow_context is context + assert change.after.merge.entry_snapshot.selection is initial change = controller.cancel_merge_mode() assert change.after == initial @@ -193,6 +194,16 @@ def test_enter_and_cancel_merge_mode_restore_exact_entry_selection(): assert not change.presentation_changed +def test_normal_workflow_snapshot_requires_a_normal_selection_state(): + with raises(TypeError, match='CurationSelectionState'): + NormalWorkflowSnapshot((1,)) + + controller = CurationSelectionController(CurationSelectionState(cluster_ids=(1,))) + controller.enter_merge_mode() + with raises(ValueError, match='Normal-mode'): + NormalWorkflowSnapshot(controller.state) + + def test_enter_merge_mode_stages_normal_presentation_order(): initial = CurationSelectionState( cluster_ids=(1, 2), From 24c1e3b4b8f8949e944aa0b2efe10abf5c28282a Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:52:35 +0200 Subject: [PATCH 059/110] fix: preserve hidden selection presentation order --- phy/cluster/supervisor.py | 28 ++++++++++++++++++++++------ phy/cluster/tests/test_supervisor.py | 22 +++++++++++++--------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 6849dbf4..8ac309f3 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1163,24 +1163,40 @@ def _similar_selected(self, sender, obj): self.similarity_view.dock.set_status(f'similar clusters: {", ".join(map(str, similar))}') @staticmethod - def _ids_in_table_order(view, cluster_ids): - """Return selected IDs in row order, retaining filtered-out IDs at the end.""" + def _ids_in_table_order(view, cluster_ids, previous_order=()): + """Return visible selected IDs followed by hidden IDs in prior presentation order.""" cluster_ids = tuple(cluster_ids) selected = set(cluster_ids) visible = [cluster_id for cluster_id in view.get_ids() if cluster_id in selected] visible_set = set(visible) - return tuple(visible) + tuple( - cluster_id for cluster_id in cluster_ids if cluster_id not in visible_set + hidden = [ + cluster_id + for cluster_id in previous_order + if cluster_id in selected and cluster_id not in visible_set + ] + hidden_set = set(hidden) + # New role IDs may not yet occur in the previous presentation while a + # table selection is being applied. They are visible in normal use; + # retain any exceptional hidden IDs rather than dropping membership. + hidden.extend( + cluster_id + for cluster_id in cluster_ids + if cluster_id not in visible_set and cluster_id not in hidden_set ) + return tuple(visible) + tuple(hidden) def _presentation_order_from_tables(self, state, similar_ids=None): """Return the active roles in table order without changing their membership.""" similar_ids = self._ids_in_table_order( - self.similarity_view, state.similar_ids if similar_ids is None else similar_ids + self.similarity_view, + state.similar_ids if similar_ids is None else similar_ids, + state.presentation_order, ) if state.is_merge_mode: return state.merge_ids + similar_ids - cluster_ids = self._ids_in_table_order(self.cluster_view, state.cluster_ids) + cluster_ids = self._ids_in_table_order( + self.cluster_view, state.cluster_ids, state.presentation_order + ) return tuple( dict.fromkeys( ( diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index d8cf5083..2d8347d1 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -950,8 +950,10 @@ def test_table_filter_reorders_normal_presentation_without_recoloring(supervisor _select(supervisor, [30]) similarity_view = supervisor.similarity_view similarity_view.sort_by('id', 'asc') - similarity_view.select([1, 11, 20]) + # Click A, C, B, while table order establishes A, B, C presentation. + similarity_view.select([1, 20, 11]) supervisor.block() + assert supervisor.selected == [30, 1, 11, 20] colors = supervisor.selection_color_order roles = (supervisor.selected_clusters, supervisor.selected_similar) events = [] @@ -960,12 +962,14 @@ def test_table_filter_reorders_normal_presentation_without_recoloring(supervisor def on_select(sender, cluster_ids): events.append(cluster_ids) - similarity_view.filter('id >= 11') + # Retain only A. Hidden B/C must use the prior presentation order, not + # the Similarity role's click order (A, C, B). + similarity_view.filter('id < 2') - assert supervisor.selected == [30, 11, 20, 1] + assert supervisor.selected == [30, 1, 11, 20] assert supervisor.selection_color_order == colors assert (supervisor.selected_clusters, supervisor.selected_similar) == roles - assert events == [[30, 11, 20, 1]] + assert events == [] unconnect(on_select) @@ -974,9 +978,9 @@ def test_table_filter_reorders_merge_similarity_tail_without_recoloring(supervis supervisor.toggle_merge_mode() similarity_view = supervisor.similarity_view similarity_view.sort_by('id', 'asc') - similarity_view.select([1, 11, 20]) + similarity_view.select([1, 20, 11]) supervisor.block() - supervisor.add_to_merge((11,), insertion=1) + assert supervisor.selected == [30, 1, 11, 20] colors = supervisor.selection_color_order roles = (supervisor.selected_merge, supervisor.selected_similar) events = [] @@ -985,12 +989,12 @@ def test_table_filter_reorders_merge_similarity_tail_without_recoloring(supervis def on_select(sender, cluster_ids): events.append(cluster_ids) - similarity_view.filter('id >= 20') + similarity_view.filter('id < 2') - assert supervisor.selected == [30, 11, 20, 1] + assert supervisor.selected == [30, 1, 11, 20] assert supervisor.selection_color_order == colors assert (supervisor.selected_merge, supervisor.selected_similar) == roles - assert events == [[30, 11, 20, 1]] + assert events == [] unconnect(on_select) From b426e9abbf301c11924cbc9944a8caecb5b1cb8f Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:54:15 +0200 Subject: [PATCH 060/110] Group View menu actions --- docs/changelog.md | 3 +++ phy/apps/base.py | 6 +++--- phy/gui/gui.py | 5 +++-- phy/gui/tests/test_gui.py | 5 +++++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index d2798e39..b815a7a0 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -84,6 +84,9 @@ behavior they verify rather than listed separately. ### Changed +- Group available views under **View > Add view** and keep global view options + separate from view creation. + - The first, blue Cluster View selection is now the explicit Similarity reference. In Normal mode, scientific views follow the selected Cluster and Similarity rows in visible table order; re-sorting either table updates that diff --git a/phy/apps/base.py b/phy/apps/base.py index dab03ecc..5849d723 100644 --- a/phy/apps/base.py +++ b/phy/apps/base.py @@ -2163,6 +2163,8 @@ def set_recording_time_unit(checked, unit=unit): self._recording_time_action_group.addAction(action) self._recording_time_actions[unit] = action + gui.view_actions.separator() + # Toggle spike reorder. @gui.view_actions.add( shortcut=self.default_shortcuts['toggle_spike_reorder'], @@ -2192,8 +2194,6 @@ def switch_raw_data_filter(): v.ex_status = filter_name v.update_status() - gui.view_actions.separator() - def _set_recording_time_unit(self, unit, gui): """Set the elapsed recording-time display unit in compatible open views.""" aliases = { @@ -2318,8 +2318,8 @@ def on_view_attached(view, gui_): # Get the state's current sort, and make sure the cluster view is initialized with it. self.supervisor.attach(gui) - self.create_misc_actions(gui) gui.set_default_actions() + self.create_misc_actions(gui) gui.create_views() # Bind the `select_more` event to add clusters to the existing selection. diff --git a/phy/gui/gui.py b/phy/gui/gui.py index 36096045..28ebb020 100644 --- a/phy/gui/gui.py +++ b/phy/gui/gui.py @@ -692,12 +692,14 @@ def exit(): """Close the GUI.""" self.close() - # Add "Add view" action. + # Add-view actions belong together in a submenu: there can be many of + # them, and they are secondary to the currently open views. for view_name in sorted(self.view_creator.keys()): self.view_actions.add( partial(self.create_and_add_view, view_name), name=f'Add {view_name}', docstring=f'Add {view_name}', + submenu='Add view', show_shortcut=False, ) self.view_actions.separator() @@ -829,7 +831,6 @@ def create_and_add_view(self, view_name): def create_views(self): """Create and add as many views as specified in view_count.""" - self.view_actions.separator() # Keep the order of self.default_views. view_names = [vn for vn in self.default_views if vn in self._requested_view_count] # We add the views in the requested view count, but not in the default views. diff --git a/phy/gui/tests/test_gui.py b/phy/gui/tests/test_gui.py index 440ee3f5..58d09b2a 100644 --- a/phy/gui/tests/test_gui.py +++ b/phy/gui/tests/test_gui.py @@ -196,6 +196,11 @@ def _create_my_canvas(): assert len(views) == 2 add_action = gui.view_actions.get('Add MyCanvas') + view_menu = gui.get_menu('&View') + add_view_menu = next( + action.menu() for action in view_menu.actions() if action.text() == 'Add view' + ) + assert add_action in add_view_menu.actions() # Close the first dock widget. views[0].dock.toggleViewAction().activate(0) From 444b130a7f7e475cc3f59da7e563a9635b43ddfb Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:55:14 +0200 Subject: [PATCH 061/110] Group selection navigation actions --- docs/changelog.md | 2 ++ phy/cluster/supervisor.py | 27 +++++++++++++-------------- phy/cluster/tests/test_supervisor.py | 10 ++++++++++ 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index b815a7a0..edc7b671 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -84,6 +84,8 @@ behavior they verify rather than listed separately. ### Changed +- Group cluster traversal commands under **Select > Navigation**. + - Group available views under **View > Add view** and keep global view options separate from view creation. diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 8ac309f3..e4b2a64d 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -619,21 +619,20 @@ def _create_select_actions(self): ) self.select_actions.separator() - self.add(w, 'first') - self.add(w, 'last') - self.select_actions.separator() - - self.add(w, 'reset_wizard', icon='f015') - self.select_actions.separator() - - self.add(w, 'next', icon='f061') - self.add(w, 'previous', icon='f060') - self.select_actions.separator() - - self.add(w, 'next_best', icon='f0a9') - self.add(w, 'previous_best', icon='f0a8') - self.select_actions.separator() + # Navigation. Keep traversal commands together rather than splitting + # the root menu into several small, related sections. + submenu = 'Navigation' + self.add(w, 'first', submenu=submenu) + self.add(w, 'last', submenu=submenu) + self.select_actions.separator(submenu=submenu) + self.add(w, 'reset_wizard', icon='f015', submenu=submenu) + self.select_actions.separator(submenu=submenu) + self.add(w, 'next', icon='f061', submenu=submenu) + self.add(w, 'previous', icon='f060', submenu=submenu) + self.select_actions.separator(submenu=submenu) + self.add(w, 'next_best', icon='f0a9', submenu=submenu) + self.add(w, 'previous_best', icon='f0a8', submenu=submenu) def _create_toolbar(self, gui): gui._toolbar.addAction(self.select_actions.get('reset_wizard')) diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 2d8347d1..a869ba8a 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -1170,6 +1170,16 @@ def test_supervisor_select_first_similar_config(gui, cluster_ids, similarity): assert not supervisor.action_creator.edit_actions.get('merge').icon().isNull() assert not gui.help_actions.get('show_all_shortcuts').icon().isNull() + select_menu = gui.get_menu('Sele&ct') + navigation_menu = next( + action.menu() for action in select_menu.actions() if action.text() == 'Navigation' + ) + navigation_actions = [action for action in navigation_menu.actions() if not action.isSeparator()] + assert navigation_actions == [ + supervisor.select_actions.get(name) + for name in ('first', 'last', 'reset_wizard', 'next', 'previous', 'next_best', 'previous_best') + ] + with raises(ValueError, match='positive integer'): supervisor.select_first_similar(0) with raises(ValueError, match='positive integer'): From 106685a19896a5b2799f36211686829ead73a32e Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:57:15 +0200 Subject: [PATCH 062/110] Organize view action menus --- docs/changelog.md | 3 +++ phy/cluster/tests/test_supervisor.py | 14 ++++++++++-- phy/cluster/views/base.py | 34 +++++++++++++++++++++++++++- phy/cluster/views/tests/test_base.py | 21 +++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index edc7b671..4f2f580c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -84,6 +84,9 @@ behavior they verify rather than listed separately. ### Changed +- Put content-specific actions first in every view menu, followed by a + consistent Auto-update, Screenshot, and Close utility footer. + - Group cluster traversal commands under **Select > Navigation**. - Group available views under **View > Add view** and keep global view options diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index a869ba8a..6534fa35 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -1174,10 +1174,20 @@ def test_supervisor_select_first_similar_config(gui, cluster_ids, similarity): navigation_menu = next( action.menu() for action in select_menu.actions() if action.text() == 'Navigation' ) - navigation_actions = [action for action in navigation_menu.actions() if not action.isSeparator()] + navigation_actions = [ + action for action in navigation_menu.actions() if not action.isSeparator() + ] assert navigation_actions == [ supervisor.select_actions.get(name) - for name in ('first', 'last', 'reset_wizard', 'next', 'previous', 'next_best', 'previous_best') + for name in ( + 'first', + 'last', + 'reset_wizard', + 'next', + 'previous', + 'next_best', + 'previous_best', + ) ] with raises(ValueError, match='positive integer'): diff --git a/phy/cluster/views/base.py b/phy/cluster/views/base.py index 956586ca..a615c09f 100644 --- a/phy/cluster/views/base.py +++ b/phy/cluster/views/base.py @@ -334,7 +334,11 @@ def on_visibility_changed(visible): ) self.actions.add(self.screenshot, show_shortcut=False) self.actions.add(self.close, show_shortcut=False) - self.actions.separator() + + # Subclasses and plugins add their content-specific actions after this + # method returns. When the menu is first opened, place shared utility + # actions at the bottom, with a single separator before them. + self.dock._menu.aboutToShow.connect(self._organize_menu_actions) on_select = partial(self.on_select_threaded, gui=gui) connect(on_select, event='select') @@ -367,6 +371,34 @@ def _set_floating(): emit('view_attached', self, gui) + def _organize_menu_actions(self): + """Put view utilities in a consistent footer and normalize separators.""" + menu = self.dock._menu + utilities = [ + self.actions.get(name) + for name in ('toggle_auto_update', 'screenshot', 'close') + ] + for action in utilities: + menu.removeAction(action) + + # Remove leading, trailing, and duplicate separators left behind by + # independently contributed view actions. + previous_is_separator = True + for action in list(menu.actions()): + if action.isSeparator(): + if previous_is_separator: + menu.removeAction(action) + previous_is_separator = True + else: + previous_is_separator = False + if menu.actions() and menu.actions()[-1].isSeparator(): + menu.removeAction(menu.actions()[-1]) + + if menu.actions(): + menu.addSeparator() + for action in utilities: + menu.addAction(action) + @property def status(self): """To be overridden.""" diff --git a/phy/cluster/views/tests/test_base.py b/phy/cluster/views/tests/test_base.py index 85c559ea..89d443d6 100644 --- a/phy/cluster/views/tests/test_base.py +++ b/phy/cluster/views/tests/test_base.py @@ -88,6 +88,27 @@ class Supervisor: qtbot.wait(100) +def test_manual_clustering_view_menu_utility_footer(qtbot, gui): + v = MyView() + v.attach(gui) + v.add_color_scheme(lambda cid: cid, name='myscheme') + + v.dock._menu.aboutToShow.emit() + actions = v.dock._menu.actions() + assert actions[-3:] == [ + v.actions.get('toggle_auto_update'), + v.actions.get('screenshot'), + v.actions.get('close'), + ] + assert actions[-4].isSeparator() + assert not any( + action.isSeparator() and next_action.isSeparator() + for action, next_action in zip(actions, actions[1:]) + ) + + _stop_and_close(qtbot, v) + + def test_manual_clustering_view_selection_is_limited(qtbot, gui): v = MyView() v.max_n_clusters = 2 From 8cce43aaae7f907bfb5442b3cd48fe5eef4e0fbd Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:05:12 +0200 Subject: [PATCH 063/110] refactor: coordinate transient split selections --- phy/cluster/views/base.py | 54 +++++++++++++++++++++++-- phy/cluster/views/feature.py | 4 +- phy/cluster/views/tests/test_base.py | 37 ++++++++++++++++- phy/cluster/views/tests/test_feature.py | 22 +++++++++- 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/phy/cluster/views/base.py b/phy/cluster/views/base.py index a615c09f..88f4ece1 100644 --- a/phy/cluster/views/base.py +++ b/phy/cluster/views/base.py @@ -375,8 +375,7 @@ def _organize_menu_actions(self): """Put view utilities in a consistent footer and normalize separators.""" menu = self.dock._menu utilities = [ - self.actions.get(name) - for name in ('toggle_auto_update', 'screenshot', 'close') + self.actions.get(name) for name in ('toggle_auto_update', 'screenshot', 'close') ] for action in utilities: menu.removeAction(action) @@ -765,7 +764,51 @@ def on_mouse_wheel(self, e): # pragma: no cover self.decrease_marker_size() -class LassoMixin: +class SplitSelectionMixin: + """Coordinate transient built-in split selections between views in one GUI. + + Split selections are deliberately view-local and transient. This mixin only + makes them mutually exclusive; it does not participate in the + ``request_split`` commit event. + """ + + def clear_split_selection(self): + """Clear this view's transient split selection. + + Views with another kind of split preview can override this hook, call + ``super()``, and clear their own preview state as well. + """ + self.canvas.lasso.clear() + + def activate_split_selection(self): + """Make this view's transient split selection the active built-in one.""" + if self.gui is not None: + emit('split_selection_activated', self) + + def attach(self, gui): + super().attach(gui) + + @connect(event='lasso_updated') + def on_lasso_updated(sender, polygon): + if sender == self.canvas and len(polygon): + self.activate_split_selection() + + @connect(event='split_selection_activated') + def on_split_selection_activated(sender): + if sender is self or getattr(sender, 'gui', None) is not self.gui: + return + self.clear_split_selection() + + @connect(event='close_view') + def on_close_view(view, sender): + if view is not self: + return + unconnect(on_lasso_updated) + unconnect(on_split_selection_activated) + unconnect(on_close_view) + + +class LassoMixin(SplitSelectionMixin): def on_request_split(self, sender=None): """Return the spikes enclosed by the lasso.""" if self.canvas.lasso.count < 3 or not len(self.cluster_ids): # pragma: no cover @@ -803,3 +846,8 @@ def on_request_split(self, sender=None): def attach(self, gui): super().attach(gui) connect(self.on_request_split) + + @connect(event='close_view', sender=self) + def on_close_view(view, gui): + unconnect(self.on_request_split) + unconnect(on_close_view) diff --git a/phy/cluster/views/feature.py b/phy/cluster/views/feature.py index f294b1da..7e272507 100644 --- a/phy/cluster/views/feature.py +++ b/phy/cluster/views/feature.py @@ -15,7 +15,7 @@ from phy.plot.visuals import LineVisual, ScatterVisual, TextVisual from phy.utils.color import selected_cluster_color -from .base import ManualClusteringView, MarkerSizeMixin, ScalingMixin +from .base import ManualClusteringView, MarkerSizeMixin, ScalingMixin, SplitSelectionMixin logger = logging.getLogger(__name__) @@ -63,7 +63,7 @@ def _uniq(seq): return [x for x in seq if not (x in seen or seen_add(x))] -class FeatureView(MarkerSizeMixin, ScalingMixin, ManualClusteringView): +class FeatureView(MarkerSizeMixin, ScalingMixin, SplitSelectionMixin, ManualClusteringView): """This view displays a 4x4 subplot matrix with different projections of the principal component features. This view keeps track of which channels are currently shown. diff --git a/phy/cluster/views/tests/test_base.py b/phy/cluster/views/tests/test_base.py index 89d443d6..f9528015 100644 --- a/phy/cluster/views/tests/test_base.py +++ b/phy/cluster/views/tests/test_base.py @@ -9,7 +9,7 @@ from phy.utils.color import colormaps, selected_cluster_color -from ..base import BaseColorView, ManualClusteringView +from ..base import BaseColorView, ManualClusteringView, SplitSelectionMixin from . import _stop_and_close # ------------------------------------------------------------------------------ @@ -41,6 +41,12 @@ def plot(self, **kwargs): self.updates.append((list(self.cluster_ids), kwargs)) +class SplitSelectionView(SplitSelectionMixin, ManualClusteringView): + def __init__(self): + super().__init__() + self.canvas.enable_lasso() + + def test_manual_clustering_view_1(qtbot, tempdir): v = MyView() v.canvas.show() @@ -176,3 +182,32 @@ class Supervisor: _stop_and_close(qtbot, hidden) _stop_and_close(qtbot, visible) + + +def test_split_selection_is_exclusive_and_disconnects_on_close(qtbot, gui): + first = SplitSelectionView() + second = SplitSelectionView() + first.attach(gui) + second.attach(gui) + + first.canvas.lasso.add((0, 0)) + emit('lasso_updated', first.canvas, first.canvas.lasso.polygon) + assert first.canvas.lasso.count == 1 + + second.canvas.lasso.add((0, 0)) + emit('lasso_updated', second.canvas, second.canvas.lasso.polygon) + assert first.canvas.lasso.count == 0 + assert second.canvas.lasso.count == 1 + + first.canvas.lasso.add((0, 0)) + emit('lasso_updated', first.canvas, first.canvas.lasso.polygon) + assert first.canvas.lasso.count == 1 + assert second.canvas.lasso.count == 0 + + first.close() + calls = [] + first.clear_split_selection = lambda: calls.append(True) + second.activate_split_selection() + assert not calls + + _stop_and_close(qtbot, second) diff --git a/phy/cluster/views/tests/test_feature.py b/phy/cluster/views/tests/test_feature.py index ce4ada2c..f749e1b6 100644 --- a/phy/cluster/views/tests/test_feature.py +++ b/phy/cluster/views/tests/test_feature.py @@ -8,11 +8,12 @@ import pytest from phylib.io.array import _spikes_per_cluster from phylib.io.mock import artificial_features, artificial_spike_clusters -from phylib.utils import Bunch, connect +from phylib.utils import Bunch, connect, emit from phy.plot.tests import mouse_click from ..feature import FeatureView, _get_default_grid +from ..scatter import ScatterView from . import _stop_and_close # ------------------------------------------------------------------------------ @@ -105,3 +106,22 @@ def on_select_feature(sender, dim=None, channel_id=None, pc=None): v.set_state(v.state) _stop_and_close(qtbot, v) + + +def test_feature_lasso_clears_other_builtin_split_selection(qtbot, gui): + feature = FeatureView(features=lambda *args, **kwargs: None) + scatter = ScatterView(coords=lambda *args, **kwargs: None) + feature.attach(gui) + scatter.attach(gui) + + scatter.canvas.lasso.add((0, 0)) + emit('lasso_updated', scatter.canvas, scatter.canvas.lasso.polygon) + assert scatter.canvas.lasso.count == 1 + + feature.canvas.lasso.add((0, 0)) + emit('lasso_updated', feature.canvas, feature.canvas.lasso.polygon) + assert scatter.canvas.lasso.count == 0 + assert feature.canvas.lasso.count == 1 + + _stop_and_close(qtbot, feature) + _stop_and_close(qtbot, scatter) From df4b8a5a43ed79a060f446f1f471b3e26c007b35 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:05:18 +0200 Subject: [PATCH 064/110] feat: preview amplitude threshold splits --- phy/cluster/views/amplitude.py | 186 +++++++++++++++++++++- phy/cluster/views/tests/test_amplitude.py | 133 +++++++++++++++- 2 files changed, 313 insertions(+), 6 deletions(-) diff --git a/phy/cluster/views/amplitude.py b/phy/cluster/views/amplitude.py index 7e109223..d8e2a8f0 100644 --- a/phy/cluster/views/amplitude.py +++ b/phy/cluster/views/amplitude.py @@ -9,11 +9,11 @@ import numpy as np from phylib.utils._types import _as_array -from phylib.utils.event import emit +from phylib.utils.event import connect, emit, unconnect from phy.cluster._utils import RotatingProperty from phy.plot.transform import NDC, Range, Rotate, Scale, Translate -from phy.plot.visuals import HistogramVisual, PatchVisual, ScatterVisual +from phy.plot.visuals import HistogramVisual, LineVisual, PatchVisual, ScatterVisual from phy.utils.color import add_alpha, selected_cluster_color from .base import LassoMixin, ManualClusteringView, MarkerSizeMixin, RecordingTimeAxisMixin @@ -51,6 +51,7 @@ class AmplitudeView(RecordingTimeAxisMixin, MarkerSizeMixin, LassoMixin, ManualC # Alpha channel of the markers in the scatter plot. marker_alpha = 1.0 time_range_color = (1.0, 1.0, 0.0, 0.25) + split_preview_color = (1.0, 0.4, 0.1, 1.0) # Number of bins in the histogram. n_bins = 100 @@ -73,10 +74,18 @@ class AmplitudeView(RecordingTimeAxisMixin, MarkerSizeMixin, LassoMixin, ManualC 'select_time': 'alt+click', } - def __init__(self, amplitudes=None, amplitudes_type=None, duration=None): + def __init__( + self, amplitudes=None, amplitudes_type=None, duration=None, split_is_eligible=None + ): super().__init__() self.state_attrs += ('amplitudes_type',) + # The split preview is deliberately transient and is not part of view state. + self.split_threshold = None + self._split_is_eligible = split_is_eligible + self._split_threshold_dragging = False + self._displayed_bunchs = () + self.canvas.enable_axes() self.canvas.enable_lasso() @@ -137,6 +146,11 @@ def __init__(self, amplitudes=None, amplitudes_type=None, duration=None): ) self.canvas.add_visual(self.patch_visual) + # Horizontal amplitude split threshold, expressed in amplitude data coordinates. + self.split_threshold_visual = LineVisual() + self.split_threshold_visual.hide() + self.canvas.add_visual(self.split_threshold_visual) + # Scatter plot. self.visual = ScatterVisual() self.canvas.add_visual(self.visual) @@ -203,9 +217,40 @@ def _plot_cluster(self, bunch): ) # Scatter plot. + color = bunch.color + if bunch.cluster_id is not None and self.split_threshold is not None: + color = np.tile(np.asarray(bunch.color), (len(bunch.amplitudes), 1)) + below = np.isfinite(bunch.amplitudes) & (bunch.amplitudes < self.split_threshold) + color[below] = self.split_preview_color self.visual.add_batch_data( - pos=bunch.pos, color=bunch.color, size=ms, data_bounds=self.data_bounds + pos=bunch.pos, color=color, size=ms, data_bounds=self.data_bounds + ) + + def _update_split_threshold_visual(self): + if self.split_threshold is None or not hasattr(self, 'data_bounds'): + self.split_threshold_visual.hide() + return + xmin, _, xmax, _ = self.data_bounds + y = self.split_threshold + self.split_threshold_visual.set_data( + pos=np.array([[xmin, y, xmax, y]]), + color=self.split_preview_color, + data_bounds=self.data_bounds, ) + self.split_threshold_visual.show() + + def _replot_displayed_amplitudes(self): + """Recolor the already loaded amplitude sample without loading data.""" + if not self._displayed_bunchs: + self._update_split_threshold_visual() + self.canvas.update() + return + self.visual.reset_batch() + for bunch in self._displayed_bunchs: + self._plot_cluster(bunch) + self.canvas.update_visual(self.visual) + self._update_split_threshold_visual() + self.canvas.update() def get_clusters_data(self, load_all=None): """Return a list of Bunch instances, with attributes pos and spike_ids.""" @@ -223,6 +268,9 @@ def get_clusters_data(self, load_all=None): spike_times = _as_array(bunch.spike_times) amplitudes = _as_array(bunch.amplitudes) assert spike_ids.shape == spike_times.shape == amplitudes.shape + bunch.spike_ids = spike_ids + bunch.spike_times = spike_times + bunch.amplitudes = amplitudes # Ensure that bunch.pos exists, as it used by the LassoMixin. bunch.pos = np.c_[spike_times, amplitudes] assert bunch.pos.ndim == 2 @@ -244,6 +292,7 @@ def plot(self, **kwargs): return self.data_bounds = self._get_data_bounds(bunchs) bunchs = self._add_histograms(bunchs) + self._displayed_bunchs = tuple(bunchs) # Use the same scale for all histograms. self._ylim = max(bunch.histogram.max() for bunch in bunchs) if bunchs else 1.0 @@ -253,6 +302,7 @@ def plot(self, **kwargs): self._plot_cluster(bunch) self.canvas.update_visual(self.visual) self.canvas.update_visual(self.hist_visual) + self._update_split_threshold_visual() self._update_axes() self.canvas.update() @@ -281,6 +331,17 @@ def callback(): self.actions.add(self.next_amplitudes_type, set_busy=True) self.actions.add(self.previous_amplitudes_type, set_busy=True) + self.actions.add(self.clear_amplitude_split_threshold, show_shortcut=False) + + @connect(event='lasso_updated', sender=self.canvas) + def on_lasso_updated(sender, polygon): + if len(polygon): + self.clear_amplitude_split_threshold() + + @connect(event='close_view', sender=self) + def on_close_view(view, gui): + unconnect(on_lasso_updated) + unconnect(on_close_view) @property def status(self): @@ -292,23 +353,138 @@ def amplitudes_type(self): @amplitudes_type.setter def amplitudes_type(self, value): + if hasattr(self, 'split_threshold') and value != self.amplitudes_types.current: + self.clear_amplitude_split_threshold() self.amplitudes_types.set(value) def next_amplitudes_type(self): """Switch to the next amplitudes type.""" + self.clear_amplitude_split_threshold() self.amplitudes_types.next() logger.debug('Switch to amplitudes type: %s.', self.amplitudes_types.current) self.plot() def previous_amplitudes_type(self): """Switch to the previous amplitudes type.""" + self.clear_amplitude_split_threshold() self.amplitudes_types.previous() logger.debug('Switch to amplitudes type: %s.', self.amplitudes_types.current) self.plot() def on_mouse_click(self, e): """Select a time from the amplitude view to display in the trace view.""" - if 'Alt' in e.modifiers: + if 'Control' in e.modifiers and e.button == 'Right': + self.clear_split_selection() + elif 'Alt' in e.modifiers and e.button == 'Left': mouse_pos = self.canvas.panzoom.window_to_ndc(e.pos) time = Range(NDC, self.data_bounds).apply(mouse_pos)[0][0] emit('select_time', self, time) + + def _can_set_split_threshold(self): + eligible = len(self.cluster_ids) == 1 + if eligible and self._split_is_eligible is not None: + eligible = bool(self._split_is_eligible()) + if not eligible: + self._show_split_status( + 'Amplitude threshold splitting requires exactly one selected cluster ' + 'and inactive Merge mode.' + ) + return eligible + + def _show_split_status(self, message): + logger.warning(message) + if hasattr(self, 'dock'): + self.dock.set_status(message) + + def _threshold_from_window_pos(self, pos): + mouse_pos = self.canvas.panzoom.window_to_ndc(pos) + return float(Range(NDC, self.data_bounds).apply(mouse_pos)[0][1]) + + def _set_split_threshold_from_pos(self, pos): + self.split_threshold = self._threshold_from_window_pos(pos) + self.activate_split_selection() + self._replot_displayed_amplitudes() + emit( + 'amplitude_split_preview_changed', + self, + cluster_id=self.cluster_ids[0], + amplitudes_type=self.amplitudes_type, + threshold=self.split_threshold, + ) + + def on_mouse_press(self, e): + if e.button != 'Right' or 'Alt' not in e.modifiers or not self._can_set_split_threshold(): + return + self.canvas.lasso.clear() + self._split_threshold_dragging = True + self._set_split_threshold_from_pos(e.pos) + + def on_mouse_move(self, e): + if not self._split_threshold_dragging: + return + if e.button != 'Right' or 'Alt' not in (e.mouse_press_modifiers or ()): + return + self._set_split_threshold_from_pos(e.pos) + + def on_mouse_release(self, e): + if not self._split_threshold_dragging: + return + self._split_threshold_dragging = False + if e.button == 'Right' and 'Alt' in e.modifiers: + self._set_split_threshold_from_pos(e.pos) + + def clear_amplitude_split_threshold(self): + """Clear the amplitude split threshold.""" + if self.split_threshold is None: + return + cluster_id = self.cluster_ids[0] if len(self.cluster_ids) == 1 else None + self.split_threshold = None + self._split_threshold_dragging = False + self._replot_displayed_amplitudes() + emit( + 'amplitude_split_preview_changed', + self, + cluster_id=cluster_id, + amplitudes_type=self.amplitudes_type, + threshold=None, + ) + + def clear_split_selection(self): + super().clear_split_selection() + self.clear_amplitude_split_threshold() + + def on_select(self, cluster_ids=None, **kwargs): + self.clear_amplitude_split_threshold() + super().on_select(cluster_ids=cluster_ids, **kwargs) + + def on_cluster(self, up): + self.clear_amplitude_split_threshold() + + def on_request_split(self, sender=None): + if self.split_threshold is None: + return super().on_request_split(sender=sender) + if len(self.cluster_ids) != 1: + return np.array([], dtype=np.int64) + + bunchs = self.get_clusters_data(load_all=True) or () + if len(bunchs) != 1: + self._show_split_status('Amplitude threshold split has no eligible spikes.') + return np.array([], dtype=np.int64) + bunch = bunchs[0] + spike_ids = _as_array(bunch.spike_ids) + amplitudes = _as_array(bunch.amplitudes) + selected = np.isfinite(amplitudes) & (amplitudes < self.split_threshold) + n_selected = int(selected.sum()) + if n_selected == 0: + self._show_split_status( + 'Amplitude threshold split rejected: no spikes are below the threshold.' + ) + return np.array([], dtype=np.int64) + if n_selected == len(spike_ids): + self._show_split_status( + 'Amplitude threshold split rejected: all spikes are below the threshold.' + ) + return np.array([], dtype=np.int64) + out = np.unique(spike_ids[selected]).astype(np.int64, copy=False) + self.clear_split_selection() + return out diff --git a/phy/cluster/views/tests/test_amplitude.py b/phy/cluster/views/tests/test_amplitude.py index aaf2ea6a..e34839fe 100644 --- a/phy/cluster/views/tests/test_amplitude.py +++ b/phy/cluster/views/tests/test_amplitude.py @@ -8,7 +8,9 @@ from phylib.io.mock import artificial_spike_samples from phylib.utils import Bunch, connect -from phy.plot.tests import mouse_click +from phy.plot import NDC +from phy.plot.tests import mouse_click, mouse_drag +from phy.plot.transform import Range from ..amplitude import AmplitudeView from . import _stop_and_close @@ -125,3 +127,132 @@ def on_select_time(sender, time): _stop_and_close(qtbot, v) finally: np.random.set_state(random_state) + + +def test_amplitude_threshold_gesture_and_preview(qtbot, gui): + background_color = (0.5, 0.5, 0.5, 0.5) + + def amplitudes(cluster_ids, load_all=False): + out = [] + for cluster_id in cluster_ids: + if cluster_id is None: + out.append(Bunch(amplitudes=[0.5], spike_ids=[90], spike_times=[0.25])) + else: + out.append( + Bunch(amplitudes=[1.0, 3.0], spike_ids=[10, 11], spike_times=[0.4, 0.6]) + ) + return out + + v = AmplitudeView(amplitudes=amplitudes, duration=1.0) + with qtbot.waitExposed(v.canvas): + v.show() + v.attach(gui) + v.on_select(cluster_ids=[0]) + + selected_times = [] + + @connect(sender=v) + def on_select_time(sender, time): + selected_times.append(time) + + w, h = v.canvas.get_size() + v.canvas.panzoom.zoom = (1.2, 1.5) + v.canvas.panzoom.pan = (0.1, -0.2) + target = np.array([[0.5, 1.5]]) + ndc = Range(v.data_bounds, NDC).apply(target)[0] + screen_ndc = (ndc + np.asarray(v.canvas.panzoom.pan)) * v.canvas.panzoom._zoom_aspect() + pixel = ((screen_ndc[0] + 1) * w / 2, (1 - screen_ndc[1]) * h / 2) + assert np.isclose(v._threshold_from_window_pos(pixel), target[0, 1]) + + mouse_drag( + qtbot, + v.canvas, + (w / 2, h * 0.75), + (w / 2, h * 0.5), + button='right', + modifiers=('Alt',), + ) + assert v.split_threshold is not None + assert selected_times == [] + assert v.split_threshold_visual._hidden is False + + v.split_threshold = 2.0 + v._replot_displayed_amplitudes() + colors = v.visual._acc.color + assert np.allclose(colors[0], background_color) + assert np.any(np.all(np.isclose(colors[1:], v.split_preview_color), axis=1)) + + mouse_click(qtbot, v.canvas, (w / 2, h / 2), button='left', modifiers=('Alt',)) + assert len(selected_times) == 1 + + mouse_click(qtbot, v.canvas, (w / 3, h / 3), button='left', modifiers=('Control',)) + assert v.canvas.lasso.count == 1 + assert v.split_threshold is None + v.split_threshold = 2.0 + v._replot_displayed_amplitudes() + mouse_click(qtbot, v.canvas, (w / 2, h / 2), button='right', modifiers=('Control',)) + assert v.split_threshold is None + assert v.canvas.lasso.count == 0 + + _stop_and_close(qtbot, v) + + +def test_amplitude_threshold_exact_split_is_strict_and_finite(qtbot): + calls = [] + + def amplitudes(cluster_ids, load_all=False): + calls.append(load_all) + if load_all: + return [ + Bunch( + amplitudes=np.array([1.0, 2.0, np.nan, 3.0]), + spike_ids=np.array([10, 11, 12, 13]), + spike_times=np.arange(4.0), + ) + ] + return [ + Bunch(amplitudes=[0.0], spike_ids=[99], spike_times=[0.0]), + Bunch(amplitudes=[1.0, 3.0], spike_ids=[10, 13], spike_times=[0.0, 3.0]), + ] + + v = AmplitudeView(amplitudes=amplitudes, duration=4.0) + v.on_select(cluster_ids=[0]) + v.split_threshold = 2.0 + spike_ids = v.on_request_split() + + assert calls.count(True) == 1 + assert spike_ids.dtype == np.int64 + assert np.array_equal(spike_ids, [10]) + assert v.split_threshold is None + v.close() + + +def test_amplitude_threshold_rejects_empty_whole_and_invalid_activation(qtbot): + exact_amplitudes = np.array([1.0, 2.0, 3.0]) + + def amplitudes(cluster_ids, load_all=False): + if load_all: + return [ + Bunch( + amplitudes=exact_amplitudes, + spike_ids=np.array([0, 1, 2]), + spike_times=np.arange(3.0), + ) + ] + return [Bunch(amplitudes=[0.0], spike_ids=[9], spike_times=[0.0]) for _ in cluster_ids] + + v = AmplitudeView(amplitudes=amplitudes, split_is_eligible=lambda: False) + v.on_select(cluster_ids=[0]) + assert not v._can_set_split_threshold() + + v.split_threshold = 0.0 + assert v.on_request_split().size == 0 + assert v.split_threshold == 0.0 + v.split_threshold = 4.0 + assert v.on_request_split().size == 0 + assert v.split_threshold == 4.0 + + v.on_select(cluster_ids=[0, 1]) + assert v.split_threshold is None + assert not v._can_set_split_threshold() + v.close() From 6d654e3a3fa5d010bfbf489293b2fdfc174f7a89 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:05:23 +0200 Subject: [PATCH 065/110] feat: link amplitude split previews to waveforms --- phy/apps/base.py | 121 +++++++++++++++--- phy/apps/tests/test_base.py | 156 +++++++++++++++++++++++ phy/cluster/views/tests/test_waveform.py | 60 +++++++++ phy/cluster/views/waveform.py | 107 +++++++++++++--- 4 files changed, 409 insertions(+), 35 deletions(-) diff --git a/phy/apps/base.py b/phy/apps/base.py index 5849d723..f31795f7 100644 --- a/phy/apps/base.py +++ b/phy/apps/base.py @@ -354,6 +354,10 @@ def _get_waveforms_with_n_spikes(self, cluster_id, n_spikes_waveforms, current_f data = self.raw_data_filter.apply(data, axis=1) return Bunch( data=data, + # Keep the identity of individual waveform traces. This is used + # for transient cross-view highlighting; it is deliberately not + # propagated to aggregate waveform providers. + spike_ids=spike_ids, channel_ids=channel_ids, channel_labels=channel_labels, channel_positions=pos[channel_ids], @@ -389,6 +393,9 @@ def _get_mean_waveforms(self, cluster_id, current_filter=None): b = self._get_waveforms(cluster_id) if b.data is not None: b.data = b.data.mean(axis=0)[np.newaxis, ...] + # The mean is one trace synthesized from many spikes, so it has no + # one-to-one spike identity. + b.pop('spike_ids', None) b['alpha'] = 1.0 return b @@ -406,6 +413,68 @@ def create_waveform_view(self): return view = WaveformView(waveforms_dict, sample_rate=self.model.sample_rate) view.ex_status = self.raw_data_filter.current + preview_amplitude_cache = {} + + def clear_amplitude_split_preview(): + preview_amplitude_cache.clear() + view.set_highlighted_spike_ids() + + def update_amplitude_split_preview(sender, cluster_id, amplitudes_type, threshold): + """Classify only the waveform traces currently on screen.""" + if getattr(sender, '_controller', None) is not self: + return + if ( + threshold is None + or view._closed + or view.waveforms_type != 'waveforms' + or list(view.cluster_ids) != [cluster_id] + or not view._displayed_bunchs + ): + clear_amplitude_split_preview() + return + bunch = view._displayed_bunchs[0] + spike_ids = bunch.get('spike_ids', None) + if spike_ids is None: + clear_amplitude_split_preview() + return + spike_ids = np.asarray(spike_ids, dtype=np.int64) + key = ( + cluster_id, + amplitudes_type, + tuple(self.get_best_channels(cluster_id)), + self.selection.get('channel_id', None), + self.selection.get('feature_pc', None), + self.raw_data_filter.current, + tuple(spike_ids), + ) + amplitudes = preview_amplitude_cache.get(key) + if amplitudes is None: + amplitudes = self._resolve_spike_amplitudes(spike_ids, amplitudes_type, cluster_id) + if amplitudes is None: + clear_amplitude_split_preview() + return + preview_amplitude_cache.clear() + preview_amplitude_cache[key] = amplitudes + highlighted = spike_ids[np.isfinite(amplitudes) & (amplitudes < threshold)] + view.set_highlighted_spike_ids(highlighted, color=sender.split_preview_color) + + @connect(event='amplitude_split_preview_changed') + def on_amplitude_split_preview_changed(sender, cluster_id, amplitudes_type, threshold): + update_amplitude_split_preview(sender, cluster_id, amplitudes_type, threshold) + + @connect(sender=self.supervisor) + def on_select(sender, cluster_ids, **kwargs): + # A new waveform selection has different displayed identities and + # must not reuse the previous preview cache. + clear_amplitude_split_preview() + + @connect + def on_selected_channel_changed(sender): + clear_amplitude_split_preview() + + @connect + def on_selected_feature_changed(sender): + clear_amplitude_split_preview() @connect(sender=view) def on_select_channel(sender, channel_id=None, key=None, button=None): @@ -457,6 +526,10 @@ def edit_view_settings(): def on_close_view(view_, gui): unconnect(on_select_channel) unconnect(on_view_attached) + unconnect(on_amplitude_split_preview_changed) + unconnect(on_select) + unconnect(on_selected_channel_changed) + unconnect(on_selected_feature_changed) return view @@ -507,6 +580,7 @@ def create_amplitude_view(self): @connect def on_selected_feature_changed(sender): # Replot the amplitude view with the selected feature. + view.clear_amplitude_split_threshold() view.amplitudes_type = 'feature' view.plot() @@ -1679,8 +1753,6 @@ def _amplitude_getter(self, cluster_ids, name=None, load_all=False): # Remove selected clusters from other_clusters to prevent them from being included # in both the grey dots and grey histogram other_clusters = [e for e in other_clusters if e not in cluster_ids] - # Get the amplitude method. - f = self._get_amplitude_functions()[name] # Take spikes from the waveform selection if we're loading the raw amplitudes, # or by minimizing the number of chunks to load if fetching waveforms directly # from the raw data. @@ -1715,19 +1787,7 @@ def _amplitude_getter(self, cluster_ids, name=None, load_all=False): ) # Get the spike times. spike_times = self._get_spike_times_reordered(spike_ids) - if name in ('feature', 'raw'): - # Retrieve the feature PC selected in the feature view - # or the channel selected in the waveform view. - channel_id = self.selection.get('channel_id', channel_id) - pc = self.selection.get('feature_pc', None) - # Call the spike amplitude getter function. - amplitudes = f( - spike_ids, - channel_ids=channel_ids, - channel_id=channel_id, - pc=pc, - first_cluster=first_cluster, - ) + amplitudes = self._resolve_spike_amplitudes(spike_ids, name, first_cluster) if amplitudes is None: continue assert amplitudes.shape == spike_ids.shape == spike_times.shape @@ -1740,6 +1800,21 @@ def _amplitude_getter(self, cluster_ids, name=None, load_all=False): ) return out + def _resolve_spike_amplitudes(self, spike_ids, name, first_cluster): + """Evaluate one amplitude type in the canonical amplitude-view context.""" + spike_ids = np.asarray(spike_ids, dtype=np.int64) + channel_ids = self.get_best_channels(first_cluster) + channel_id = channel_ids[0] + if name in ('feature', 'raw'): + channel_id = self.selection.get('channel_id', channel_id) + return self._get_amplitude_functions()[name]( + spike_ids, + channel_ids=channel_ids, + channel_id=channel_id, + pc=self.selection.get('feature_pc', None), + first_cluster=first_cluster, + ) + def create_amplitude_view(self): """Create the amplitude view.""" amplitudes_dict = { @@ -1752,11 +1827,18 @@ def create_amplitude_view(self): # or they're loaded from a small part of the dataset which is not very useful. if len(amplitudes_dict) > 1 and 'raw' in amplitudes_dict: del amplitudes_dict['raw'] + + def split_is_eligible(): + state = self.supervisor.selection.state + return len(self.supervisor.selected) == 1 and not state.is_merge_mode + view = AmplitudeView( amplitudes=amplitudes_dict, amplitudes_type=None, # TODO: GUI state duration=self.model.duration, + split_is_eligible=split_is_eligible, ) + view._controller = self @connect def on_toggle_spike_reorder(sender, do_reorder): @@ -1770,6 +1852,7 @@ def on_selected_channel_changed(sender): # Do nothing if the displayed amplitude does not depend on the channel. if view.amplitudes_type not in ('feature', 'raw'): return + view.clear_amplitude_split_threshold() # Otherwise, replot the amplitude view, which will use # Selection.selected_channel_id to use the requested channel in the computation of # the amplitudes. @@ -1782,6 +1865,12 @@ def on_select(sender, cluster_ids, update_views=True): if update_views and view.amplitudes_type == 'raw' and len(cluster_ids): # Update the channel used in the amplitude when the cluster selection changes. self.selection.channel_id = self.get_best_channel(cluster_ids[0]) + if not split_is_eligible(): + view.clear_amplitude_split_threshold() + + @connect(sender=self.supervisor) + def on_cluster(sender, up): + view.clear_amplitude_split_threshold() @connect def on_time_range_selected(sender, interval): @@ -1818,9 +1907,11 @@ def edit_view_settings(): @connect(sender=view) def on_close_view(view_, gui): + view.clear_amplitude_split_threshold() unconnect(on_toggle_spike_reorder) unconnect(on_selected_channel_changed) unconnect(on_select) + unconnect(on_cluster) unconnect(on_time_range_selected) unconnect(on_view_attached) diff --git a/phy/apps/tests/test_base.py b/phy/apps/tests/test_base.py index 325c8609..6d728b9e 100644 --- a/phy/apps/tests/test_base.py +++ b/phy/apps/tests/test_base.py @@ -433,9 +433,165 @@ def capture_waveforms(spike_ids, channel_ids): eligible = subset_spikes[controller.supervisor.clustering.spike_clusters[subset_spikes] == 0] expected_indices = [0, (len(eligible) - 1) // 2, len(eligible) - 1] np.testing.assert_array_equal(selected[0], eligible[expected_indices]) + np.testing.assert_array_equal(bunch.spike_ids, selected[0]) controller.close() +def test_mean_waveforms_do_not_expose_individual_spike_ids(tempdir): + controller = _mock_controller(tempdir, MyControllerW) + bunch = controller._get_mean_waveforms(0) + assert bunch.data.shape[0] == 1 + assert 'spike_ids' not in bunch + controller.close() + + +def test_amplitude_preview_highlights_waveforms_with_cached_resolver(qtbot, tempdir): + controller = _mock_controller(tempdir, MyControllerW) + amplitude = controller.create_amplitude_view() + waveform = controller.create_waveform_view() + gui = GUI(name='AmplitudePreview', config_dir=tempdir) + amplitude.attach(gui) + waveform.attach(gui) + waveform.on_select(cluster_ids=[0]) + spike_ids = waveform._displayed_bunchs[0].spike_ids + calls = [] + + def resolve(ids, name, first_cluster): + calls.append((ids.copy(), name, first_cluster)) + return np.arange(len(ids), dtype=float) + + controller._resolve_spike_amplitudes = resolve + emit( + 'amplitude_split_preview_changed', + amplitude, + cluster_id=0, + amplitudes_type='raw', + threshold=1.5, + ) + np.testing.assert_array_equal(waveform._highlighted_spike_ids, spike_ids[:2]) + assert waveform._highlighted_spike_color == amplitude.split_preview_color + assert len(calls) == 1 + + # Moving only the threshold must reuse the amplitudes already resolved for + # the displayed waveform identities. + emit( + 'amplitude_split_preview_changed', + amplitude, + cluster_id=0, + amplitudes_type='raw', + threshold=2.5, + ) + np.testing.assert_array_equal(waveform._highlighted_spike_ids, spike_ids[:3]) + assert len(calls) == 1 + + amplitude.split_threshold = 1.5 + emit('selected_channel_changed', waveform) + assert amplitude.split_threshold is None + + emit( + 'amplitude_split_preview_changed', + amplitude, + cluster_id=1, + amplitudes_type='raw', + threshold=2.5, + ) + assert not len(waveform._highlighted_spike_ids) + + amplitude.split_threshold = 0.5 + emit( + 'amplitude_split_preview_changed', + amplitude, + cluster_id=0, + amplitudes_type='raw', + threshold=amplitude.split_threshold, + ) + assert len(waveform._highlighted_spike_ids) + amplitude.dock.close() + assert not len(waveform._highlighted_spike_ids) + waveform.dock.close() + gui.close() + controller.close() + + +def test_amplitude_threshold_split_commits_exact_partition_and_undo_redo(qtbot, tempdir): + """The sampled preview must commit the exact, all-spike threshold partition.""" + controller = _mock_controller(tempdir, MyControllerFull) + controller.n_spikes_amplitudes = 3 + controller.n_spikes_waveforms = 8 + supervisor = controller.supervisor + cluster_id = 0 + original_clusters = controller.model.spike_clusters.copy() + cluster_spike_ids = np.flatnonzero(original_clusters == cluster_id) + # The sparse display samples cannot be authoritative: this deterministic + # pattern gives both sides of the threshold throughout the cluster. + controller.model.amplitudes = np.full(controller.model.n_spikes, 3.0) + controller.model.amplitudes[cluster_spike_ids] = np.arange(len(cluster_spike_ids)) % 4 + expected = cluster_spike_ids[controller.model.amplitudes[cluster_spike_ids] < 1.5] + remaining = np.setdiff1d(cluster_spike_ids, expected) + gui = controller.create_gui(do_prompt_save=False) + try: + with qtbot.waitExposed(gui): + gui.show() + supervisor.select([cluster_id]) + supervisor.block() + amplitude = gui.list_views(AmplitudeView)[0] + waveform = gui.list_views(WaveformView)[0] + amplitude.amplitudes_type = 'template' + amplitude.plot() + waveform.waveforms_type = 'waveforms' + waveform.plot() + + displayed_amplitudes = next( + bunch for bunch in amplitude._displayed_bunchs if bunch.cluster_id == cluster_id + ) + waveform_spike_ids = waveform._displayed_bunchs[0].spike_ids + missing_from_amplitude = np.setdiff1d(waveform_spike_ids, displayed_amplitudes.spike_ids) + assert len(missing_from_amplitude) + assert np.any(np.isin(expected, missing_from_amplitude)) + + # Set the transient threshold through the view-level preview contract; + # waveform classification resolves its own displayed spike identities. + amplitude.split_threshold = 1.5 + amplitude._replot_displayed_amplitudes() + emit( + 'amplitude_split_preview_changed', + amplitude, + cluster_id=cluster_id, + amplitudes_type=amplitude.amplitudes_type, + threshold=amplitude.split_threshold, + ) + expected_waveform = waveform_spike_ids[ + controller.model.amplitudes[waveform_spike_ids] < amplitude.split_threshold + ] + np.testing.assert_array_equal(waveform._highlighted_spike_ids, expected_waveform) + + # This is the same request_split path invoked by the K shortcut. + supervisor.actions.split() + supervisor.block() + after_split = controller.model.spike_clusters.copy() + split_cluster = after_split[expected] + remaining_cluster = after_split[remaining] + assert len(np.unique(split_cluster)) == len(np.unique(remaining_cluster)) == 1 + assert split_cluster[0] != remaining_cluster[0] + assert not np.any(after_split[cluster_spike_ids] == cluster_id) + assert amplitude.split_threshold is None + assert not len(waveform._highlighted_spike_ids) + + supervisor.actions.undo() + supervisor.block() + np.testing.assert_array_equal(controller.model.spike_clusters, original_clusters) + assert amplitude.split_threshold is None + + supervisor.actions.redo() + supervisor.block() + redone = controller.model.spike_clusters + np.testing.assert_array_equal(redone[expected], split_cluster) + np.testing.assert_array_equal(redone[remaining], remaining_cluster) + finally: + gui.close() + controller.close() + + def test_waveform_selected_clusters_share_total_budget(tempdir): controller = _mock_controller(tempdir, MyControllerW) controller.n_spikes_waveforms = 100 diff --git a/phy/cluster/views/tests/test_waveform.py b/phy/cluster/views/tests/test_waveform.py index 90335fc6..ddf7e4f3 100644 --- a/phy/cluster/views/tests/test_waveform.py +++ b/phy/cluster/views/tests/test_waveform.py @@ -113,3 +113,63 @@ def on_select_channel(sender, channel_id=None, button=None, key=None): v.set_state(v.state) _stop_and_close(qtbot, v) + + +def test_waveform_view_highlights_displayed_spike_ids_without_reloading(qtbot, gui): + n_spikes, n_channels, n_samples = 3, 2, 10 + calls = [] + + def get_waveforms(cluster_id): + calls.append(cluster_id) + return Bunch( + data=artificial_waveforms(n_spikes, n_samples, n_channels), + spike_ids=np.array([10, 11, 12]), + channel_ids=np.arange(n_channels), + channel_positions=staggered_positions(n_channels), + ) + + v = WaveformView( + waveforms={'waveforms': get_waveforms, 'mean_waveforms': get_waveforms}, + sample_rate=10000.0, + ) + with qtbot.waitExposed(v.canvas): + v.show() + v.attach(gui) + v.on_select(cluster_ids=[0]) + assert calls == [0] + + base_color = np.asarray(v._displayed_bunchs[0].base_color) + v.set_highlighted_spike_ids([11, 99]) + assert calls == [0] + colors = v._displayed_bunchs[0].color + assert colors.shape == (n_spikes * n_channels, 4) + np.testing.assert_array_equal(colors[:n_channels], np.tile(base_color, (n_channels, 1))) + np.testing.assert_array_equal( + colors[n_channels : 2 * n_channels], + np.tile(v.highlighted_spike_color, (n_channels, 1)), + ) + np.testing.assert_array_equal(colors[2 * n_channels :], np.tile(base_color, (n_channels, 1))) + + v.toggle_mean_waveforms(True) + assert not len(v._highlighted_spike_ids) + _stop_and_close(qtbot, v) + + +def test_waveform_view_ignores_highlights_without_spike_identity(qtbot, gui): + n_channels = 2 + + def get_waveforms(cluster_id): + return Bunch( + data=artificial_waveforms(2, 10, n_channels), + channel_ids=np.arange(n_channels), + channel_positions=staggered_positions(n_channels), + ) + + v = WaveformView(waveforms=get_waveforms, sample_rate=10000.0) + with qtbot.waitExposed(v.canvas): + v.show() + v.attach(gui) + v.on_select(cluster_ids=[0]) + v.set_highlighted_spike_ids([10]) + assert v._displayed_bunchs[0].color == v._displayed_bunchs[0].base_color + _stop_and_close(qtbot, v) diff --git a/phy/cluster/views/waveform.py b/phy/cluster/views/waveform.py index 005544d9..df3bcefc 100644 --- a/phy/cluster/views/waveform.py +++ b/phy/cluster/views/waveform.py @@ -100,6 +100,7 @@ class WaveformView(ScalingMixin, ManualClusteringView): _default_position = 'right' ax_color = (0.75, 0.75, 0.75, 1.0) + highlighted_spike_color = (1.0, 1.0, 0.0, 1.0) tick_size = 5.0 cluster_ids = () @@ -134,6 +135,9 @@ def __init__(self, waveforms=None, waveforms_type=None, sample_rate=None, **kwar self.data_bounds = None self.sample_rate = sample_rate self._status_suffix = '' + self._displayed_bunchs = () + self._highlighted_spike_ids = np.array([], dtype=np.int64) + self._highlighted_spike_color = self.highlighted_spike_color assert sample_rate > 0.0, 'The sample rate must be provided to the waveform view.' # Initialize the view. @@ -205,8 +209,73 @@ def get_clusters_data(self): bunch.offset = offset bunch.n_clu = n_clu bunch.color = selected_cluster_color(color_index, bunch.get('alpha', 0.75)) + bunch.base_color = bunch.color return bunchs + def _update_highlight_colors(self): + """Apply transient per-spike colors to the already displayed bunches.""" + for bunch in self._displayed_bunchs: + color = bunch.base_color + spike_ids = bunch.get('spike_ids', None) + if ( + self.waveforms_type == 'waveforms' + and spike_ids is not None + and bunch.data is not None + and len(spike_ids) == len(bunch.data) + and len(self._highlighted_spike_ids) + ): + colors = np.tile(color, (len(spike_ids), 1)) + colors[np.isin(spike_ids, self._highlighted_spike_ids)] = ( + self._highlighted_spike_color + ) + # `_plot_cluster()` flattens waveforms in spike-major, + # channel-major order after its transpose. + color = np.repeat(colors, len(bunch.channel_ids), axis=0) + bunch.color = color + + def set_highlighted_spike_ids(self, spike_ids=None, color=None): + """Highlight displayed individual waveform traces by spike id. + + This rerenders the cached displayed data only; it never invokes a + waveform provider. Providers without per-trace ``spike_ids`` simply + retain their ordinary cluster color. + """ + if self.waveforms_type != 'waveforms' or spike_ids is None: + spike_ids = () + self._highlighted_spike_color = color or self.highlighted_spike_color + self._highlighted_spike_ids = np.unique(np.asarray(spike_ids, dtype=np.int64)) + if self._displayed_bunchs: + self._update_highlight_colors() + self._render_bunchs() + + def _render_bunchs(self): + """Render cached waveform bunches without reloading their providers.""" + bunchs = self._displayed_bunchs + if not bunchs: + return + + self._current_visual.reset_batch() + self.line_visual.reset_batch() + self.tick_visual.reset_batch() + for bunch in bunchs: + self._plot_cluster(bunch) + self.canvas.update_visual(self.tick_visual) + self.canvas.update_visual(self.line_visual) + self.canvas.update_visual(self._current_visual) + + self._plot_labels(self.channel_ids, len(self.cluster_ids), self._channel_labels) + + # Only show the current waveform visual. + if self._current_visual == self.waveform_visual: + self.waveform_visual.show() + self.waveform_agg_visual.hide() + elif self._current_visual == self.waveform_agg_visual: + self.waveform_agg_visual.show() + self.waveform_visual.hide() + + self.canvas.update() + self.update_status() + def _plot_cluster(self, bunch): wave = bunch.data if wave is None or not wave.size: @@ -335,6 +404,9 @@ def plot(self, **kwargs): bunchs = self.get_clusters_data() if not bunchs: return + self._displayed_bunchs = bunchs + if self.waveforms_type != 'waveforms': + self._highlighted_spike_ids = np.array([], dtype=np.int64) # All channel ids appearing in all selected clusters. channel_ids = sorted(set(_flatten([d.channel_ids for d in bunchs]))) @@ -351,6 +423,7 @@ def plot(self, **kwargs): channel_labels.update( {channel_id: chl[i] for i, channel_id in enumerate(d.channel_ids)} ) + self._channel_labels = channel_labels # Update the Boxed box positions as a function of the selected channels. if channel_ids: @@ -358,27 +431,14 @@ def plot(self, **kwargs): self.data_bounds = self.data_bounds or self._get_data_bounds(bunchs) - self._current_visual.reset_batch() - self.line_visual.reset_batch() - self.tick_visual.reset_batch() - for bunch in bunchs: - self._plot_cluster(bunch) - self.canvas.update_visual(self.tick_visual) - self.canvas.update_visual(self.line_visual) - self.canvas.update_visual(self._current_visual) - - self._plot_labels(channel_ids, len(self.cluster_ids), channel_labels) + self._update_highlight_colors() + self._render_bunchs() - # Only show the current waveform visual. - if self._current_visual == self.waveform_visual: - self.waveform_visual.show() - self.waveform_agg_visual.hide() - elif self._current_visual == self.waveform_agg_visual: - self.waveform_agg_visual.show() - self.waveform_visual.hide() - - self.canvas.update() - self.update_status() + def on_select(self, sender=None, cluster_ids=None, **kwargs): + """Clear transient highlighting when the displayed selection changes.""" + self._highlighted_spike_ids = np.array([], dtype=np.int64) + self._displayed_bunchs = () + super().on_select(cluster_ids=cluster_ids, **kwargs) def attach(self, gui): """Attach the view to the GUI.""" @@ -513,16 +573,22 @@ def waveforms_type(self): @waveforms_type.setter def waveforms_type(self, value): self.waveforms_types.set(value) + if value != 'waveforms' and hasattr(self, '_highlighted_spike_ids'): + self._highlighted_spike_ids = np.array([], dtype=np.int64) def next_waveforms_type(self): """Switch to the next waveforms type.""" self.waveforms_types.next() + if self.waveforms_type != 'waveforms': + self._highlighted_spike_ids = np.array([], dtype=np.int64) logger.debug('Switch to waveforms type %s.', self.waveforms_type) self.plot() def previous_waveforms_type(self): """Switch to the previous waveforms type.""" self.waveforms_types.previous() + if self.waveforms_type != 'waveforms': + self._highlighted_spike_ids = np.array([], dtype=np.int64) logger.debug('Switch to waveforms type %s.', self.waveforms_type) self.plot() @@ -534,5 +600,6 @@ def toggle_mean_waveforms(self, checked): self.plot() elif 'mean_waveforms' in self.waveforms: self.waveforms_types.set('mean_waveforms') + self._highlighted_spike_ids = np.array([], dtype=np.int64) logger.debug('Switch to mean waveforms.') self.plot() From d8af2377a4868ecd9647df9d582b09fdc5618971 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:05:28 +0200 Subject: [PATCH 066/110] docs: cover amplitude threshold splitting --- docs/api.md | 116 +++++++++++++++++++++++++++++++++++++++++- docs/changelog.md | 5 ++ docs/clustering.md | 8 +++ docs/visualization.md | 17 ++++++- 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/docs/api.md b/docs/api.md index 95990f00..0bb0af94 100644 --- a/docs/api.md +++ b/docs/api.md @@ -5796,6 +5796,15 @@ This view displays an amplitude plot for all selected clusters. --- +#### AmplitudeView.activate_split_selection + + +**`AmplitudeView.activate_split_selection(self)`** + +Make this view's transient split selection the active built-in one. + +--- + #### AmplitudeView.attach @@ -5805,6 +5814,27 @@ Attach the view to the GUI. --- +#### AmplitudeView.clear_amplitude_split_threshold + + +**`AmplitudeView.clear_amplitude_split_threshold(self)`** + +Clear the amplitude split threshold. + +--- + +#### AmplitudeView.clear_split_selection + + +**`AmplitudeView.clear_split_selection(self)`** + +Clear this view's transient split selection. + +Views with another kind of split preview can override this hook, call +``super()``, and clear their own preview state as well. + +--- + #### AmplitudeView.close @@ -5881,6 +5911,33 @@ selected clusters (template view, raster view). Select a time from the amplitude view to display in the trace view. +--- + +#### AmplitudeView.on_mouse_move + + +**`AmplitudeView.on_mouse_move(self, e)`** + + + +--- + +#### AmplitudeView.on_mouse_press + + +**`AmplitudeView.on_mouse_press(self, e)`** + + + +--- + +#### AmplitudeView.on_mouse_release + + +**`AmplitudeView.on_mouse_release(self, e)`** + + + --- #### AmplitudeView.on_mouse_wheel @@ -7557,6 +7614,15 @@ component features. This view keeps track of which channels are currently shown. --- +#### FeatureView.activate_split_selection + + +**`FeatureView.activate_split_selection(self)`** + +Make this view's transient split selection the active built-in one. + +--- + #### FeatureView.attach @@ -7575,6 +7641,18 @@ Reset the current channels. --- +#### FeatureView.clear_split_selection + + +**`FeatureView.clear_split_selection(self)`** + +Clear this view's transient split selection. + +Views with another kind of split preview can override this hook, call +``super()``, and clear their own preview state as well. + +--- + #### FeatureView.close @@ -9280,6 +9358,15 @@ This view displays a scatter plot for all selected clusters. --- +#### ScatterView.activate_split_selection + + +**`ScatterView.activate_split_selection(self)`** + +Make this view's transient split selection the active built-in one. + +--- + #### ScatterView.attach @@ -9287,6 +9374,18 @@ This view displays a scatter plot for all selected clusters. +--- + +#### ScatterView.clear_split_selection + + +**`ScatterView.clear_split_selection(self)`** + +Clear this view's transient split selection. + +Views with another kind of split preview can override this hook, call +``super()``, and clear their own preview state as well. + --- #### ScatterView.close @@ -11868,9 +11967,9 @@ Change the scaling with the wheel. #### WaveformView.on_select -**`WaveformView.on_select(self, cluster_ids=None, **kwargs)`** +**`WaveformView.on_select(self, sender=None, cluster_ids=None, **kwargs)`** -Callback function when clusters are selected. May be overridden. +Clear transient highlighting when the displayed selection changes. --- @@ -11920,6 +12019,19 @@ are saved in `~/.phy/screenshots/`. --- +#### WaveformView.set_highlighted_spike_ids + + +**`WaveformView.set_highlighted_spike_ids(self, spike_ids=None, color=None)`** + +Highlight displayed individual waveform traces by spike id. + +This rerenders the cached displayed data only; it never invokes a +waveform provider. Providers without per-trace ``spike_ids`` simply +retain their ordinary cluster color. + +--- + #### WaveformView.set_state diff --git a/docs/changelog.md b/docs/changelog.md index 4f2f580c..54b10dd1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -18,6 +18,11 @@ behavior they verify rather than listed separately. ### Added +- Split the lower-amplitude portion of one selected cluster directly from the + Amplitude View: use `Alt`-right-drag to preview a threshold, then press `K` + to commit an exact all-spike split. Individual waveform traces receive the + same transient preview; **Control+right-click** or **View > Clear amplitude + split threshold** clears it. - Display elapsed recording time in seconds, minutes, or hours in Amplitude and Firing Rate views. Choose the shared preference from **View > Recording time unit** and see open views update immediately. Axis labels use thousands diff --git a/docs/clustering.md b/docs/clustering.md index 1221c1d6..1d7046d1 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -51,6 +51,14 @@ Remaining clusters, i.e. spikes outside the polygon, are also assigned to new cl Note: if not all spikes are displayed (there is a limit to the number of spikes displayed in each view), then all spikes are loaded before computing which spikes belong to the drawn polygon. +When exactly one cluster is selected in Normal mode, the Amplitude View also +supports a threshold split. Hold **Alt** and right-drag to place a horizontal +threshold, then press `K` to split the finite-amplitude spikes strictly below +it. The preview is sampled for responsiveness, but the committed split is +evaluated over every eligible spike in the cluster. Use +**Control+right-click** or **View > Clear amplitude split threshold** to clear +the threshold; an empty or whole-cluster threshold is not committed. + ## Wizard diff --git a/docs/visualization.md b/docs/visualization.md index b907333c..62c78ce9 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -471,13 +471,28 @@ effect immediately and are saved as global controller preferences. This view supports splitting like in the feature view. When splitting, all spikes (and not just displayed spikes) are loaded before computing the spikes that belong to the lasso polygon. +With exactly one cluster selected in Normal mode, you can also split by amplitude: +hold **Alt** and right-drag to place a horizontal threshold. Spikes with a +finite amplitude strictly below the threshold are highlighted in the Amplitude +View and, when individual spike waveforms are displayed, in the Waveform View. +Spikes exactly on the threshold remain in the upper group. Press `K` to split +the highlighted lower group. The split evaluates every eligible spike in the +cluster, not only the plotted or waveform samples, so the committed result +matches the active amplitude type and context. A threshold selecting no spikes +or the entire cluster is left in place and cannot be committed. + +The threshold remains active after the drag so you can adjust it. Use +**Control+right-click** or **View > Clear amplitude split threshold** to clear +it; either action also clears the Amplitude View lasso. Threshold previews only +color individual waveform traces: mean and template waveforms are unchanged. + #### Background spikes Extra spikes beyond those of the selected clusters are shown in gray. These spikes come from clusters whose best channels include the first selected cluster's peak channel. The gray spikes come from all clusters that have some signal on the first selected cluster's peak channel, and not necessarily those for which the best channel corresponds exactly to that channel. #### Time range -The time interval currently displayed in the trace view is shown as a vertical yellow bar. You can change the current time range with `Alt+click` in the amplitude view: that will automatically change the time range in the trace view. +The time interval currently displayed in the trace view is shown as a vertical yellow bar. You can change the current time range with `Alt+left-click` in the amplitude view: that will automatically change the time range in the trace view. #### Keyboard shortcuts From 40b99a3cbd8861ef8f9018e4ba258459881a3aa1 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 2 Aug 2026 23:57:18 +0200 Subject: [PATCH 067/110] refactor: validate authoritative selection color mapping --- phy/cluster/views/base.py | 32 ++++++++++++++++++++-------- phy/cluster/views/tests/test_base.py | 30 ++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/phy/cluster/views/base.py b/phy/cluster/views/base.py index 88f4ece1..5f948069 100644 --- a/phy/cluster/views/base.py +++ b/phy/cluster/views/base.py @@ -8,6 +8,7 @@ import gc import logging from functools import partial +from types import MappingProxyType import numpy as np from phylib.utils import Bunch, connect, emit, unconnect @@ -89,7 +90,7 @@ def __init__(self, shortcuts=None, **kwargs): self._dock_visible = True self._pending_selection = None self.cluster_ids = () - self._cluster_color_index_by_id = {} + self._cluster_color_index_by_id = MappingProxyType({}) # Load default shortcuts, and override with any user shortcuts. self.shortcuts = self.default_shortcuts.copy() @@ -161,12 +162,25 @@ def on_select(self, cluster_ids=None, **kwargs): return self.plot(**kwargs) - def _update_cluster_color_indices(self, sender): - order = getattr(sender, 'selection_color_order', ()) - if order: - self._cluster_color_index_by_id = { - cluster_id: index for index, cluster_id in enumerate(order) - } + def _update_cluster_color_indices(self, sender, cluster_ids): + """Copy and validate the authoritative selected-cluster color mapping.""" + try: + order = tuple(sender.selection_color_order) + except AttributeError: + # Standalone views and other non-authoritative senders retain the + # traditional positional colors. + self._cluster_color_index_by_id = MappingProxyType({}) + return + color_indices = MappingProxyType( + {cluster_id: index for index, cluster_id in enumerate(order)} + ) + missing_ids = set(cluster_ids).difference(color_indices) + if missing_ids: + raise ValueError( + 'Authoritative selection color mapping is missing active cluster IDs: ' + f'{sorted(missing_ids)}.' + ) + self._cluster_color_index_by_id = color_indices def cluster_color_index(self, cluster_id, fallback): """Return the stable selected-color slot for a cluster.""" @@ -182,7 +196,7 @@ def on_select_threaded(self, sender, cluster_ids, gui=None, **kwargs): assert isinstance(cluster_ids, list) if not cluster_ids: return - self._update_cluster_color_indices(sender) + self._update_cluster_color_indices(sender, cluster_ids) # Limit the number of displayed clusters for performance reasons. Keep the # selection order so that a large selection still refreshes the view rather # than leaving its previous contents on screen. @@ -521,7 +535,7 @@ def on_select(self, sender=None, cluster_ids=(), **kwargs): assert isinstance(cluster_ids, list) if not cluster_ids: return - self._update_cluster_color_indices(sender) + self._update_cluster_color_indices(sender, cluster_ids) self.cluster_ids = cluster_ids # selected clusters diff --git a/phy/cluster/views/tests/test_base.py b/phy/cluster/views/tests/test_base.py index f9528015..fc905fec 100644 --- a/phy/cluster/views/tests/test_base.py +++ b/phy/cluster/views/tests/test_base.py @@ -6,6 +6,7 @@ import numpy as np from phylib.utils import emit +from pytest import raises from phy.utils.color import colormaps, selected_cluster_color @@ -76,11 +77,14 @@ def test_manual_clustering_view_2(qtbot, gui): v.attach(gui) class Supervisor: - selection_color_order = (0, 2, 1) + selection_color_order = [0, 2, 1] - emit('select', Supervisor(), cluster_ids=[0, 1]) + sender = Supervisor() + emit('select', sender, cluster_ids=[0, 1]) assert v.cluster_color_index(0, 0) == 0 assert v.cluster_color_index(1, 1) == 2 + sender.selection_color_order[:] = (0, 1, 2) + assert v.cluster_color_index(1, 1) == 2 v.actions.get('Change color scheme to myscheme').trigger() v.next_color_scheme() @@ -115,6 +119,28 @@ def test_manual_clustering_view_menu_utility_footer(qtbot, gui): _stop_and_close(qtbot, v) +def test_authoritative_selection_colors_require_every_active_cluster(): + view = ManualClusteringView() + + class Supervisor: + selection_color_order = (1,) + + with raises(ValueError, match='missing active cluster IDs: \\[2\\]'): + view._update_cluster_color_indices(Supervisor(), [1, 2]) + + +def test_standalone_selection_uses_positional_colors(): + view = ManualClusteringView() + + class Supervisor: + selection_color_order = (1, 2) + + view._update_cluster_color_indices(Supervisor(), [1, 2]) + view._update_cluster_color_indices(object(), [1, 2]) + + assert view.cluster_color_index(2, 1) == 1 + + def test_manual_clustering_view_selection_is_limited(qtbot, gui): v = MyView() v.max_n_clusters = 2 From 378c1364cb30682d004fdbfab231c6c68fe5faf5 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:06:49 +0200 Subject: [PATCH 068/110] docs: explain stable selection ordering and colors --- docs/changelog.md | 13 ++++++++----- docs/clustering.md | 7 ++++--- docs/visualization.md | 5 ++++- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 54b10dd1..1ea709ec 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -99,11 +99,14 @@ behavior they verify rather than listed separately. - The first, blue Cluster View selection is now the explicit Similarity reference. In Normal mode, scientific views follow the selected Cluster and - Similarity rows in visible table order; re-sorting either table updates that - presentation without recoloring existing selections. In Merge mode, explicit - Merge View order takes precedence, while workflow-table colors remain fixed - for the entire session. Normal-mode cross-role mouse transfers and - cross-correlogram promotion have been removed in favor of the Merge workspace. + Similarity rows in visible table order; sorting or filtering either table + updates that presentation without recoloring existing selections. Deselecting + and reselecting a cluster reuses its color while the blue reference remains + unchanged; choosing a new reference starts a new color session. In Merge + mode, explicit Merge View order takes precedence, while workflow-table + colors remain fixed for the entire session. Normal-mode cross-role mouse + transfers and cross-correlogram promotion have been removed in favor of the + Merge workspace. - Undo and redo restore the complete selection context around merge, split, and metadata actions; redo also preserves selection-only exploration made after the original action. diff --git a/docs/clustering.md b/docs/clustering.md index 1d7046d1..ad8c6eac 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -75,9 +75,10 @@ Wizard navigation skips clusters labeled `noise` or `mua` by default. To include On macOS, this shortcut uses the Control key, not Command. If `Control+Space` is assigned to switching input sources in macOS System Settings, disable or remap that system shortcut so that phy can receive it. In Normal mode, scientific views follow the selected Cluster and Similarity rows in their visible -table order, with the blue Similarity reference first. Sorting either table updates that -presentation without recoloring existing table selections. Use Merge mode when you need to collect -or explicitly order candidates. +table order, with the blue Similarity reference first. Sorting or filtering either table updates +that presentation without recoloring existing table selections. Deselecting and reselecting a row +restores its previous color while the same blue reference is active; selecting a new reference +starts a new color sequence. Use Merge mode when you need to collect or explicitly order candidates. For each similar cluster, you can either: diff --git a/docs/visualization.md b/docs/visualization.md index 62c78ce9..7af56353 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -82,7 +82,10 @@ Select quickly one or several cluster(s) by using **snippets**: for example, typ ![image](https://user-images.githubusercontent.com/1942359/58951169-bac4a280-8790-11e9-8e7b-5fa5410de152.png) -Selected clusters are assigned with a special color: blue for the first selected cluster, red for the second, yellow for the third, etc. +Selected clusters are assigned with a special color: blue for the explicit Similarity reference, +then red, yellow, and so on for clusters encountered with that reference. Table sorting and +filtering do not change these colors, and a deselected cluster recovers its prior color when it is +reselected. Choosing a new blue reference starts a new color sequence. #### Cluster table From 613161903df65061ab280697ef1dfa3a7daa3022 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:08:54 +0200 Subject: [PATCH 069/110] test: mark imported supervisor fixture use --- phy/cluster/tests/test_merge_lifecycle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phy/cluster/tests/test_merge_lifecycle.py b/phy/cluster/tests/test_merge_lifecycle.py index ff64d0ff..d43bd8d0 100644 --- a/phy/cluster/tests/test_merge_lifecycle.py +++ b/phy/cluster/tests/test_merge_lifecycle.py @@ -7,7 +7,7 @@ from .test_supervisor import _select, supervisor # noqa: F401 -def test_supervisor_merge_mode_releases_temporary_event_callbacks(supervisor): +def test_supervisor_merge_mode_releases_temporary_event_callbacks(supervisor): # noqa: F811 _select(supervisor, [30], [20]) def callbacks_for(callback): From 7723affe47e1522c3f4782ee73b5e0bcef77703f Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:20:34 +0200 Subject: [PATCH 070/110] fix: reuse wizard candidate color slot --- phy/cluster/_selection.py | 62 +++++++++++++++++++++++++++++ phy/cluster/tests/test_selection.py | 44 ++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index 733c8747..3de15bcb 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -298,6 +298,33 @@ def set_similarity_selection(self, similar_ids, presentation_order=None): ) return self._apply(after) + def navigate_similarity_selection(self, similar_ids, presentation_order=None): + """Replace the Normal-mode wizard candidate while reusing its color slot.""" + self._require_normal_mode() + current = self._state + similar_ids = _as_unique_ids(similar_ids) + if len(similar_ids) > 1: + raise ValueError('Similarity navigation selects at most one candidate.') + effective_ids = _ordered_union(current.cluster_ids, similar_ids) + if presentation_order is None: + presentation_order = _ordered_union( + tuple( + cluster_id + for cluster_id in current.presentation_order + if cluster_id in effective_ids + ), + effective_ids, + ) + color_order = self._navigation_color_order(similar_ids) + after = CurationSelectionState( + cluster_ids=current.cluster_ids, + similar_ids=similar_ids, + reference_id=current.reference_id, + presentation_order=presentation_order, + color_order=color_order, + ) + return self._apply(after) + def set_presentation_order(self, presentation_order): """Set scientific-view order without changing roles or color slots.""" current = self._state @@ -440,6 +467,41 @@ def _next_color_order(self, reference_id, presentation_order): return tuple(presentation_order) return _ordered_union(current.color_order, presentation_order) + def _navigation_color_order(self, similar_ids): + """Give a replacement wizard candidate the outgoing candidate's slot.""" + current = self._state + if not similar_ids: + return current.color_order + candidate = similar_ids[0] + color_order = list(current.color_order) + outgoing_slots = [ + color_order.index(cluster_id) + for cluster_id in current.similar_ids + if cluster_id in color_order + ] + if outgoing_slots: + target = min(outgoing_slots) + else: + primary_ids = set(current.cluster_ids) + target = next( + ( + index + for index, cluster_id in enumerate(color_order) + if cluster_id not in primary_ids + ), + len(color_order), + ) + if candidate in color_order: + source = color_order.index(candidate) + color_order[target], color_order[source] = color_order[source], color_order[target] + elif target < len(color_order): + displaced = color_order[target] + color_order[target] = candidate + color_order.append(displaced) + else: + color_order.append(candidate) + return tuple(color_order) + def _apply(self, after): before = self._state self._state = after diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index 468f41ed..0d311654 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -116,6 +116,50 @@ def test_similarity_deselection_and_reselection_preserve_color_slots(): assert not change.colors_changed +def test_similarity_navigation_reuses_outgoing_or_inactive_color_slot(): + controller = CurationSelectionController( + CurationSelectionState( + cluster_ids=(1,), + similar_ids=(2,), + reference_id=1, + color_order=(1, 2, 3), + ) + ) + + change = controller.navigate_similarity_selection((3,)) + assert change.after.similar_ids == (3,) + assert change.after.presentation_order == (1, 3) + assert change.after.color_order == (1, 3, 2) + assert change.colors_changed + + change = controller.navigate_similarity_selection((2,)) + assert change.after.color_order == (1, 2, 3) + + controller.clear_similarity_selection() + change = controller.navigate_similarity_selection((4,)) + assert change.after.color_order == (1, 4, 3, 2) + + +def test_similarity_navigation_preserves_primary_colors_and_is_normal_only(): + controller = CurationSelectionController( + CurationSelectionState( + cluster_ids=(1, 4), + similar_ids=(2,), + reference_id=1, + color_order=(1, 4, 2, 3), + ) + ) + + change = controller.navigate_similarity_selection((3,)) + assert change.after.color_order == (1, 4, 3, 2) + with raises(ValueError, match='at most one'): + controller.navigate_similarity_selection((2, 3)) + + controller.enter_merge_mode() + with raises(RuntimeError, match='unavailable'): + controller.navigate_similarity_selection((5,)) + + def test_set_normal_selection_replaces_all_roles_atomically(): controller = CurationSelectionController() From 1646e87ba342fb56f0c7968bcd6709d337c119ab Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:22:54 +0200 Subject: [PATCH 071/110] fix: keep wizard candidate color stable --- phy/cluster/supervisor.py | 11 ++++++++--- phy/cluster/tests/test_supervisor.py | 15 +++++++++++++++ phy/gui/tests/test_widgets.py | 10 ++++++++++ phy/gui/widgets.py | 8 ++++---- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index e4b2a64d..3ae8a9d4 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1118,7 +1118,8 @@ def _clusters_selected(self, sender, obj, **kwargs): return cluster_ids = obj['selected'] next_cluster = obj['next'] - kwargs = obj.get('kwargs', {}) + kwargs = dict(obj.get('kwargs', {})) + kwargs.pop('_selection_intent', None) logger.debug('Clusters selected: %s (%s)', cluster_ids, next_cluster) change = self.selection.set_normal_selection(cluster_ids) change = self._set_table_presentation_order(change) @@ -1147,12 +1148,16 @@ def _similar_selected(self, sender, obj): return similar = obj['selected'] next_similar = obj['next'] - kwargs = obj.get('kwargs', {}) + kwargs = dict(obj.get('kwargs', {})) + selection_intent = kwargs.pop('_selection_intent', None) logger.debug('Similar clusters selected: %s (%s)', similar, next_similar) presentation_order = self._presentation_order_from_tables( self.selection.state, similar_ids=similar ) - self.selection.set_similarity_selection(similar, presentation_order) + if selection_intent == 'navigation' and not self.selection.state.is_merge_mode: + self.selection.navigate_similarity_selection(similar, presentation_order) + else: + self.selection.set_similarity_selection(similar, presentation_order) self._update_selection_colors() self._project_merge_view() self.task_logger.log(self.similarity_view, 'select', similar, output=obj) diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 6534fa35..e4a8048e 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -610,6 +610,14 @@ def test_merge_mode_next_navigates_similarity_not_cluster(supervisor): assert supervisor.selection.state.merge is before.merge assert supervisor.selected_clusters == [] assert len(supervisor.selected_similar) == 1 + first_candidate = supervisor.selected_similar[0] + first_colors = supervisor.selection_color_order + + supervisor.next() + supervisor.block() + + assert supervisor.selection_color_order[: len(first_colors)] == first_colors + assert supervisor.selected_similar != [first_candidate] def test_merge_mode_merge_undo_redo_restores_workspace(supervisor): @@ -1735,19 +1743,26 @@ def test_supervisor_reset(qtbot, supervisor): supervisor.select_actions.next() supervisor.block() _assert_selected(supervisor, [30, 20]) + assert supervisor.similarity_view._selected_color_index(20) == 1 supervisor.select_actions.next() supervisor.block() _assert_selected(supervisor, [30, 11]) + assert supervisor.similarity_view._selected_color_index(11) == 1 supervisor.select_actions.previous() supervisor.block() _assert_selected(supervisor, [30, 20]) + assert supervisor.similarity_view._selected_color_index(20) == 1 supervisor.select_actions.unselect_similar() supervisor.block() _assert_selected(supervisor, [30]) + supervisor.select_actions.next() + supervisor.block() + assert supervisor.similarity_view._selected_color_index(supervisor.selected_similar[0]) == 1 + def test_supervisor_nav(qtbot, supervisor): supervisor.select_actions.reset_wizard() diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index e2900bb7..d43dfbf3 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -494,13 +494,23 @@ def test_table_nav_last(qtbot, table): def test_table_nav_0(qtbot, table): + payloads = [] + + @connect(event='select', sender=table) + def on_select(sender, obj): + payloads.append(obj) + table.select([4]) + assert payloads[-1]['kwargs'] == {} table.next() _assert(table.get_selected, [6]) + assert payloads[-1]['kwargs'] == {'_selection_intent': 'navigation'} table.previous() _assert(table.get_selected, [4]) + assert payloads[-1]['kwargs'] == {'_selection_intent': 'navigation'} + unconnect(on_select) def test_table_navigation_skip_masked_policy(qtbot, table): diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index a3b24e6b..e80c9031 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -1315,18 +1315,18 @@ def get_sibling_id(self, row_id=None, direction='next'): def _move_to_sibling(self, row_id=None, direction='next'): if not self.get_selected_ids(): - return self._select_first_or_last('first') + return self._select_first_or_last('first', _selection_intent='navigation') new_id = self.get_sibling_id(row_id, direction) if new_id is None: return None - return self.select([new_id]) + return self.select([new_id], _selection_intent='navigation') - def _select_first_or_last(self, which): + def _select_first_or_last(self, which, **kwargs): visible = self._visible_ids() ordered = visible if which == 'first' else list(reversed(visible)) for row_id in ordered: if self._is_navigable_id(row_id): - return self.select([row_id]) + return self.select([row_id], **kwargs) return None def sort_by(self, name, sort_dir='asc'): From e9fe6a9d358ea405fcfcea61eddce8ee0ed1d238 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:23:18 +0200 Subject: [PATCH 072/110] docs: clarify wizard candidate colors --- docs/changelog.md | 12 +++++++----- docs/clustering.md | 5 +++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 1ea709ec..94be4a4b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -102,11 +102,13 @@ behavior they verify rather than listed separately. Similarity rows in visible table order; sorting or filtering either table updates that presentation without recoloring existing selections. Deselecting and reselecting a cluster reuses its color while the blue reference remains - unchanged; choosing a new reference starts a new color session. In Merge - mode, explicit Merge View order takes precedence, while workflow-table - colors remain fixed for the entire session. Normal-mode cross-role mouse - transfers and cross-correlogram promotion have been removed in favor of the - Merge workspace. + unchanged; choosing a new reference starts a new color session. Normal-mode + `Space`/`Shift+Space` navigation gives the replacement wizard candidate the + outgoing candidate's color, so a lone candidate remains red. In Merge mode, + explicit Merge View order takes precedence, while workflow-table colors + remain fixed for the entire session. Normal-mode cross-role mouse transfers + and cross-correlogram promotion have been removed in favor of the Merge + workspace. - Undo and redo restore the complete selection context around merge, split, and metadata actions; redo also preserves selection-only exploration made after the original action. diff --git a/docs/clustering.md b/docs/clustering.md index ad8c6eac..f364128e 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -68,6 +68,11 @@ You can move up and down in the **cluster view** with the `Up` and `Down` arrows You can move up and down in the **similarity view** with the `Space` and `Shift-space` arrows. The cluster selected in the similarity view is called the **similar cluster**. The idea is to go through every "best cluster" in the cluster view, and review the "similar clusters" in the similarity view (sorted by decreasing similarity with the best cluster). +In Normal mode, this wizard navigation replaces the current similar cluster in +its color slot. With one blue reference, the candidate therefore remains red as +you move forward or backward. Multi-selection colors and Merge-mode colors keep +their separate stable-slot behavior. + Press `Control+Space` to select the first 15 eligible clusters currently shown in the similarity view while preserving the cluster view selection. Repeat it to select the next batch. This uses the current similarity view sorting and filtering. To choose a different number, use **Select > Select N Similar**; the chosen number becomes the shortcut's new default and is remembered across sessions. Wizard navigation skips clusters labeled `noise` or `mua` by default. To include them when moving through either table or when selecting N similar clusters, uncheck **Select > Skip Noise and MUA**. This preference is remembered across sessions. Direct selection with the mouse, a cluster ID, or a snippet can always select these clusters. Code that creates a `Supervisor` can choose the initial behavior with `skip_masked_clusters=False`; saved GUI state takes precedence when present. From 40162f92ae6c8277787ddb006056d003f14dc84d Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:39:50 +0200 Subject: [PATCH 073/110] docs: define intent-driven selection colors --- design/selection-order-color-refactor.md | 96 ++++++++++++++++-------- 1 file changed, 65 insertions(+), 31 deletions(-) diff --git a/design/selection-order-color-refactor.md b/design/selection-order-color-refactor.md index 867d2569..7d56eaf9 100644 --- a/design/selection-order-color-refactor.md +++ b/design/selection-order-color-refactor.md @@ -46,22 +46,25 @@ cluster_ids similar_ids reference_id presentation_order -color_order +color_slots merge ``` `presentation_order` contains the active cluster IDs in the exact order sent to -scientific views. `color_order` is a reference-scoped registry: its tuple -position is the selected-cluster palette slot. It may retain inactive cluster -IDs so deselection and reselection do not change colors. +scientific views. `color_slots` is a tuple of cluster IDs or `None`; its tuple +position is the selected-cluster palette slot. A hole is an explicitly released +slot, while an inactive cluster ID is a reserved binding that may be reused by +that cluster. Consumers receive an immutable `{cluster_id: palette_index}` +projection and never infer colors from presentation order. ### 3.1 State invariants -1. All role, presentation, and color sequences contain unique cluster IDs. +1. All role and presentation sequences contain unique cluster IDs; non-`None` + color-slot bindings are unique. 2. `set(presentation_order) == set(effective_ids)`. -3. `set(effective_ids) <= set(color_order)`. +3. Every effective ID has exactly one color-slot binding. 4. If a reference exists, it belongs to the active primary role and occupies - index zero in both `presentation_order` and `color_order`. + index zero in both `presentation_order` and `color_slots`. 5. Normal mode has no Merge session and its reference belongs to `cluster_ids`. 6. Merge mode has no Cluster role selection, and the reference is the first Merge member. @@ -73,26 +76,37 @@ IDs so deselection and reselection do not change colors. ## 4. Color lifecycle -Color slots are stable for the lifetime of one reference: - -- Selecting a previously unseen cluster appends it to `color_order`. -- Deselecting a cluster removes it from active presentation but retains its - color slot. -- Reselecting a cluster reuses its existing slot. +Color-slot policy depends on the selection operation; final selected IDs alone +are intentionally insufficient to choose a color transition: + +- `REPLACE` releases previous Similarity bindings and assigns the replacement + selection from the first slot not occupied by an active Cluster-role member. + Consequently, directly clicking different single Similarity candidates and + navigating with Space/Shift+Space use the same color (red when the reference + is the only Cluster-role member). +- `EXTEND` preserves existing bindings and assigns newly added IDs to released + holes before extending the slot tuple. +- `TOGGLE` preserves removed-cluster bindings as reservations, so removing an + item from a multi-selection does not recolor the remaining items and + reselecting it restores its color. +- `CLEAR` releases Normal-mode Similarity bindings, allowing the next selection + to start from the first Similarity slot. - Sorting, filtering, Merge transfers, and Merge reordering never change color slots. - Editing the Cluster selection while retaining the same reference preserves - existing slots and appends new clusters. + valid bindings and assigns newly active members deterministically. - Changing the reference starts a new color session. The new reference becomes slot zero, and active clusters receive fresh slots in presentation order. -- Entering Merge mode preserves the Normal color registry. -- Cancelling Merge restores the complete entry registry. +- Entering Merge mode freezes all existing bindings. New Merge-mode candidates + use released holes or new slots, but no existing binding changes during the + session. +- Cancelling Merge restores the complete entry assignment. - A committed merge selects a new reference and therefore starts a new registry. -- Undo and redo restore the exact registry stored in their selection snapshots. +- Undo and redo restore the exact assignment stored in their selection + snapshots. -Color slots are not reused before the reference changes. The registry is bounded -by the number of clusters encountered for one reference and remains independent -of the number of spikes. +The assignment remains bounded by the number of clusters encountered for one +reference and remains independent of the number of spikes. ## 5. Presentation lifecycle @@ -114,12 +128,26 @@ the immutable state and is restored by cancellation and history. ## 6. Controller transitions -The selection controller should expose explicit transitions for distinct user -intents: +All Similarity-table paths produce a structured operation before reaching the +selection controller: + +```python +SelectionMutation( + intent=SelectionIntent.REPLACE | EXTEND | TOGGLE | CLEAR | NAVIGATE, + before_ids=(...), + after_ids=(...), + added_ids=(...), + removed_ids=(...), +) +``` + +`NAVIGATE` has `REPLACE` color semantics; it remains distinct so keyboard +navigation behavior is explicit and testable. The controller exposes one +`apply_similarity_mutation()` reducer plus transitions for the other domains: ```python set_normal_selection(...) -set_similarity_selection(...) +apply_similarity_mutation(...) set_presentation_order(...) enter_merge_mode(...) cancel_merge_mode() @@ -161,10 +189,10 @@ projects the resulting state. It does not independently own color state. Required changes: 1. Remove `Supervisor._selection_color_order`. -2. Make `Supervisor.selection_color_order` delegate to - `selection.state.color_order`. +2. Make `Supervisor.selection_color_indices` delegate to the immutable mapping + projected from `selection.state.color_slots`. 3. Remove the `reset` policy from `_update_selection_colors()`; projection uses - the state's color order verbatim. + the state's explicit color indices verbatim. 4. Replace `_normalize_presentation_order()` with a pure table-order calculation followed by `set_presentation_order()`. 5. Canonicalize selection intent before projection so one user operation @@ -182,10 +210,9 @@ to reject delayed events from an obsolete table state or workflow mode. ## 8. View projection -Workflow tables receive `state.color_order` through -`set_selected_index_order()`. Built-in scientific views obtain an immutable copy -of the same mapping for each selection render and use it only for palette lookup; -layout continues to follow `presentation_order`. +Workflow tables and built-in scientific views receive the same immutable +`{cluster_id: palette_index}` mapping for each selection render and use it only +for palette lookup; layout continues to follow `presentation_order`. Standalone views without a Supervisor may fall back to positional colors. Attached built-in views must not silently fall back when an authoritative color @@ -201,7 +228,14 @@ state invariant and should be covered by tests. - Select a Ctrl+Space batch, deselect a middle row, and verify all remaining colors are unchanged. - Reselect the removed row and verify its original color returns. -- Select a new row after a deselection and verify it receives a new slot. +- Directly click A, then C, then B as single Similarity selections and verify + each candidate uses the first Similarity slot. +- Navigate with Space and Shift+Space and verify the candidate uses the same + slot as direct replacement. +- Plain-click one member of a multi-selection and verify replacement semantics; + Ctrl-remove a member and verify toggle semantics preserve remaining colors. +- Clear Similarity selection and verify the next selection starts at the first + Similarity slot. - Sort and filter both role tables without recoloring. - Modify Cluster selection without changing the reference and retain colors. - Change the reference and verify the new reference is blue and slots reset. From 780445dc07dc08ef95d04883ff909a0ae5c99efb Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:41:42 +0200 Subject: [PATCH 074/110] refactor: project explicit view color indices --- phy/cluster/views/base.py | 17 ++++++++++++----- phy/cluster/views/tests/test_base.py | 23 +++++++++++++++++------ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/phy/cluster/views/base.py b/phy/cluster/views/base.py index 5f948069..dc47b2e7 100644 --- a/phy/cluster/views/base.py +++ b/phy/cluster/views/base.py @@ -7,6 +7,7 @@ import gc import logging +from collections.abc import Mapping from functools import partial from types import MappingProxyType @@ -163,17 +164,23 @@ def on_select(self, cluster_ids=None, **kwargs): self.plot(**kwargs) def _update_cluster_color_indices(self, sender, cluster_ids): - """Copy and validate the authoritative selected-cluster color mapping.""" + """Adopt and validate the authoritative selected-cluster color mapping. + + A Supervisor publishes color slots separately from presentation order. + Keep its immutable projection intact: plotting code can therefore look up + a cluster's palette slot without inferring it from the render order. + Standalone views, which have no Supervisor projection, retain positional + colors. + """ try: - order = tuple(sender.selection_color_order) + color_indices = sender.selection_color_indices except AttributeError: # Standalone views and other non-authoritative senders retain the # traditional positional colors. self._cluster_color_index_by_id = MappingProxyType({}) return - color_indices = MappingProxyType( - {cluster_id: index for index, cluster_id in enumerate(order)} - ) + if not isinstance(color_indices, Mapping): + raise TypeError('selection_color_indices must be a mapping.') missing_ids = set(cluster_ids).difference(color_indices) if missing_ids: raise ValueError( diff --git a/phy/cluster/views/tests/test_base.py b/phy/cluster/views/tests/test_base.py index fc905fec..d4a95d6e 100644 --- a/phy/cluster/views/tests/test_base.py +++ b/phy/cluster/views/tests/test_base.py @@ -4,6 +4,8 @@ # Imports # ------------------------------------------------------------------------------ +from types import MappingProxyType + import numpy as np from phylib.utils import emit from pytest import raises @@ -77,13 +79,13 @@ def test_manual_clustering_view_2(qtbot, gui): v.attach(gui) class Supervisor: - selection_color_order = [0, 2, 1] + selection_color_indices = MappingProxyType({0: 0, 1: 2, 2: 1}) sender = Supervisor() emit('select', sender, cluster_ids=[0, 1]) assert v.cluster_color_index(0, 0) == 0 assert v.cluster_color_index(1, 1) == 2 - sender.selection_color_order[:] = (0, 1, 2) + sender.selection_color_indices = MappingProxyType({0: 0, 1: 1, 2: 2}) assert v.cluster_color_index(1, 1) == 2 v.actions.get('Change color scheme to myscheme').trigger() @@ -123,7 +125,7 @@ def test_authoritative_selection_colors_require_every_active_cluster(): view = ManualClusteringView() class Supervisor: - selection_color_order = (1,) + selection_color_indices = MappingProxyType({1: 0}) with raises(ValueError, match='missing active cluster IDs: \\[2\\]'): view._update_cluster_color_indices(Supervisor(), [1, 2]) @@ -132,13 +134,22 @@ class Supervisor: def test_standalone_selection_uses_positional_colors(): view = ManualClusteringView() + view._update_cluster_color_indices(object(), [1, 2]) + + assert view.cluster_color_index(2, 1) == 1 + + +def test_authoritative_selection_color_mapping_is_used_directly(): + view = ManualClusteringView() + color_indices = MappingProxyType({1: 3, 2: 0}) + class Supervisor: - selection_color_order = (1, 2) + selection_color_indices = color_indices view._update_cluster_color_indices(Supervisor(), [1, 2]) - view._update_cluster_color_indices(object(), [1, 2]) - assert view.cluster_color_index(2, 1) == 1 + assert view._cluster_color_index_by_id is color_indices + assert view.cluster_color_index(1, 0) == 3 def test_manual_clustering_view_selection_is_limited(qtbot, gui): From 1050cadedc9662e2d6063d60fca5a71068cb9c14 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:43:47 +0200 Subject: [PATCH 075/110] refactor: describe table selection intent --- phy/gui/tests/test_widgets.py | 40 +++++++++++++++++++++-- phy/gui/widgets.py | 61 +++++++++++++++++++++++++++++------ phy/utils/selection.py | 48 +++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 phy/utils/selection.py diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index d43dfbf3..fa18854a 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -11,6 +11,8 @@ from phylib.utils import connect, unconnect from pytest import fixture, mark, raises +from phy.utils.selection import SelectionIntent, SelectionMutation + from ..qt import QApplication, QEvent, QHeaderView, QMimeData, QMouseEvent, Qt from ..widgets import Barrier, IPythonView, KeyValueWidget, Table, ViewSettingsDialog from . import show_and_wait @@ -376,6 +378,38 @@ def on_select(sender, obj): unconnect(on_select) +def test_table_selection_events_include_structured_mutations(table): + events = [] + + @connect(event='select', sender=table) + def on_select(sender, payload): + events.append(payload) + + table.select([1]) + replace = events[-1]['kwargs']['_selection_mutation'] + assert replace == SelectionMutation(SelectionIntent.REPLACE, (), (1,), (1,), ()) + + table.select_toggle(3) + toggle = events[-1]['kwargs']['_selection_mutation'] + assert toggle == SelectionMutation(SelectionIntent.TOGGLE, (1,), (1, 3), (3,), ()) + + table.select_until(5) + extend = events[-1]['kwargs']['_selection_mutation'] + assert extend == SelectionMutation(SelectionIntent.EXTEND, (1, 3), (1, 3, 4, 5), (4, 5), ()) + + table.select([]) + clear = events[-1]['kwargs']['_selection_mutation'] + assert clear == SelectionMutation(SelectionIntent.CLEAR, (1, 3, 4, 5), (), (), (1, 3, 4, 5)) + + # Programmatic projection remains intentionally silent. + table.set_selected_ids([1]) + assert len(events) == 4 + table.next() + navigate = events[-1]['kwargs']['_selection_mutation'] + assert navigate == SelectionMutation(SelectionIntent.NAVIGATE, (1,), (4,), (4,), (1,)) + unconnect(on_select) + + def test_table_batch_update_fits_once(table): fit_calls = [] table._fit_columns = lambda: fit_calls.append(True) @@ -501,15 +535,15 @@ def on_select(sender, obj): payloads.append(obj) table.select([4]) - assert payloads[-1]['kwargs'] == {} + assert payloads[-1]['kwargs']['_selection_mutation'].intent is SelectionIntent.REPLACE table.next() _assert(table.get_selected, [6]) - assert payloads[-1]['kwargs'] == {'_selection_intent': 'navigation'} + assert payloads[-1]['kwargs']['_selection_mutation'].intent is SelectionIntent.NAVIGATE table.previous() _assert(table.get_selected, [4]) - assert payloads[-1]['kwargs'] == {'_selection_intent': 'navigation'} + assert payloads[-1]['kwargs']['_selection_mutation'].intent is SelectionIntent.NAVIGATE unconnect(on_select) diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index e80c9031..c7da045b 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -22,6 +22,7 @@ from qtconsole.rich_jupyter_widget import RichJupyterWidget from phy.utils.color import _is_bright, colormaps +from phy.utils.selection import SelectionIntent, SelectionMutation from .qt import ( Debouncer, @@ -1211,8 +1212,27 @@ def _selected_payload(self, kwargs=None): 'revision': self._selection_revision, } - def _emit_selected(self, kwargs=None): + def _selection_mutation(self, before_ids, intent): + """Describe the selection operation that just updated this table.""" + before_ids = tuple(before_ids) + after_ids = tuple(self.get_selected_ids()) + before_set = set(before_ids) + after_set = set(after_ids) + return SelectionMutation( + intent=intent, + before_ids=before_ids, + after_ids=after_ids, + added_ids=tuple(row_id for row_id in after_ids if row_id not in before_set), + removed_ids=tuple(row_id for row_id in before_ids if row_id not in after_set), + ) + + def _emit_selected(self, kwargs=None, mutation=None): self._selection_revision += 1 + kwargs = dict(kwargs or {}) + if mutation is not None: + # This stays in the private kwargs channel so existing consumers see + # the same top-level select payload. + kwargs['_selection_mutation'] = mutation payload = self._selected_payload(kwargs) self._emit_event('select', payload) return payload @@ -1273,27 +1293,33 @@ def get_selected_ids(self): return [row_id for row_id in self._selected_ids if row_id in visible] def select_toggle(self, row_id): + before_ids = tuple(self.get_selected_ids()) if row_id in self._selected_ids: self._selected_ids.remove(row_id) else: self._selected_ids.append(row_id) self._refresh_selection() - return self._emit_selected() + return self._emit_selected( + mutation=self._selection_mutation(before_ids, SelectionIntent.TOGGLE) + ) def select_until(self, row_id): + before_ids = tuple(self.get_selected_ids()) visible = self._visible_ids() if row_id not in visible: return None anchor = self._selection_anchor_row() if anchor is None: - return self.select([row_id]) + return self.select([row_id], _selection_intent=SelectionIntent.EXTEND) clicked = visible.index(row_id) imin, imax = sorted((anchor, clicked)) for visible_id in visible[imin : imax + 1]: if visible_id not in self._selected_ids: self._selected_ids.append(visible_id) self._refresh_selection() - return self._emit_selected() + return self._emit_selected( + mutation=self._selection_mutation(before_ids, SelectionIntent.EXTEND) + ) def get_sibling_id(self, row_id=None, direction='next'): selected = self.get_selected_ids() @@ -1315,11 +1341,11 @@ def get_sibling_id(self, row_id=None, direction='next'): def _move_to_sibling(self, row_id=None, direction='next'): if not self.get_selected_ids(): - return self._select_first_or_last('first', _selection_intent='navigation') + return self._select_first_or_last('first', _selection_intent=SelectionIntent.NAVIGATE) new_id = self.get_sibling_id(row_id, direction) if new_id is None: return None - return self.select([new_id], _selection_intent='navigation') + return self.select([new_id], _selection_intent=SelectionIntent.NAVIGATE) def _select_first_or_last(self, which, **kwargs): visible = self._visible_ids() @@ -1382,10 +1408,16 @@ def selection_after_navigation(self, direction='next'): return [row_id] if row_id is not None else [] def first(self, callback=None): - return self._async_return(self._select_first_or_last('first'), callback) + return self._async_return( + self._select_first_or_last('first', _selection_intent=SelectionIntent.NAVIGATE), + callback, + ) def last(self, callback=None): - return self._async_return(self._select_first_or_last('last'), callback) + return self._async_return( + self._select_first_or_last('last', _selection_intent=SelectionIntent.NAVIGATE), + callback, + ) def next(self, callback=None): return self._async_return(self._move_to_sibling(None, 'next'), callback) @@ -1394,8 +1426,19 @@ def previous(self, callback=None): return self._async_return(self._move_to_sibling(None, 'previous'), callback) def select(self, ids, callback=None, **kwargs): + ids = tuple(ids) + before_ids = tuple(self.get_selected_ids()) + intent = kwargs.pop('_selection_intent', None) + if intent == 'navigation': + intent = SelectionIntent.NAVIGATE + elif intent is None: + intent = SelectionIntent.CLEAR if not ids else SelectionIntent.REPLACE + if not isinstance(intent, SelectionIntent): + raise TypeError('_selection_intent must be a SelectionIntent.') self.set_selected_ids(ids) - payload = self._emit_selected(kwargs) + payload = self._emit_selected( + kwargs, mutation=self._selection_mutation(before_ids, intent) + ) return self._async_return(payload, callback) def set_selected_ids(self, ids): diff --git a/phy/utils/selection.py b/phy/utils/selection.py new file mode 100644 index 00000000..89e1add6 --- /dev/null +++ b/phy/utils/selection.py @@ -0,0 +1,48 @@ +"""Selection operations emitted by workflow tables. + +This small value object deliberately has no Qt, GUI, or Supervisor +dependencies. A table can describe how its selected IDs changed without +making a controller infer an operation from the final IDs alone. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class SelectionIntent(Enum): + """The user operation which produced a table selection.""" + + REPLACE = 'replace' + EXTEND = 'extend' + TOGGLE = 'toggle' + CLEAR = 'clear' + NAVIGATE = 'navigate' + + +@dataclass(frozen=True) +class SelectionMutation: + """An immutable transition between two ordered row-ID selections.""" + + intent: SelectionIntent + before_ids: tuple[int, ...] + after_ids: tuple[int, ...] + added_ids: tuple[int, ...] + removed_ids: tuple[int, ...] + + def __post_init__(self): + if not isinstance(self.intent, SelectionIntent): + raise TypeError('intent must be a SelectionIntent.') + for name in ('before_ids', 'after_ids', 'added_ids', 'removed_ids'): + ids = tuple(getattr(self, name)) + if len(ids) != len(set(ids)): + raise ValueError(f'{name} must contain unique cluster IDs.') + object.__setattr__(self, name, ids) + + before_ids = self.before_ids + after_ids = self.after_ids + expected_added = tuple(row_id for row_id in after_ids if row_id not in before_ids) + expected_removed = tuple(row_id for row_id in before_ids if row_id not in after_ids) + if self.added_ids != expected_added or self.removed_ids != expected_removed: + raise ValueError('Added and removed IDs must match the selection delta.') From b4121a7194bd690381b09d79180749d748306104 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:44:39 +0200 Subject: [PATCH 076/110] refactor: model explicit selection color slots --- phy/cluster/_selection.py | 593 ++++++++++++++-------------- phy/cluster/tests/test_selection.py | 113 +++++- 2 files changed, 390 insertions(+), 316 deletions(-) diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index 3de15bcb..443c93da 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -1,40 +1,32 @@ -"""Immutable selection state used by curation workflows. - -This module intentionally has no Qt or Supervisor dependencies. Views can use -the controller as a synchronous source of selection state, while deciding -separately how and when to render a :class:`SelectionChange`. -""" +"""Immutable, UI-independent selection state for curation workflows.""" from __future__ import annotations from dataclasses import dataclass from enum import Enum +from types import MappingProxyType +from phy.utils.selection import SelectionIntent, SelectionMutation -class WorkflowMode(Enum): - """The active curation workflow.""" +class WorkflowMode(Enum): NORMAL = 'normal' MERGE = 'merge' def _as_unique_ids(cluster_ids) -> tuple[int, ...]: - """Return *cluster_ids* as a tuple, rejecting duplicates.""" cluster_ids = tuple(cluster_ids) if len(cluster_ids) != len(set(cluster_ids)): raise ValueError('Cluster IDs must be unique.') return cluster_ids -def _ordered_union(*cluster_id_lists) -> tuple[int, ...]: - """Return the ordered union of the supplied cluster-ID sequences.""" - return tuple(dict.fromkeys(cluster_id for ids in cluster_id_lists for cluster_id in ids)) +def _ordered_union(*lists) -> tuple[int, ...]: + return tuple(dict.fromkeys(cluster_id for ids in lists for cluster_id in ids)) @dataclass(frozen=True) class NormalWorkflowSnapshot: - """Normal-mode selection plus opaque view state needed for cancellation.""" - selection: CurationSelectionState workflow_context: object = None @@ -47,27 +39,25 @@ def __post_init__(self): @dataclass(frozen=True) class MergeSession: - """Temporary ordered merge workspace tied to one fixed reference cluster.""" - reference_id: int ordered_ids: tuple[int, ...] entry_snapshot: NormalWorkflowSnapshot def __post_init__(self): - ordered_ids = _as_unique_ids(self.ordered_ids) - if not ordered_ids or ordered_ids[0] != self.reference_id: + ordered = _as_unique_ids(self.ordered_ids) + if not ordered or ordered[0] != self.reference_id: raise ValueError('The merge reference must be the first staged cluster.') - object.__setattr__(self, 'ordered_ids', ordered_ids) + object.__setattr__(self, 'ordered_ids', ordered) @dataclass(frozen=True) class CurationSelectionState: - """The authoritative, immutable curation selection. + """Authoritative roles, rendering order, and palette-slot bindings. - ``presentation_order`` is the effective selection in the order delivered - to scientific views. The Supervisor derives Normal-mode order from the - visible role tables. In Merge mode it is derived from the visible roles: - Merge View order first, followed by Similarity View selection order. + ``color_slots`` is deliberately not derived from presentation order: a + ``None`` entry is a released palette slot and a non-active ID is a reserved + binding. ``color_order`` is a temporary legacy projection for callers + which still expect a compact sequence. """ mode: WorkflowMode = WorkflowMode.NORMAL @@ -75,85 +65,99 @@ class CurationSelectionState: similar_ids: tuple[int, ...] = () reference_id: int | None = None presentation_order: tuple[int, ...] | None = None + color_slots: tuple[int | None, ...] | None = None color_order: tuple[int, ...] | None = None merge: MergeSession | None = None def __post_init__(self): - cluster_ids = _as_unique_ids(self.cluster_ids) - similar_ids = _as_unique_ids(self.similar_ids) - reference_id = self.reference_id + clusters = _as_unique_ids(self.cluster_ids) + similar = _as_unique_ids(self.similar_ids) + reference = self.reference_id merge = self.merge if self.mode is WorkflowMode.NORMAL: if merge is not None: raise ValueError('Normal mode cannot contain a merge session.') - if reference_id is None and cluster_ids: - reference_id = cluster_ids[0] - if reference_id is not None and reference_id not in cluster_ids: + if reference is None and clusters: + reference = clusters[0] + if reference is not None and reference not in clusters: raise ValueError('The reference ID must belong to the cluster selection.') - effective_ids = _ordered_union(cluster_ids, similar_ids) + effective = _ordered_union(clusters, similar) + primary = clusters else: if merge is None: raise ValueError('Merge mode requires a merge session.') - if cluster_ids: + if clusters: raise ValueError('Cluster selection must be empty in Merge mode.') - if reference_id is None: - reference_id = merge.reference_id - if reference_id != merge.reference_id: + reference = merge.reference_id if reference is None else reference + if reference != merge.reference_id: raise ValueError('The selection and merge references must agree.') - if set(similar_ids).intersection(merge.ordered_ids): + if set(similar) & set(merge.ordered_ids): raise ValueError('A cluster cannot be both staged and selected as similar.') - effective_ids = _ordered_union(merge.ordered_ids, similar_ids) - default_presentation = _ordered_union( - (reference_id,) if reference_id is not None else (), - merge.ordered_ids if merge is not None else cluster_ids, - similar_ids, + effective = _ordered_union(merge.ordered_ids, similar) + primary = merge.ordered_ids + default_order = _ordered_union( + (reference,) if reference is not None else (), primary, similar ) - presentation_order = ( - default_presentation + presentation = ( + default_order if self.presentation_order is None else _as_unique_ids(self.presentation_order) ) - - if set(presentation_order) != set(effective_ids): + if set(presentation) != set(effective): raise ValueError('Presentation order must contain exactly the effective IDs.') - if similar_ids and reference_id is None: + if similar and reference is None: raise ValueError('Similarity selection requires a reference ID.') - if ( - presentation_order - and reference_id is not None - and presentation_order[0] != reference_id - ): + if presentation and reference is not None and presentation[0] != reference: raise ValueError('The reference ID must occupy the first presentation slot.') if self.mode is WorkflowMode.MERGE: - merge_ids = merge.ordered_ids - if presentation_order[: len(merge_ids)] != merge_ids: + if presentation[: len(primary)] != primary: raise ValueError('Merge presentation must begin with the staged merge order.') - if set(presentation_order[len(merge_ids) :]) != set(similar_ids): + if set(presentation[len(primary) :]) != set(similar): raise ValueError('Merge presentation tail must contain the Similarity selection.') - color_order = ( - presentation_order if self.color_order is None else _as_unique_ids(self.color_order) - ) - if reference_id is None and color_order: - raise ValueError('Color order requires a reference ID.') - if not set(effective_ids) <= set(color_order): - raise ValueError('Color order must contain every effective ID.') - if color_order and reference_id is not None and color_order[0] != reference_id: + if self.color_slots is not None and self.color_order is not None: + raise ValueError('Specify color_slots instead of legacy color_order.') + slots = self.color_slots + if slots is None: + slots = self.color_order if self.color_order is not None else presentation + slots = tuple(slots) + bindings = tuple(cluster_id for cluster_id in slots if cluster_id is not None) + if len(bindings) != len(set(bindings)): + raise ValueError('Color-slot bindings must be unique.') + if reference is None and bindings: + raise ValueError('Color slots require a reference ID.') + if not set(effective) <= set(bindings): + raise ValueError('Color slots must contain every effective ID.') + if bindings and reference is not None and (not slots or slots[0] != reference): raise ValueError('The reference ID must occupy the first color slot.') + object.__setattr__(self, 'cluster_ids', clusters) + object.__setattr__(self, 'similar_ids', similar) + object.__setattr__(self, 'reference_id', reference) + object.__setattr__(self, 'presentation_order', presentation) + object.__setattr__(self, 'color_slots', slots) + # Compatibility only: consumers needing palette indices must use color_indices. + object.__setattr__( + self, + 'color_order', + tuple(cluster_id for cluster_id in slots if cluster_id is not None), + ) - object.__setattr__(self, 'cluster_ids', cluster_ids) - object.__setattr__(self, 'similar_ids', similar_ids) - object.__setattr__(self, 'reference_id', reference_id) - object.__setattr__(self, 'presentation_order', presentation_order) - object.__setattr__(self, 'color_order', color_order) + @property + def color_indices(self): + """Immutable ``cluster_id -> palette slot`` projection.""" + return MappingProxyType( + { + cluster_id: index + for index, cluster_id in enumerate(self.color_slots) + if cluster_id is not None + } + ) @property def effective_ids(self): - """Return the effective selection for the active workflow mode.""" return _ordered_union(self.merge_ids, self.similar_ids) @property def merge_ids(self): - """Return staged IDs in Merge mode, otherwise the Cluster selection.""" return self.merge.ordered_ids if self.merge is not None else self.cluster_ids @property @@ -161,15 +165,11 @@ def is_merge_mode(self): return self.mode is WorkflowMode.MERGE -# A state is itself an immutable and complete snapshot for Normal mode. The -# alias makes the snapshot boundary explicit at controller call sites. CurationSelectionSnapshot = CurationSelectionState @dataclass(frozen=True) class SelectionChange: - """The complete before/after diff for one selection transition.""" - before: CurationSelectionState after: CurationSelectionState roles_changed: bool @@ -180,277 +180,306 @@ class SelectionChange: @classmethod def create(cls, before, after): - """Classify the transition from *before* to *after*.""" return cls( - before=before, - after=after, - roles_changed=( - before.cluster_ids != after.cluster_ids - or before.similar_ids != after.similar_ids - or before.merge_ids != after.merge_ids - ), - presentation_changed=before.presentation_order != after.presentation_order, - colors_changed=before.color_order != after.color_order, - reference_changed=before.reference_id != after.reference_id, - mode_changed=before.mode is not after.mode, + before, + after, + before.cluster_ids != after.cluster_ids + or before.similar_ids != after.similar_ids + or before.merge_ids != after.merge_ids, + before.presentation_order != after.presentation_order, + before.color_slots != after.color_slots, + before.reference_id != after.reference_id, + before.mode is not after.mode, ) @property def changed(self): - """Whether this transition changes any modeled state.""" return self.before != self.after @property def render_changed(self): - """Whether scientific views need an updated selection render.""" return self.presentation_changed or self.colors_changed class CurationSelectionController: - """Apply validated, atomic curation selection transitions.""" - def __init__(self, state=None): self._state = state or CurationSelectionState() @property def state(self): - """Return the current immutable selection state.""" return self._state def snapshot(self): - """Return the current immutable selection state.""" return self._state def restore(self, snapshot): - """Restore a previously captured Normal-mode *snapshot*.""" if not isinstance(snapshot, CurationSelectionState): raise TypeError('Expected a CurationSelectionState snapshot.') return self._apply(snapshot) def set_cluster_selection(self, cluster_ids, reference_id=None): - """Set Cluster View IDs, using the first (blue) ID as the default reference.""" self._require_normal_mode() - cluster_ids = _as_unique_ids(cluster_ids) - if reference_id is None: - reference_id = cluster_ids[0] if cluster_ids else None - similar_ids = self._state.similar_ids if reference_id is not None else () - presentation_order = _ordered_union( - (reference_id,) if reference_id is not None else (), - cluster_ids, - similar_ids, - ) - after = CurationSelectionState( - cluster_ids=cluster_ids, - similar_ids=similar_ids, - reference_id=reference_id, - presentation_order=presentation_order, - color_order=self._next_color_order(reference_id, presentation_order), + clusters = _as_unique_ids(cluster_ids) + reference = clusters[0] if reference_id is None and clusters else reference_id + similar = self._state.similar_ids if reference is not None else () + order = _ordered_union((reference,) if reference is not None else (), clusters, similar) + slots = self._slots_for_normal_roles(clusters, similar, reference, order) + return self._apply( + CurationSelectionState( + cluster_ids=clusters, + similar_ids=similar, + reference_id=reference, + presentation_order=order, + color_slots=slots, + ) ) - return self._apply(after) def set_normal_selection( - self, - cluster_ids, - similar_ids=(), - reference_id=None, - presentation_order=None, + self, cluster_ids, similar_ids=(), reference_id=None, presentation_order=None ): - """Atomically replace all Normal-mode selection roles and presentation state.""" - cluster_ids = _as_unique_ids(cluster_ids) - similar_ids = _as_unique_ids(similar_ids) - if reference_id is None: - reference_id = cluster_ids[0] if cluster_ids else None - if presentation_order is None: - presentation_order = _ordered_union( - (reference_id,) if reference_id is not None else (), cluster_ids, similar_ids + clusters, similar = _as_unique_ids(cluster_ids), _as_unique_ids(similar_ids) + reference = clusters[0] if reference_id is None and clusters else reference_id + order = presentation_order or _ordered_union( + (reference,) if reference is not None else (), clusters, similar + ) + slots = self._slots_for_normal_roles(clusters, similar, reference, order) + return self._apply( + CurationSelectionState( + cluster_ids=clusters, + similar_ids=similar, + reference_id=reference, + presentation_order=order, + color_slots=slots, ) - after = CurationSelectionState( - cluster_ids=cluster_ids, - similar_ids=similar_ids, - reference_id=reference_id, - presentation_order=presentation_order, - color_order=self._next_color_order(reference_id, presentation_order), ) - return self._apply(after) - def set_similarity_selection(self, similar_ids, presentation_order=None): - """Set Similarity View IDs without changing the current reference.""" + def apply_similarity_mutation(self, mutation, presentation_order=None): + """Reduce one canonical Similarity operation into a complete state.""" + if not isinstance(mutation, SelectionMutation): + raise TypeError('Expected a SelectionMutation.') current = self._state - similar_ids = _as_unique_ids(similar_ids) - effective_ids = _ordered_union(current.merge_ids, similar_ids) - if presentation_order is None: - presentation_order = _ordered_union( - tuple( - cluster_id - for cluster_id in current.presentation_order - if cluster_id in effective_ids - ), - effective_ids, + if mutation.before_ids != current.similar_ids: + raise ValueError('Mutation before_ids do not match the current Similarity selection.') + similar = mutation.after_ids + effective = _ordered_union(current.merge_ids, similar) + order = presentation_order or _ordered_union( + tuple( + cluster_id for cluster_id in current.presentation_order if cluster_id in effective + ), + effective, + ) + if current.is_merge_mode: + slots = self._merge_slots(effective) + elif mutation.intent in ( + SelectionIntent.REPLACE, + SelectionIntent.NAVIGATE, + SelectionIntent.CLEAR, + ): + slots = self._replace_similarity_slots(similar) + else: + slots = self._preserve_and_allocate(current.color_slots, similar) + return self._apply( + CurationSelectionState( + mode=current.mode, + cluster_ids=current.cluster_ids, + similar_ids=similar, + reference_id=current.reference_id, + presentation_order=order, + color_slots=slots, + merge=current.merge, ) - after = CurationSelectionState( - mode=current.mode, - cluster_ids=current.cluster_ids, - similar_ids=similar_ids, - reference_id=current.reference_id, - presentation_order=presentation_order, - color_order=self._next_color_order(current.reference_id, presentation_order), - merge=current.merge, ) - return self._apply(after) + + def set_similarity_selection(self, similar_ids, presentation_order=None): + """Legacy adapter; UI callers should supply a SelectionMutation.""" + similar = _as_unique_ids(similar_ids) + intent = SelectionIntent.CLEAR if not similar else SelectionIntent.REPLACE + before = self._state.similar_ids + return self.apply_similarity_mutation( + SelectionMutation( + intent, + before, + similar, + tuple(cluster_id for cluster_id in similar if cluster_id not in before), + tuple(cluster_id for cluster_id in before if cluster_id not in similar), + ), + presentation_order, + ) def navigate_similarity_selection(self, similar_ids, presentation_order=None): - """Replace the Normal-mode wizard candidate while reusing its color slot.""" self._require_normal_mode() - current = self._state - similar_ids = _as_unique_ids(similar_ids) - if len(similar_ids) > 1: + similar = _as_unique_ids(similar_ids) + if len(similar) > 1: raise ValueError('Similarity navigation selects at most one candidate.') - effective_ids = _ordered_union(current.cluster_ids, similar_ids) - if presentation_order is None: - presentation_order = _ordered_union( - tuple( - cluster_id - for cluster_id in current.presentation_order - if cluster_id in effective_ids - ), - effective_ids, - ) - color_order = self._navigation_color_order(similar_ids) - after = CurationSelectionState( - cluster_ids=current.cluster_ids, - similar_ids=similar_ids, - reference_id=current.reference_id, - presentation_order=presentation_order, - color_order=color_order, + before = self._state.similar_ids + return self.apply_similarity_mutation( + SelectionMutation( + SelectionIntent.NAVIGATE, + before, + similar, + tuple(cluster_id for cluster_id in similar if cluster_id not in before), + tuple(cluster_id for cluster_id in before if cluster_id not in similar), + ), + presentation_order, ) - return self._apply(after) + + def clear_similarity_selection(self): + return self.set_similarity_selection(()) def set_presentation_order(self, presentation_order): - """Set scientific-view order without changing roles or color slots.""" current = self._state - after = CurationSelectionState( - mode=current.mode, - cluster_ids=current.cluster_ids, - similar_ids=current.similar_ids, - reference_id=current.reference_id, - presentation_order=_as_unique_ids(presentation_order), - color_order=current.color_order, - merge=current.merge, + return self._apply( + CurationSelectionState( + mode=current.mode, + cluster_ids=current.cluster_ids, + similar_ids=current.similar_ids, + reference_id=current.reference_id, + presentation_order=_as_unique_ids(presentation_order), + color_slots=current.color_slots, + merge=current.merge, + ) ) - return self._apply(after) - - def clear_similarity_selection(self): - """Clear only the Similarity View selection.""" - return self.set_similarity_selection(()) def enter_merge_mode(self, workflow_context=None): - """Stage the complete Normal-mode selection and enter Merge mode.""" self._require_normal_mode() current = self._state if not current.cluster_ids: raise ValueError('Merge mode requires a Cluster View selection.') - snapshot = NormalWorkflowSnapshot(current, workflow_context=workflow_context) - ordered_ids = current.presentation_order - merge = MergeSession(current.reference_id, ordered_ids, snapshot) - after = CurationSelectionState( - mode=WorkflowMode.MERGE, - reference_id=current.reference_id, - presentation_order=current.presentation_order, - color_order=self._next_color_order(current.reference_id, current.presentation_order), - merge=merge, + merge = MergeSession( + current.reference_id, + current.presentation_order, + NormalWorkflowSnapshot(current, workflow_context), + ) + return self._apply( + CurationSelectionState( + mode=WorkflowMode.MERGE, + reference_id=current.reference_id, + presentation_order=current.presentation_order, + color_slots=current.color_slots, + merge=merge, + ) ) - return self._apply(after) def cancel_merge_mode(self): - """Leave Merge mode and restore the exact entry selection.""" self._require_merge_mode() return self._apply(self._state.merge.entry_snapshot.selection) def add_to_merge(self, cluster_ids, insertion=None): - """Stage candidates, removing them from Similarity selection if necessary.""" self._require_merge_mode() - cluster_ids = _as_unique_ids(cluster_ids) current = self._state - new_ids = tuple( - cluster_id for cluster_id in cluster_ids if cluster_id not in current.merge_ids - ) - if not new_ids: + requested = _as_unique_ids(cluster_ids) + new = tuple(cluster_id for cluster_id in requested if cluster_id not in current.merge_ids) + if not new: return self._apply(current) - ordered_ids = list(current.merge_ids) - if insertion is None: - insertion = len(ordered_ids) - if not 1 <= insertion <= len(ordered_ids): + ids = list(current.merge_ids) + insertion = len(ids) if insertion is None else insertion + if not 1 <= insertion <= len(ids): raise ValueError('Merge insertion must follow the fixed reference.') - ordered_ids[insertion:insertion] = new_ids - merge = MergeSession( - current.reference_id, - tuple(ordered_ids), - current.merge.entry_snapshot, - ) - similar_ids = tuple( - cluster_id for cluster_id in current.similar_ids if cluster_id not in new_ids - ) - after = CurationSelectionState( - mode=WorkflowMode.MERGE, - similar_ids=similar_ids, - reference_id=current.reference_id, - merge=merge, - color_order=self._next_color_order( - current.reference_id, _ordered_union(merge.ordered_ids, similar_ids) - ), + ids[insertion:insertion] = new + merge = MergeSession(current.reference_id, tuple(ids), current.merge.entry_snapshot) + similar = tuple(cluster_id for cluster_id in current.similar_ids if cluster_id not in new) + effective = _ordered_union(merge.ordered_ids, similar) + return self._apply( + CurationSelectionState( + mode=WorkflowMode.MERGE, + similar_ids=similar, + reference_id=current.reference_id, + merge=merge, + color_slots=self._merge_slots(effective), + ) ) - return self._apply(after) def remove_from_merge(self, cluster_ids): - """Return staged non-reference candidates to the Similarity selection.""" self._require_merge_mode() - cluster_ids = _as_unique_ids(cluster_ids) current = self._state - if current.reference_id in cluster_ids: + removed = _as_unique_ids(cluster_ids) + if current.reference_id in removed: raise ValueError('The merge reference cannot be removed.') - if not set(cluster_ids) <= set(current.merge_ids): + if not set(removed) <= set(current.merge_ids): raise ValueError('Removed IDs must belong to the merge session.') - remaining = tuple( - cluster_id for cluster_id in current.merge_ids if cluster_id not in cluster_ids + merge = MergeSession( + current.reference_id, + tuple(i for i in current.merge_ids if i not in removed), + current.merge.entry_snapshot, ) - merge = MergeSession(current.reference_id, remaining, current.merge.entry_snapshot) - after = CurationSelectionState( - mode=WorkflowMode.MERGE, - similar_ids=_ordered_union(current.similar_ids, cluster_ids), - reference_id=current.reference_id, - merge=merge, - color_order=self._next_color_order( - current.reference_id, _ordered_union(merge.ordered_ids, current.similar_ids) - ), + similar = _ordered_union(current.similar_ids, removed) + effective = _ordered_union(merge.ordered_ids, similar) + return self._apply( + CurationSelectionState( + mode=WorkflowMode.MERGE, + similar_ids=similar, + reference_id=current.reference_id, + merge=merge, + color_slots=self._merge_slots(effective), + ) ) - return self._apply(after) def reorder_merge(self, cluster_ids, insertion): - """Move staged candidates to an insertion point.""" self._require_merge_mode() - cluster_ids = _as_unique_ids(cluster_ids) current = self._state - if current.reference_id in cluster_ids: + moving = _as_unique_ids(cluster_ids) + if current.reference_id in moving: raise ValueError('The merge reference cannot be reordered.') - if not set(cluster_ids) <= set(current.merge_ids): + if not set(moving) <= set(current.merge_ids): raise ValueError('Reordered IDs must belong to the merge session.') - remaining = [ - cluster_id for cluster_id in current.merge_ids if cluster_id not in cluster_ids - ] - if not 1 <= insertion <= len(remaining): + remain = [i for i in current.merge_ids if i not in moving] + if not 1 <= insertion <= len(remain): raise ValueError('Merge insertion must follow the fixed reference.') - remaining[insertion:insertion] = cluster_ids - merge = MergeSession(current.reference_id, tuple(remaining), current.merge.entry_snapshot) - after = CurationSelectionState( - mode=WorkflowMode.MERGE, - similar_ids=current.similar_ids, - reference_id=current.reference_id, - merge=merge, - color_order=self._next_color_order( - current.reference_id, _ordered_union(merge.ordered_ids, current.similar_ids) - ), + remain[insertion:insertion] = moving + merge = MergeSession(current.reference_id, tuple(remain), current.merge.entry_snapshot) + return self._apply( + CurationSelectionState( + mode=WorkflowMode.MERGE, + similar_ids=current.similar_ids, + reference_id=current.reference_id, + merge=merge, + color_slots=current.color_slots, + ) ) - return self._apply(after) + + def _slots_for_normal_roles(self, clusters, similar, reference, order): + current = self._state + if reference != current.reference_id: + return tuple(order) + slots = list(current.color_slots) + active = set(clusters) | set(similar) + # Removed Cluster IDs are not Similarity reservations. + for index, cluster_id in enumerate(slots): + if cluster_id in current.cluster_ids and cluster_id not in active: + slots[index] = None + return self._preserve_and_allocate( + tuple(slots), _ordered_union(clusters, similar), primary_ids=clusters + ) + + def _replace_similarity_slots(self, similar): + current = self._state + primary = set(current.cluster_ids) + slots = list(current.color_slots) + for index, cluster_id in enumerate(slots): + if cluster_id not in primary: + slots[index] = None + return self._preserve_and_allocate(tuple(slots), similar, primary_ids=current.cluster_ids) + + def _preserve_and_allocate(self, slots, ids, primary_ids=()): + slots = list(slots) + existing = {cluster_id for cluster_id in slots if cluster_id is not None} + primary = set(primary_ids) + start = ( + max((i for i, cluster_id in enumerate(slots) if cluster_id in primary), default=-1) + 1 + ) + for cluster_id in ids: + if cluster_id in existing: + continue + hole = next((i for i in range(start, len(slots)) if slots[i] is None), None) + if hole is None: + slots.append(cluster_id) + else: + slots[hole] = cluster_id + existing.add(cluster_id) + return tuple(slots) + + def _merge_slots(self, effective): + return self._preserve_and_allocate(self._state.color_slots, effective) def _require_normal_mode(self): if self._state.is_merge_mode: @@ -460,48 +489,6 @@ def _require_merge_mode(self): if not self._state.is_merge_mode: raise RuntimeError('This operation requires Merge mode.') - def _next_color_order(self, reference_id, presentation_order): - """Return the reference-scoped registry for the next selection state.""" - current = self._state - if reference_id != current.reference_id: - return tuple(presentation_order) - return _ordered_union(current.color_order, presentation_order) - - def _navigation_color_order(self, similar_ids): - """Give a replacement wizard candidate the outgoing candidate's slot.""" - current = self._state - if not similar_ids: - return current.color_order - candidate = similar_ids[0] - color_order = list(current.color_order) - outgoing_slots = [ - color_order.index(cluster_id) - for cluster_id in current.similar_ids - if cluster_id in color_order - ] - if outgoing_slots: - target = min(outgoing_slots) - else: - primary_ids = set(current.cluster_ids) - target = next( - ( - index - for index, cluster_id in enumerate(color_order) - if cluster_id not in primary_ids - ), - len(color_order), - ) - if candidate in color_order: - source = color_order.index(candidate) - color_order[target], color_order[source] = color_order[source], color_order[target] - elif target < len(color_order): - displaced = color_order[target] - color_order[target] = candidate - color_order.append(displaced) - else: - color_order.append(candidate) - return tuple(color_order) - def _apply(self, after): before = self._state self._state = after diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index 0d311654..7953b07e 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -4,6 +4,8 @@ from pytest import raises +from phy.utils.selection import SelectionIntent, SelectionMutation + from .._selection import ( CurationSelectionController, CurationSelectionState, @@ -35,13 +37,13 @@ def test_state_rejects_invalid_ids_reference_and_presentation(): ) with raises(ValueError, match='requires a merge session'): CurationSelectionState(mode=WorkflowMode.MERGE) - with raises(ValueError, match='Color order'): + with raises(ValueError, match='Color slots'): CurationSelectionState(cluster_ids=(1, 2), color_order=(1,)) with raises(ValueError, match='first color'): CurationSelectionState(cluster_ids=(1, 2), color_order=(2, 1)) with raises(ValueError, match='Similarity selection'): CurationSelectionState(similar_ids=(2,)) - with raises(ValueError, match='Color order'): + with raises(ValueError, match='Color slots'): CurationSelectionState(color_order=(2,)) @@ -100,19 +102,25 @@ def test_set_similarity_and_clear_similarity_selection(): assert change.after.presentation_order == (1,) -def test_similarity_deselection_and_reselection_preserve_color_slots(): +def test_toggle_deselection_reserves_and_reselection_restores_color_slots(): controller = CurationSelectionController( CurationSelectionState(cluster_ids=(1,), reference_id=1) ) - controller.set_similarity_selection((2, 3, 4)) - color_order = controller.state.color_order - change = controller.set_similarity_selection((2, 4)) + controller.apply_similarity_mutation( + SelectionMutation(SelectionIntent.EXTEND, (), (2, 3, 4), (2, 3, 4), ()) + ) + slots = controller.state.color_slots + change = controller.apply_similarity_mutation( + SelectionMutation(SelectionIntent.TOGGLE, (2, 3, 4), (2, 4), (), (3,)) + ) - assert change.after.color_order == color_order + assert change.after.color_slots == slots assert not change.colors_changed - change = controller.set_similarity_selection((2, 3, 4)) - assert change.after.color_order == color_order + change = controller.apply_similarity_mutation( + SelectionMutation(SelectionIntent.TOGGLE, (2, 4), (2, 3, 4), (3,), ()) + ) + assert change.after.color_slots == slots assert not change.colors_changed @@ -129,15 +137,15 @@ def test_similarity_navigation_reuses_outgoing_or_inactive_color_slot(): change = controller.navigate_similarity_selection((3,)) assert change.after.similar_ids == (3,) assert change.after.presentation_order == (1, 3) - assert change.after.color_order == (1, 3, 2) + assert change.after.color_slots == (1, 3, None) assert change.colors_changed change = controller.navigate_similarity_selection((2,)) - assert change.after.color_order == (1, 2, 3) + assert change.after.color_slots == (1, 2, None) controller.clear_similarity_selection() change = controller.navigate_similarity_selection((4,)) - assert change.after.color_order == (1, 4, 3, 2) + assert change.after.color_slots == (1, 4, None) def test_similarity_navigation_preserves_primary_colors_and_is_normal_only(): @@ -151,7 +159,7 @@ def test_similarity_navigation_preserves_primary_colors_and_is_normal_only(): ) change = controller.navigate_similarity_selection((3,)) - assert change.after.color_order == (1, 4, 3, 2) + assert change.after.color_slots == (1, 4, 3, None) with raises(ValueError, match='at most one'): controller.navigate_similarity_selection((2, 3)) @@ -342,3 +350,82 @@ def test_merge_presentation_order_requires_merge_prefix_and_similarity_tail(): controller.set_presentation_order((1, 3, 2)) with raises(ValueError, match='exactly'): controller.set_presentation_order((1, 2, 4)) + + +def test_replace_and_navigate_reuse_first_similarity_slot_after_cluster_roles(): + controller = CurationSelectionController( + CurationSelectionState(cluster_ids=(1, 8), reference_id=1) + ) + + controller.apply_similarity_mutation( + SelectionMutation(SelectionIntent.REPLACE, (), (5,), (5,), ()) + ) + assert controller.state.color_slots == (1, 8, 5) + controller.apply_similarity_mutation( + SelectionMutation(SelectionIntent.REPLACE, (5,), (7,), (7,), (5,)) + ) + assert controller.state.color_slots == (1, 8, 7) + controller.apply_similarity_mutation( + SelectionMutation(SelectionIntent.NAVIGATE, (7,), (6,), (6,), (7,)) + ) + assert controller.state.color_slots == (1, 8, 6) + + +def test_extend_fills_released_holes_and_exposes_immutable_palette_projection(): + controller = CurationSelectionController( + CurationSelectionState(cluster_ids=(1,), reference_id=1, color_slots=(1, None, None)) + ) + + controller.apply_similarity_mutation( + SelectionMutation(SelectionIntent.EXTEND, (), (4, 5), (4, 5), ()) + ) + assert controller.state.color_slots == (1, 4, 5) + assert dict(controller.state.color_indices) == {1: 0, 4: 1, 5: 2} + with raises(TypeError): + controller.state.color_indices[4] = 9 + + +def test_clear_releases_similarity_reservations_for_the_next_replace(): + controller = CurationSelectionController( + CurationSelectionState(cluster_ids=(1,), reference_id=1) + ) + controller.apply_similarity_mutation( + SelectionMutation(SelectionIntent.EXTEND, (), (2, 3), (2, 3), ()) + ) + controller.apply_similarity_mutation( + SelectionMutation(SelectionIntent.TOGGLE, (2, 3), (2,), (), (3,)) + ) + controller.apply_similarity_mutation( + SelectionMutation(SelectionIntent.CLEAR, (2,), (), (), (2,)) + ) + assert controller.state.color_slots == (1, None, None) + controller.apply_similarity_mutation( + SelectionMutation(SelectionIntent.REPLACE, (), (9,), (9,), ()) + ) + assert controller.state.color_slots == (1, 9, None) + + +def test_merge_freezes_existing_bindings_and_unseen_candidates_fill_holes(): + initial = CurationSelectionState( + cluster_ids=(1, 2), + similar_ids=(3,), + reference_id=1, + color_slots=(1, 2, 3, None), + ) + controller = CurationSelectionController(initial) + controller.enter_merge_mode() + entry_slots = controller.state.color_slots + controller.apply_similarity_mutation( + SelectionMutation(SelectionIntent.REPLACE, (), (4,), (4,), ()) + ) + assert controller.state.color_slots == (1, 2, 3, 4) + controller.add_to_merge((4,)) + controller.reorder_merge((4,), 1) + controller.remove_from_merge((2,)) + assert controller.state.color_indices[1] == 0 + assert controller.state.color_indices[2] == 1 + assert controller.state.color_indices[3] == 2 + assert controller.state.color_indices[4] == 3 + controller.cancel_merge_mode() + assert controller.state == initial + assert entry_slots == initial.color_slots From 53272e925ac0b095b17d1c42ea711532d40779a4 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:51:24 +0200 Subject: [PATCH 077/110] refactor: reduce selection colors by intent --- phy/cluster/_selection.py | 60 +++++----------------------- phy/cluster/supervisor.py | 43 ++++++++++++-------- phy/cluster/tests/test_selection.py | 46 +++++++++++---------- phy/cluster/tests/test_supervisor.py | 36 ++++++++++++----- phy/gui/widgets.py | 21 +++++----- phy/utils/selection.py | 15 +++++++ 6 files changed, 110 insertions(+), 111 deletions(-) diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index 443c93da..d0852870 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -56,8 +56,7 @@ class CurationSelectionState: ``color_slots`` is deliberately not derived from presentation order: a ``None`` entry is a released palette slot and a non-active ID is a reserved - binding. ``color_order`` is a temporary legacy projection for callers - which still expect a compact sequence. + binding. """ mode: WorkflowMode = WorkflowMode.NORMAL @@ -66,7 +65,6 @@ class CurationSelectionState: reference_id: int | None = None presentation_order: tuple[int, ...] | None = None color_slots: tuple[int | None, ...] | None = None - color_order: tuple[int, ...] | None = None merge: MergeSession | None = None def __post_init__(self): @@ -114,12 +112,7 @@ def __post_init__(self): raise ValueError('Merge presentation must begin with the staged merge order.') if set(presentation[len(primary) :]) != set(similar): raise ValueError('Merge presentation tail must contain the Similarity selection.') - if self.color_slots is not None and self.color_order is not None: - raise ValueError('Specify color_slots instead of legacy color_order.') - slots = self.color_slots - if slots is None: - slots = self.color_order if self.color_order is not None else presentation - slots = tuple(slots) + slots = presentation if self.color_slots is None else tuple(self.color_slots) bindings = tuple(cluster_id for cluster_id in slots if cluster_id is not None) if len(bindings) != len(set(bindings)): raise ValueError('Color-slot bindings must be unique.') @@ -134,12 +127,6 @@ def __post_init__(self): object.__setattr__(self, 'reference_id', reference) object.__setattr__(self, 'presentation_order', presentation) object.__setattr__(self, 'color_slots', slots) - # Compatibility only: consumers needing palette indices must use color_indices. - object.__setattr__( - self, - 'color_order', - tuple(cluster_id for cluster_id in slots if cluster_id is not None), - ) @property def color_indices(self): @@ -261,6 +248,8 @@ def apply_similarity_mutation(self, mutation, presentation_order=None): if mutation.before_ids != current.similar_ids: raise ValueError('Mutation before_ids do not match the current Similarity selection.') similar = mutation.after_ids + if mutation.intent is SelectionIntent.NAVIGATE and len(similar) > 1: + raise ValueError('Similarity navigation selects at most one candidate.') effective = _ordered_union(current.merge_ids, similar) order = presentation_order or _ordered_union( tuple( @@ -275,7 +264,10 @@ def apply_similarity_mutation(self, mutation, presentation_order=None): SelectionIntent.NAVIGATE, SelectionIntent.CLEAR, ): - slots = self._replace_similarity_slots(similar) + ordered_similar = tuple( + cluster_id for cluster_id in order if cluster_id in set(similar) + ) + slots = self._replace_similarity_slots(ordered_similar) else: slots = self._preserve_and_allocate(current.color_slots, similar) return self._apply( @@ -290,42 +282,12 @@ def apply_similarity_mutation(self, mutation, presentation_order=None): ) ) - def set_similarity_selection(self, similar_ids, presentation_order=None): - """Legacy adapter; UI callers should supply a SelectionMutation.""" - similar = _as_unique_ids(similar_ids) - intent = SelectionIntent.CLEAR if not similar else SelectionIntent.REPLACE - before = self._state.similar_ids - return self.apply_similarity_mutation( - SelectionMutation( - intent, - before, - similar, - tuple(cluster_id for cluster_id in similar if cluster_id not in before), - tuple(cluster_id for cluster_id in before if cluster_id not in similar), - ), - presentation_order, - ) - - def navigate_similarity_selection(self, similar_ids, presentation_order=None): - self._require_normal_mode() - similar = _as_unique_ids(similar_ids) - if len(similar) > 1: - raise ValueError('Similarity navigation selects at most one candidate.') - before = self._state.similar_ids + def clear_similarity_selection(self): + current = self._state return self.apply_similarity_mutation( - SelectionMutation( - SelectionIntent.NAVIGATE, - before, - similar, - tuple(cluster_id for cluster_id in similar if cluster_id not in before), - tuple(cluster_id for cluster_id in before if cluster_id not in similar), - ), - presentation_order, + SelectionMutation.create(SelectionIntent.CLEAR, current.similar_ids, ()) ) - def clear_similarity_selection(self): - return self.set_similarity_selection(()) - def set_presentation_order(self, presentation_order): current = self._state return self._apply( diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 3ae8a9d4..7e09c809 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -19,6 +19,7 @@ from phy.gui.actions import Actions from phy.gui.qt import QAbstractItemView, QHeaderView, Qt, _block, _wait, set_busy from phy.gui.widgets import Barrier, Table, _uniq +from phy.utils.selection import SelectionIntent, SelectionMutation from ._history import GlobalHistory from ._selection import CurationSelectionController, SelectionChange @@ -431,10 +432,10 @@ def _on_row_clicked(self, index): def _on_header_clicked(self, section): """Merge order changes only through explicit reorder intents.""" - def set_merge_ids(self, cluster_ids, data, color_order): + def set_merge_ids(self, cluster_ids, data, color_indices): """Project one complete ordered Merge session.""" self.remove_all_and_add(data, fit_columns=not self._column_widths_fitted) - self.set_selected_index_order(color_order) + self.set_selected_index_mapping(color_indices) self.set_selected_ids(cluster_ids) def _drag_ids_for_index(self, index): @@ -1119,7 +1120,7 @@ def _clusters_selected(self, sender, obj, **kwargs): cluster_ids = obj['selected'] next_cluster = obj['next'] kwargs = dict(obj.get('kwargs', {})) - kwargs.pop('_selection_intent', None) + kwargs.pop('_selection_mutation', None) logger.debug('Clusters selected: %s (%s)', cluster_ids, next_cluster) change = self.selection.set_normal_selection(cluster_ids) change = self._set_table_presentation_order(change) @@ -1149,15 +1150,17 @@ def _similar_selected(self, sender, obj): similar = obj['selected'] next_similar = obj['next'] kwargs = dict(obj.get('kwargs', {})) - selection_intent = kwargs.pop('_selection_intent', None) + mutation = kwargs.pop('_selection_mutation', None) + if mutation is None: + intent = SelectionIntent.CLEAR if not similar else SelectionIntent.REPLACE + mutation = SelectionMutation.create(intent, self.selection.state.similar_ids, similar) + elif mutation.after_ids != tuple(similar): + raise ValueError('Similarity mutation does not match the table selection payload.') logger.debug('Similar clusters selected: %s (%s)', similar, next_similar) presentation_order = self._presentation_order_from_tables( self.selection.state, similar_ids=similar ) - if selection_intent == 'navigation' and not self.selection.state.is_merge_mode: - self.selection.navigate_similarity_selection(similar, presentation_order) - else: - self.selection.set_similarity_selection(similar, presentation_order) + self.selection.apply_similarity_mutation(mutation, presentation_order) self._update_selection_colors() self._project_merge_view() self.task_logger.log(self.similarity_view, 'select', similar, output=obj) @@ -1232,18 +1235,18 @@ def _table_order_changed(self, sender, row_ids): def _update_selection_colors(self): """Project stable selection-color positions into all workflow tables.""" state = self.selection.state - order = state.color_order - self.cluster_view.set_selected_index_order(order) - self.similarity_view.set_selected_index_order(order) + color_indices = state.color_indices + self.cluster_view.set_selected_index_mapping(color_indices) + self.similarity_view.set_selected_index_mapping(color_indices) if self.merge_view is not None: - self.merge_view.set_selected_index_order(order) + self.merge_view.set_selected_index_mapping(color_indices) def _project_merge_view(self): state = self.selection.state if self.merge_view is None or not state.is_merge_mode: return data = [self.get_cluster_info(cluster_id) for cluster_id in state.merge_ids] - self.merge_view.set_merge_ids(state.merge_ids, data, state.color_order) + self.merge_view.set_merge_ids(state.merge_ids, data, state.color_indices) self.merge_view.dock.set_status(self._merge_status_text()) def _merge_status_text(self): @@ -1519,7 +1522,13 @@ def _after_action(self, sender, up): self.selection.state.reference_id is not None and tuple(similar_ids) != self.selection.state.similar_ids ): - self.selection.set_similarity_selection(similar_ids) + self.selection.apply_similarity_mutation( + SelectionMutation.create( + SelectionIntent.TOGGLE, + self.selection.state.similar_ids, + similar_ids, + ) + ) # After the action has finished, we process the pending actions, # like selection of new clusters in the tables. self.task_logger.process() @@ -1745,9 +1754,9 @@ def selected(self): return list(self.selection.state.presentation_order) @property - def selection_color_order(self): - """Cluster IDs in their stable selected-color slots.""" - return self.selection.state.color_order + def selection_color_indices(self): + """Immutable mapping from cluster IDs to stable selected-color slots.""" + return self.selection.state.color_indices def n_spikes(self, cluster_id): """Number of spikes in a given cluster.""" diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index 7953b07e..0e9a6c38 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -15,6 +15,12 @@ ) +def _mutate_similarity(controller, after_ids, intent=SelectionIntent.REPLACE): + return controller.apply_similarity_mutation( + SelectionMutation.create(intent, controller.state.similar_ids, after_ids) + ) + + def test_state_derives_unique_effective_and_presentation_ids(): state = CurationSelectionState(cluster_ids=(3, 1), similar_ids=(1, 2)) @@ -38,13 +44,13 @@ def test_state_rejects_invalid_ids_reference_and_presentation(): with raises(ValueError, match='requires a merge session'): CurationSelectionState(mode=WorkflowMode.MERGE) with raises(ValueError, match='Color slots'): - CurationSelectionState(cluster_ids=(1, 2), color_order=(1,)) + CurationSelectionState(cluster_ids=(1, 2), color_slots=(1,)) with raises(ValueError, match='first color'): - CurationSelectionState(cluster_ids=(1, 2), color_order=(2, 1)) + CurationSelectionState(cluster_ids=(1, 2), color_slots=(2, 1)) with raises(ValueError, match='Similarity selection'): CurationSelectionState(similar_ids=(2,)) with raises(ValueError, match='Color slots'): - CurationSelectionState(color_order=(2,)) + CurationSelectionState(color_slots=(2,)) def test_state_is_immutable(): @@ -82,7 +88,7 @@ def test_empty_cluster_selection_clears_similarity_and_color_session(): assert change.after.similar_ids == () assert change.after.reference_id is None assert change.after.presentation_order == () - assert change.after.color_order == () + assert change.after.color_slots == () def test_set_similarity_and_clear_similarity_selection(): @@ -90,7 +96,7 @@ def test_set_similarity_and_clear_similarity_selection(): CurationSelectionState(cluster_ids=(1,), reference_id=1) ) - change = controller.set_similarity_selection((3, 2)) + change = _mutate_similarity(controller, (3, 2)) assert change.after.effective_ids == (1, 3, 2) assert change.after.presentation_order == (1, 3, 2) assert change.roles_changed @@ -130,42 +136,38 @@ def test_similarity_navigation_reuses_outgoing_or_inactive_color_slot(): cluster_ids=(1,), similar_ids=(2,), reference_id=1, - color_order=(1, 2, 3), + color_slots=(1, 2, 3), ) ) - change = controller.navigate_similarity_selection((3,)) + change = _mutate_similarity(controller, (3,), SelectionIntent.NAVIGATE) assert change.after.similar_ids == (3,) assert change.after.presentation_order == (1, 3) assert change.after.color_slots == (1, 3, None) assert change.colors_changed - change = controller.navigate_similarity_selection((2,)) + change = _mutate_similarity(controller, (2,), SelectionIntent.NAVIGATE) assert change.after.color_slots == (1, 2, None) controller.clear_similarity_selection() - change = controller.navigate_similarity_selection((4,)) + change = _mutate_similarity(controller, (4,), SelectionIntent.NAVIGATE) assert change.after.color_slots == (1, 4, None) -def test_similarity_navigation_preserves_primary_colors_and_is_normal_only(): +def test_similarity_navigation_preserves_primary_colors_and_selects_at_most_one(): controller = CurationSelectionController( CurationSelectionState( cluster_ids=(1, 4), similar_ids=(2,), reference_id=1, - color_order=(1, 4, 2, 3), + color_slots=(1, 4, 2, 3), ) ) - change = controller.navigate_similarity_selection((3,)) + change = _mutate_similarity(controller, (3,), SelectionIntent.NAVIGATE) assert change.after.color_slots == (1, 4, 3, None) with raises(ValueError, match='at most one'): - controller.navigate_similarity_selection((2, 3)) - - controller.enter_merge_mode() - with raises(RuntimeError, match='unavailable'): - controller.navigate_similarity_selection((5,)) + _mutate_similarity(controller, (2, 3), SelectionIntent.NAVIGATE) def test_set_normal_selection_replaces_all_roles_atomically(): @@ -184,7 +186,7 @@ def test_snapshot_restore_and_noop_change_classification(): CurationSelectionState(cluster_ids=(1,), similar_ids=(2,), reference_id=1) ) snapshot = controller.snapshot() - controller.set_similarity_selection((3,)) + _mutate_similarity(controller, (3,)) change = controller.restore(snapshot) assert change.before.similar_ids == (3,) @@ -283,7 +285,7 @@ def test_merge_candidate_transfer_and_reorder_follow_visible_role_order(): ) controller.enter_merge_mode() - change = controller.set_similarity_selection((4, 5)) + change = _mutate_similarity(controller, (4, 5)) assert change.after.presentation_order == (1, 2, 3, 4, 5) change = controller.add_to_merge((4,)) @@ -321,7 +323,7 @@ def test_merge_candidate_guards_reference_and_duplicate_membership(): def test_presentation_order_transition_preserves_roles_and_colors(): controller = CurationSelectionController( CurationSelectionState( - cluster_ids=(1, 2), similar_ids=(3,), reference_id=1, color_order=(1, 2, 3) + cluster_ids=(1, 2), similar_ids=(3,), reference_id=1, color_slots=(1, 2, 3) ) ) @@ -332,7 +334,7 @@ def test_presentation_order_transition_preserves_roles_and_colors(): assert not change.colors_changed assert change.after.cluster_ids == (1, 2) assert change.after.similar_ids == (3,) - assert change.after.color_order == (1, 2, 3) + assert change.after.color_slots == (1, 2, 3) with raises(ValueError, match='exactly'): controller.set_presentation_order((1, 2)) @@ -340,7 +342,7 @@ def test_presentation_order_transition_preserves_roles_and_colors(): def test_merge_presentation_order_requires_merge_prefix_and_similarity_tail(): controller = CurationSelectionController(CurationSelectionState(cluster_ids=(1, 2))) controller.enter_merge_mode() - controller.set_similarity_selection((3,)) + _mutate_similarity(controller, (3,)) change = controller.set_presentation_order((1, 2, 3)) diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index e4a8048e..fc8242c4 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -611,12 +611,14 @@ def test_merge_mode_next_navigates_similarity_not_cluster(supervisor): assert supervisor.selected_clusters == [] assert len(supervisor.selected_similar) == 1 first_candidate = supervisor.selected_similar[0] - first_colors = supervisor.selection_color_order + first_colors = dict(supervisor.selection_color_indices) supervisor.next() supervisor.block() - assert supervisor.selection_color_order[: len(first_colors)] == first_colors + assert { + cluster_id: supervisor.selection_color_indices[cluster_id] for cluster_id in first_colors + } == first_colors assert supervisor.selected_similar != [first_candidate] @@ -662,7 +664,7 @@ def on_select(sender, cluster_ids): assert supervisor.merge_view is not None assert supervisor.actions.get('redo').isEnabled() assert events[-1] == list(merge_before.presentation_order) - assert supervisor.selection_color_order == merge_before.color_order + assert dict(supervisor.selection_color_indices) == dict(merge_before.color_indices) assert { cluster_id: ( supervisor.merge_view._selected_color_index(cluster_id) @@ -899,12 +901,12 @@ def test_normal_similarity_insertion_does_not_recolor_existing_rows(supervisor): similarity_view.select([1]) supervisor.block() - similarity_view.select([1, 20]) + similarity_view.select_toggle(20) supervisor.block() color_before = similarity_view._selected_color_index(20) # Insert 11 before 20 in visible row order without changing 20's color slot. - similarity_view.select([1, 20, 11]) + similarity_view.select_toggle(11) supervisor.block() assert supervisor.selected == [30, 1, 11, 20] @@ -912,6 +914,18 @@ def test_normal_similarity_insertion_does_not_recolor_existing_rows(supervisor): assert similarity_view._selected_color_index(11) > color_before +def test_direct_similarity_replacements_reuse_first_candidate_color(supervisor): + _select(supervisor, [30]) + similarity_view = supervisor.similarity_view + + for candidate in (20, 1, 11): + similarity_view.select([candidate]) + supervisor.block() + + assert supervisor.selected_similar == [candidate] + assert similarity_view._selected_color_index(candidate) == 1 + + def test_normal_similarity_deselection_and_reselection_preserve_color_slots(supervisor): _select(supervisor, [30]) similarity_view = supervisor.similarity_view @@ -922,13 +936,13 @@ def test_normal_similarity_deselection_and_reselection_preserve_color_slots(supe cluster_id: similarity_view._selected_color_index(cluster_id) for cluster_id in (1, 11, 20) } - similarity_view.select([1, 20]) + similarity_view.select_toggle(11) supervisor.block() assert { cluster_id: similarity_view._selected_color_index(cluster_id) for cluster_id in (1, 20) } == {cluster_id: colors_before[cluster_id] for cluster_id in (1, 20)} - similarity_view.select([1, 11, 20]) + similarity_view.select_toggle(11) supervisor.block() assert { cluster_id: similarity_view._selected_color_index(cluster_id) for cluster_id in (1, 11, 20) @@ -962,7 +976,7 @@ def test_table_filter_reorders_normal_presentation_without_recoloring(supervisor similarity_view.select([1, 20, 11]) supervisor.block() assert supervisor.selected == [30, 1, 11, 20] - colors = supervisor.selection_color_order + colors = dict(supervisor.selection_color_indices) roles = (supervisor.selected_clusters, supervisor.selected_similar) events = [] @@ -975,7 +989,7 @@ def on_select(sender, cluster_ids): similarity_view.filter('id < 2') assert supervisor.selected == [30, 1, 11, 20] - assert supervisor.selection_color_order == colors + assert dict(supervisor.selection_color_indices) == colors assert (supervisor.selected_clusters, supervisor.selected_similar) == roles assert events == [] unconnect(on_select) @@ -989,7 +1003,7 @@ def test_table_filter_reorders_merge_similarity_tail_without_recoloring(supervis similarity_view.select([1, 20, 11]) supervisor.block() assert supervisor.selected == [30, 1, 11, 20] - colors = supervisor.selection_color_order + colors = dict(supervisor.selection_color_indices) roles = (supervisor.selected_merge, supervisor.selected_similar) events = [] @@ -1000,7 +1014,7 @@ def on_select(sender, cluster_ids): similarity_view.filter('id < 2') assert supervisor.selected == [30, 1, 11, 20] - assert supervisor.selection_color_order == colors + assert dict(supervisor.selection_color_indices) == colors assert (supervisor.selected_merge, supervisor.selected_similar) == roles assert events == [] unconnect(on_select) diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index c7da045b..a9b59b2f 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -11,6 +11,7 @@ import logging import re import sys +from collections.abc import Mapping from contextlib import contextmanager from functools import partial @@ -1216,15 +1217,7 @@ def _selection_mutation(self, before_ids, intent): """Describe the selection operation that just updated this table.""" before_ids = tuple(before_ids) after_ids = tuple(self.get_selected_ids()) - before_set = set(before_ids) - after_set = set(after_ids) - return SelectionMutation( - intent=intent, - before_ids=before_ids, - after_ids=after_ids, - added_ids=tuple(row_id for row_id in after_ids if row_id not in before_set), - removed_ids=tuple(row_id for row_id in before_ids if row_id not in after_set), - ) + return SelectionMutation.create(intent, before_ids, after_ids) def _emit_selected(self, kwargs=None, mutation=None): self._selection_revision += 1 @@ -1581,9 +1574,13 @@ def set_selected_index_offset(self, n): self._selected_index_by_id = None self.table_view.viewport().update() - def set_selected_index_order(self, ids): - """Set stable positional-color indices independently of table-role order.""" - self._selected_index_by_id = {row_id: index for index, row_id in enumerate(_uniq(ids))} + def set_selected_index_mapping(self, color_indices): + """Set explicit selected-row palette indices independently of role order.""" + if not isinstance(color_indices, Mapping): + raise TypeError('Selected color indices must be a mapping.') + if any(not _is_integer(index) or index < 0 for index in color_indices.values()): + raise ValueError('Selected color indices must be non-negative integers.') + self._selected_index_by_id = dict(color_indices) self.table_view.viewport().update() def clear_temporary_files(self): diff --git a/phy/utils/selection.py b/phy/utils/selection.py index 89e1add6..8cbe7824 100644 --- a/phy/utils/selection.py +++ b/phy/utils/selection.py @@ -31,6 +31,21 @@ class SelectionMutation: added_ids: tuple[int, ...] removed_ids: tuple[int, ...] + @classmethod + def create(cls, intent, before_ids, after_ids): + """Build a mutation and derive its ordered added/removed IDs.""" + before_ids = tuple(before_ids) + after_ids = tuple(after_ids) + before_set = set(before_ids) + after_set = set(after_ids) + return cls( + intent=intent, + before_ids=before_ids, + after_ids=after_ids, + added_ids=tuple(row_id for row_id in after_ids if row_id not in before_set), + removed_ids=tuple(row_id for row_id in before_ids if row_id not in after_set), + ) + def __post_init__(self): if not isinstance(self.intent, SelectionIntent): raise TypeError('intent must be a SelectionIntent.') From 85e5a016c64a525df0a8195ee42a55fb01cc5eb2 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:53:10 +0200 Subject: [PATCH 078/110] test: cover explicit table color mappings --- phy/gui/tests/test_widgets.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index fa18854a..c5e9a7ef 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -410,6 +410,18 @@ def on_select(sender, payload): unconnect(on_select) +def test_table_uses_explicit_selected_color_mapping(table): + table.set_selected_ids([1, 3]) + table.set_selected_index_mapping({1: 1, 3: 4}) + + assert table._selected_color_index(1) == 1 + assert table._selected_color_index(3) == 4 + with raises(TypeError, match='mapping'): + table.set_selected_index_mapping([1, 3]) + with raises(ValueError, match='non-negative'): + table.set_selected_index_mapping({1: -1}) + + def test_table_batch_update_fits_once(table): fit_calls = [] table._fit_columns = lambda: fit_calls.append(True) From 79d590b5b9811f8b2144be840b000315bdefe234 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:54:12 +0200 Subject: [PATCH 079/110] docs: explain intent-driven selection colors --- design/merge-view-architecture.md | 25 ++++++++------ design/selection-order-color-refactor.md | 9 ++--- docs/api.md | 44 ++++++++++++------------ docs/changelog.md | 19 +++++----- docs/clustering.md | 16 +++++---- 5 files changed, 59 insertions(+), 54 deletions(-) diff --git a/design/merge-view-architecture.md b/design/merge-view-architecture.md index ecd971e5..81b08408 100644 --- a/design/merge-view-architecture.md +++ b/design/merge-view-architecture.md @@ -80,14 +80,14 @@ user-visible consequence must be reviewed during implementation. ### 2.4 Color slots are independent of presentation order -The Supervisor owns both an explicit `presentation_order` and an independent -cluster-to-color order. Normal-mode presentation follows the visible role -tables; Merge ordering is modeled separately and takes precedence while active. -Normal selection additions receive a new color slot without recoloring existing -clusters. In Merge mode, slots are retained across transfers, reordering, +The authoritative selection state owns both an explicit `presentation_order` +and independent `color_slots`. Normal-mode presentation follows the visible +role tables; Merge ordering is modeled separately and takes precedence while +active. Normal color transitions depend on structured selection intent. In +Merge mode, existing bindings are retained across transfers, reordering, temporary deselection, and reselection. Built-in scientific views resolve their -positional palette index through that mapping while retaining presentation order -for layout. +positional palette index through that mapping while retaining presentation +order for layout. ### 2.5 History lacks orchestration context @@ -182,6 +182,7 @@ class CurationSelectionState: similar_ids: tuple[int, ...] reference_id: int | None presentation_order: tuple[int, ...] + color_slots: tuple[int | None, ...] merge: MergeSession | None ``` @@ -196,8 +197,9 @@ state.is_merge_mode In Normal mode, effective membership is Cluster plus Similarity membership. In Merge mode, it is Merge plus Similarity membership. `presentation_order` is the -ordered unique list emitted to scientific views. The Supervisor separately -retains the cross-view color-slot order. +ordered unique list emitted to scientific views. `color_slots` independently +stores explicit cluster-to-palette bindings, including released holes and +reserved inactive bindings. ### 4.3 Merge session @@ -227,6 +229,7 @@ class SelectionChange: after: CurationSelectionState roles_changed: bool presentation_changed: bool + colors_changed: bool reference_changed: bool mode_changed: bool ``` @@ -234,7 +237,7 @@ class SelectionChange: This diff determines which observers need work: - `roles_changed`: update Cluster, Similarity, and Merge projections; -- `presentation_changed`: emit the legacy public `select` event; +- `presentation_changed` or `colors_changed`: emit the public `select` event; - `reference_changed`: recompute Similarity candidates; and - `mode_changed`: update action availability and enabled views. @@ -252,7 +255,7 @@ add_to_merge(cluster_ids, insertion=None) remove_from_merge(cluster_ids) reorder_merge(cluster_id, insertion) set_cluster_selection(cluster_ids) -set_similarity_selection(cluster_ids) +apply_similarity_mutation(mutation) clear_similarity_selection() ``` diff --git a/design/selection-order-color-refactor.md b/design/selection-order-color-refactor.md index 7d56eaf9..6027ad3f 100644 --- a/design/selection-order-color-refactor.md +++ b/design/selection-order-color-refactor.md @@ -174,12 +174,9 @@ render_changed # presentation_changed or colors_changed ### 6.1 Snapshot simplification -`NormalWorkflowSnapshot` should store the complete immutable Normal selection -state plus opaque table workflow context, rather than duplicate individual -selection fields. If changing the dataclass layout in the first implementation -step would make the migration unnecessarily risky, adding `color_order` to the -existing snapshot is an acceptable intermediate commit; the duplicated fields -must still be removed before completing the refactor. +`NormalWorkflowSnapshot` stores the complete immutable Normal selection state, +including explicit color slots, plus opaque table workflow context. It does not +duplicate individual selection fields. ## 7. Supervisor responsibilities diff --git a/docs/api.md b/docs/api.md index 0bb0af94..48bdfd0f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2019,21 +2019,21 @@ Project selected row IDs without emitting a selection event. --- -#### Table.set_selected_index_offset - +#### Table.set_selected_index_mapping -**`Table.set_selected_index_offset(self, n)`** +**`Table.set_selected_index_mapping(self, color_indices)`** +Set explicit selected-row palette indices independently of role order. --- -#### Table.set_selected_index_order +#### Table.set_selected_index_offset + +**`Table.set_selected_index_offset(self, n)`** -**`Table.set_selected_index_order(self, ids)`** -Set stable positional-color indices independently of table-role order. --- @@ -7011,21 +7011,21 @@ Project selected row IDs without emitting a selection event. --- -#### ClusterView.set_selected_index_offset +#### ClusterView.set_selected_index_mapping -**`ClusterView.set_selected_index_offset(self, n)`** - +**`ClusterView.set_selected_index_mapping(self, color_indices)`** +Set explicit selected-row palette indices independently of role order. --- -#### ClusterView.set_selected_index_order +#### ClusterView.set_selected_index_offset + +**`ClusterView.set_selected_index_offset(self, n)`** -**`ClusterView.set_selected_index_order(self, ids)`** -Set stable positional-color indices independently of table-role order. --- @@ -9945,22 +9945,22 @@ Project selected row IDs without emitting a selection event. --- -#### SimilarityView.set_selected_index_offset +#### SimilarityView.set_selected_index_mapping -**`SimilarityView.set_selected_index_offset(self, n)`** +**`SimilarityView.set_selected_index_mapping(self, color_indices)`** -Set the index of the selected cluster, used for correct coloring in the similarity -view. +Set explicit selected-row palette indices independently of role order. --- -#### SimilarityView.set_selected_index_order +#### SimilarityView.set_selected_index_offset -**`SimilarityView.set_selected_index_order(self, ids)`** +**`SimilarityView.set_selected_index_offset(self, n)`** -Set stable positional-color indices independently of table-role order. +Set the index of the selected cluster, used for correct coloring in the similarity +view. --- @@ -10407,12 +10407,12 @@ Selected clusters in the similarity view only. --- -#### Supervisor.selection_color_order +#### Supervisor.selection_color_indices -**`Supervisor.selection_color_order`** +**`Supervisor.selection_color_indices`** -Cluster IDs in their stable selected-color slots. +Immutable mapping from cluster IDs to stable selected-color slots. --- diff --git a/docs/changelog.md b/docs/changelog.md index 94be4a4b..fb2fde88 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -100,15 +100,16 @@ behavior they verify rather than listed separately. - The first, blue Cluster View selection is now the explicit Similarity reference. In Normal mode, scientific views follow the selected Cluster and Similarity rows in visible table order; sorting or filtering either table - updates that presentation without recoloring existing selections. Deselecting - and reselecting a cluster reuses its color while the blue reference remains - unchanged; choosing a new reference starts a new color session. Normal-mode - `Space`/`Shift+Space` navigation gives the replacement wizard candidate the - outgoing candidate's color, so a lone candidate remains red. In Merge mode, - explicit Merge View order takes precedence, while workflow-table colors - remain fixed for the entire session. Normal-mode cross-role mouse transfers - and cross-correlogram promotion have been removed in favor of the Merge - workspace. + updates that presentation without recoloring existing selections. Color + assignment now follows explicit selection intent: ordinary clicks and + `Space`/`Shift+Space` replace the Similarity candidate and reuse the first + candidate color, so a lone candidate remains red; Control/Shift + multi-selection preserves existing colors and reserves a toggled-off row's + color for reselection; Backspace releases Normal-mode candidate reservations. + Choosing a new reference starts a new color session. In Merge mode, explicit + Merge View order takes precedence and all existing colors remain fixed for + the entire session. Normal-mode cross-role mouse transfers and + cross-correlogram promotion have been removed in favor of the Merge workspace. - Undo and redo restore the complete selection context around merge, split, and metadata actions; redo also preserves selection-only exploration made after the original action. diff --git a/docs/clustering.md b/docs/clustering.md index f364128e..4733d2a8 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -68,10 +68,13 @@ You can move up and down in the **cluster view** with the `Up` and `Down` arrows You can move up and down in the **similarity view** with the `Space` and `Shift-space` arrows. The cluster selected in the similarity view is called the **similar cluster**. The idea is to go through every "best cluster" in the cluster view, and review the "similar clusters" in the similarity view (sorted by decreasing similarity with the best cluster). -In Normal mode, this wizard navigation replaces the current similar cluster in -its color slot. With one blue reference, the candidate therefore remains red as -you move forward or backward. Multi-selection colors and Merge-mode colors keep -their separate stable-slot behavior. +In Normal mode, ordinary single-row selection and wizard navigation both replace +the current similar cluster. With one blue reference, the replacement candidate +therefore remains red whether you click it or move with `Space`/`Shift+Space`. +Extending a selection with Control or Shift keeps existing cluster colors; +Control-deselecting and reselecting a row restores its reserved color. Backspace +clears those Normal-mode candidate reservations, so the next candidate starts +red again. Merge-mode colors remain fixed for the entire Merge session. Press `Control+Space` to select the first 15 eligible clusters currently shown in the similarity view while preserving the cluster view selection. Repeat it to select the next batch. This uses the current similarity view sorting and filtering. To choose a different number, use **Select > Select N Similar**; the chosen number becomes the shortcut's new default and is remembered across sessions. @@ -81,8 +84,9 @@ On macOS, this shortcut uses the Control key, not Command. If `Control+Space` is In Normal mode, scientific views follow the selected Cluster and Similarity rows in their visible table order, with the blue Similarity reference first. Sorting or filtering either table updates -that presentation without recoloring existing table selections. Deselecting and reselecting a row -restores its previous color while the same blue reference is active; selecting a new reference +that presentation without recoloring existing table selections. Colors follow the selection +operation rather than incidental row order: replacement starts from the first candidate color, +while Control/Shift multi-selection preserves existing assignments. Selecting a new reference starts a new color sequence. Use Merge mode when you need to collect or explicitly order candidates. For each similar cluster, you can either: From 3c24bd6f88ef62d4d04ca132f41561a7e0b3a744 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 00:59:39 +0200 Subject: [PATCH 080/110] fix: preserve filtered selection mutations --- phy/cluster/supervisor.py | 8 +++++--- phy/cluster/tests/test_supervisor.py | 24 ++++++++++++++++++++++++ phy/gui/tests/test_widgets.py | 17 +++++++++++++++++ phy/gui/widgets.py | 13 +++++++------ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 7e09c809..eb03032b 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1120,7 +1120,9 @@ def _clusters_selected(self, sender, obj, **kwargs): cluster_ids = obj['selected'] next_cluster = obj['next'] kwargs = dict(obj.get('kwargs', {})) - kwargs.pop('_selection_mutation', None) + mutation = kwargs.pop('_selection_mutation', None) + if mutation is not None: + cluster_ids = list(mutation.after_ids) logger.debug('Clusters selected: %s (%s)', cluster_ids, next_cluster) change = self.selection.set_normal_selection(cluster_ids) change = self._set_table_presentation_order(change) @@ -1154,8 +1156,8 @@ def _similar_selected(self, sender, obj): if mutation is None: intent = SelectionIntent.CLEAR if not similar else SelectionIntent.REPLACE mutation = SelectionMutation.create(intent, self.selection.state.similar_ids, similar) - elif mutation.after_ids != tuple(similar): - raise ValueError('Similarity mutation does not match the table selection payload.') + else: + similar = list(mutation.after_ids) logger.debug('Similar clusters selected: %s (%s)', similar, next_similar) presentation_order = self._presentation_order_from_tables( self.selection.state, similar_ids=similar diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index fc8242c4..6c0a6236 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -995,6 +995,30 @@ def on_select(sender, cluster_ids): unconnect(on_select) +def test_filtered_ctrl_toggle_preserves_hidden_selection_and_colors(supervisor): + _select(supervisor, [30]) + similarity_view = supervisor.similarity_view + similarity_view.sort_by('id', 'asc') + similarity_view.select([1, 11, 20]) + supervisor.block() + colors = dict(supervisor.selection_color_indices) + similarity_view.filter('id < 2') + + similarity_view.select_toggle(1) + supervisor.block() + + assert supervisor.selected_similar == [11, 20] + assert { + cluster_id: supervisor.selection_color_indices[cluster_id] for cluster_id in colors + } == colors + + similarity_view.select_toggle(1) + supervisor.block() + + assert set(supervisor.selected_similar) == {1, 11, 20} + assert dict(supervisor.selection_color_indices) == colors + + def test_table_filter_reorders_merge_similarity_tail_without_recoloring(supervisor): _select(supervisor, [30]) supervisor.toggle_merge_mode() diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index c5e9a7ef..3b4dc2a3 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -422,6 +422,23 @@ def test_table_uses_explicit_selected_color_mapping(table): table.set_selected_index_mapping({1: -1}) +def test_table_mutation_retains_filtered_selected_ids(table): + payloads = [] + + @connect(event='select', sender=table) + def on_select(sender, payload): + payloads.append(payload) + + table.select([1, 4]) + table.filter('id < 2') + table.select_toggle(1) + + mutation = payloads[-1]['kwargs']['_selection_mutation'] + assert mutation == SelectionMutation(SelectionIntent.TOGGLE, (1, 4), (4,), (), (1,)) + assert payloads[-1]['selected'] == [] + unconnect(on_select) + + def test_table_batch_update_fits_once(table): fit_calls = [] table._fit_columns = lambda: fit_calls.append(True) diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index a9b59b2f..2b6d630e 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -1216,7 +1216,7 @@ def _selected_payload(self, kwargs=None): def _selection_mutation(self, before_ids, intent): """Describe the selection operation that just updated this table.""" before_ids = tuple(before_ids) - after_ids = tuple(self.get_selected_ids()) + after_ids = tuple(self._selected_ids) return SelectionMutation.create(intent, before_ids, after_ids) def _emit_selected(self, kwargs=None, mutation=None): @@ -1286,7 +1286,7 @@ def get_selected_ids(self): return [row_id for row_id in self._selected_ids if row_id in visible] def select_toggle(self, row_id): - before_ids = tuple(self.get_selected_ids()) + before_ids = tuple(self._selected_ids) if row_id in self._selected_ids: self._selected_ids.remove(row_id) else: @@ -1297,7 +1297,7 @@ def select_toggle(self, row_id): ) def select_until(self, row_id): - before_ids = tuple(self.get_selected_ids()) + before_ids = tuple(self._selected_ids) visible = self._visible_ids() if row_id not in visible: return None @@ -1420,7 +1420,7 @@ def previous(self, callback=None): def select(self, ids, callback=None, **kwargs): ids = tuple(ids) - before_ids = tuple(self.get_selected_ids()) + before_ids = tuple(self._selected_ids) intent = kwargs.pop('_selection_intent', None) if intent == 'navigation': intent = SelectionIntent.NAVIGATE @@ -1438,8 +1438,9 @@ def set_selected_ids(self, ids): """Project selected row IDs without emitting a selection event.""" ids = _uniq(ids) assert all(_is_integer(_) for _ in ids) - visible = set(self._visible_ids()) - self._selected_ids = [row_id for row_id in ids if row_id in visible] + self._selected_ids = [ + row_id for row_id in ids if self._model.row_by_id(row_id) is not None + ] self._selection_revision += 1 self._refresh_selection() return self._selected_payload() From 2fdea100899399ddc4bd35efbd98f10c56770f71 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 01:13:09 +0200 Subject: [PATCH 081/110] fix: restore tables when merge view closes --- docs/changelog.md | 2 ++ phy/cluster/supervisor.py | 5 ++++- phy/cluster/tests/test_supervisor.py | 20 ++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index fb2fde88..382aab98 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -58,6 +58,8 @@ behavior they verify rather than listed separately. ### Fixed +- Closing the Merge View now restores staged clusters to their original + Cluster and Similarity View rows, selections, and table positions. - Show the active sort column and direction in Cluster and Similarity View headers. - Dragging Merge View rows now shows the cluster ID preview, insertion boundary, diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index eb03032b..fc05c7f7 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1375,7 +1375,10 @@ def _cancel_merge_mode(self, close_view=True): context = self.selection.state.merge.entry_snapshot.workflow_context change = self.selection.cancel_merge_mode() self._set_merge_mode_ui(False) - self._apply_selection_change(change, refresh_similarity=False, sync_presentation=False) + # Merge mode rebuilds Similarity while excluding every staged row. Rebuild it + # again from the restored Cluster role so the pre-merge Similarity rows are + # present before their selection and table context are restored. + self._apply_selection_change(change, refresh_similarity=True, sync_presentation=False) self._restore_workflow_context(context) if close_view: self._close_merge_view() diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 6c0a6236..b17afa6c 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -396,6 +396,26 @@ def on_select(sender, cluster_ids): unconnect(on_select) +def test_closing_merge_view_restores_original_table_rows(qtbot, supervisor): + _select(supervisor, [10, 30], [20, 11]) + cluster_rows = supervisor.cluster_view.get_ids() + similarity_rows = supervisor.similarity_view.get_ids() + + supervisor.toggle_merge_mode() + assert 20 not in supervisor.similarity_view.get_ids() + assert 11 not in supervisor.similarity_view.get_ids() + + supervisor.merge_view.dock.close() + qtbot.wait(10) + + assert not supervisor.selection.state.is_merge_mode + assert supervisor.merge_view is None + assert supervisor.cluster_view.get_ids() == cluster_rows + assert supervisor.similarity_view.get_ids() == similarity_rows + assert supervisor.cluster_view.get_selected_ids() == [10, 30] + assert supervisor.similarity_view.get_selected_ids() == [20, 11] + + def test_supervisor_merge_view_opens_below_cluster_and_restores_position(qtbot, supervisor): _select(supervisor, [30], [20]) From a41d3005de9310981240bfba9705f18f3714441d Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 09:32:26 +0200 Subject: [PATCH 082/110] feat: add merge proposition domain state --- phy/cluster/_propositions.py | 482 +++++++++++++++++++++++++ phy/cluster/_selection.py | 43 ++- phy/cluster/tests/test_propositions.py | 214 +++++++++++ phy/cluster/tests/test_selection.py | 44 +++ 4 files changed, 781 insertions(+), 2 deletions(-) create mode 100644 phy/cluster/_propositions.py create mode 100644 phy/cluster/tests/test_propositions.py diff --git a/phy/cluster/_propositions.py b/phy/cluster/_propositions.py new file mode 100644 index 00000000..fc27748f --- /dev/null +++ b/phy/cluster/_propositions.py @@ -0,0 +1,482 @@ +"""Pure, immutable domain objects for AIND merge propositions. + +This module deliberately knows nothing about Qt, files, spike arrays, or the +curation workflow. The controller and persistence layers exchange ordinary +JSON-compatible mappings through the codec functions below. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import Enum +from hashlib import sha256 +from types import MappingProxyType + +from ._history import History + +CURATION_FORMAT_VERSION = '2' +REVIEW_FORMAT_VERSION = '1' + + +def _ids(values, *, name='unit_ids', minimum=0): + """Return a validated, ordered tuple of integer IDs.""" + if not isinstance(values, (list, tuple)): + raise ValueError(f'{name} must be a list of integer IDs.') + ids = tuple(values) + if any(not isinstance(unit_id, int) or isinstance(unit_id, bool) for unit_id in ids): + raise ValueError(f'{name} must contain only integer IDs.') + if len(ids) < minimum: + raise ValueError(f'{name} must contain at least {minimum} IDs.') + if len(ids) != len(set(ids)): + raise ValueError(f'{name} must contain unique IDs.') + return ids + + +def _mapping(value, *, name): + if not isinstance(value, Mapping): + raise ValueError(f'{name} must be an object.') + return value + + +def _freeze(value): + if isinstance(value, Mapping): + return MappingProxyType({str(key): _freeze(item) for key, item in value.items()}) + if isinstance(value, list): + return tuple(_freeze(item) for item in value) + if isinstance(value, tuple): + return tuple(_freeze(item) for item in value) + return value + + +def _thaw(value): + if isinstance(value, Mapping): + return {key: _thaw(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_thaw(item) for item in value] + return value + + +def proposition_key(unit_ids) -> str: + """Return the stable key for one *ordered* source merge proposition.""" + ids = _ids(unit_ids, minimum=2) + payload = ','.join(str(unit_id) for unit_id in ids).encode('ascii') + return f'merge:{sha256(payload).hexdigest()[:16]}' + + +class PropositionStatus(Enum): + PENDING = 'pending' + ACCEPTED = 'accepted' + ACCEPTED_MODIFIED = 'accepted_modified' + REJECTED = 'rejected' + INVALID = 'invalid' + STALE = 'stale' + + +class ReviewDecision(Enum): + ACCEPTED = 'accepted' + REJECTED = 'rejected' + + +@dataclass(frozen=True) +class MergeProposition: + """A valid AIND ``merges`` item, preserving its producer provenance.""" + + unit_ids: tuple[int, ...] + new_unit_id: int | None = None + key: str = field(init=False) + + def __post_init__(self): + ids = _ids(self.unit_ids, minimum=2) + if self.new_unit_id is not None and ( + not isinstance(self.new_unit_id, int) or isinstance(self.new_unit_id, bool) + ): + raise ValueError('new_unit_id must be an integer when supplied.') + object.__setattr__(self, 'unit_ids', ids) + object.__setattr__(self, 'key', proposition_key(ids)) + + @property + def reference_id(self): + return self.unit_ids[0] + + def source_mapping(self): + result = {'unit_ids': list(self.unit_ids)} + if self.new_unit_id is not None: + result['new_unit_id'] = self.new_unit_id + return result + + +@dataclass(frozen=True) +class PropositionReview: + """A durable curator decision. Pending is represented by no record.""" + + decision: ReviewDecision + applied_unit_ids: tuple[int, ...] | None = None + result_unit_id: int | None = None + + def __post_init__(self): + decision = self.decision + if not isinstance(decision, ReviewDecision): + try: + decision = ReviewDecision(decision) + except ValueError as e: + raise ValueError('Unknown proposition review decision.') from e + if decision is ReviewDecision.ACCEPTED: + if self.applied_unit_ids is None: + raise ValueError('Accepted reviews require applied_unit_ids.') + applied = _ids(self.applied_unit_ids, name='applied_unit_ids', minimum=2) + if not isinstance(self.result_unit_id, int) or isinstance(self.result_unit_id, bool): + raise ValueError('Accepted reviews require an integer result_unit_id.') + else: + if self.applied_unit_ids is not None or self.result_unit_id is not None: + raise ValueError('Rejected reviews cannot contain applied merge results.') + applied = None + object.__setattr__(self, 'decision', decision) + object.__setattr__(self, 'applied_unit_ids', applied) + + def mapping(self): + result = {'decision': self.decision.value} + if self.decision is ReviewDecision.ACCEPTED: + result['applied_unit_ids'] = list(self.applied_unit_ids) + result['result_unit_id'] = self.result_unit_id + return result + + +@dataclass(frozen=True) +class PropositionEntry: + """One source list item, including individually invalid source entries.""" + + index: int + proposition: MergeProposition | None = None + invalid_reason: str | None = None + raw_mapping: Mapping = field(default_factory=dict) + + def __post_init__(self): + if self.index < 0: + raise ValueError('Entry index must be non-negative.') + if (self.proposition is None) == (self.invalid_reason is None): + raise ValueError('An entry must be either valid or invalid.') + object.__setattr__( + self, 'raw_mapping', _freeze(_mapping(self.raw_mapping, name='merge entry')) + ) + + @property + def key(self): + return self.proposition.key if self.proposition is not None else None + + +@dataclass(frozen=True) +class MergePropositionCatalog: + """Immutable source propositions, durable reviews, and live-ID projection.""" + + source_unit_ids: tuple[int, ...] + entries: tuple[PropositionEntry, ...] + reviews: Mapping[str, PropositionReview] = field(default_factory=dict) + live_unit_ids: tuple[int, ...] | None = None + source_mapping: Mapping = field(default_factory=dict) + + def __post_init__(self): + source_ids = _ids(self.source_unit_ids, minimum=0) + entries = tuple(self.entries) + if any(not isinstance(entry, PropositionEntry) for entry in entries): + raise TypeError('entries must contain PropositionEntry instances.') + keys = tuple(entry.key for entry in entries if entry.key is not None) + if len(keys) != len(set(keys)): + raise ValueError('Duplicate merge propositions are invalid.') + reviews = dict(self.reviews) + if any( + not isinstance(key, str) or not isinstance(value, PropositionReview) + for key, value in reviews.items() + ): + raise TypeError('reviews must map proposition keys to PropositionReview values.') + live_ids = ( + source_ids + if self.live_unit_ids is None + else _ids(self.live_unit_ids, name='live_unit_ids') + ) + source_mapping = _freeze(_mapping(self.source_mapping, name='curation document')) + object.__setattr__(self, 'source_unit_ids', source_ids) + object.__setattr__(self, 'entries', entries) + object.__setattr__(self, 'reviews', MappingProxyType(reviews)) + object.__setattr__(self, 'live_unit_ids', live_ids) + object.__setattr__(self, 'source_mapping', source_mapping) + + @property + def propositions(self): + return tuple(entry.proposition for entry in self.entries if entry.proposition is not None) + + @property + def orphaned_reviews(self): + keys = {proposition.key for proposition in self.propositions} + return MappingProxyType( + {key: value for key, value in self.reviews.items() if key not in keys} + ) + + def entry_for(self, key): + return next((entry for entry in self.entries if entry.key == key), None) + + def status_for(self, key): + entry = self.entry_for(key) + if entry is None: + raise KeyError(key) + if entry.invalid_reason is not None: + return PropositionStatus.INVALID + proposition = entry.proposition + if not set(proposition.unit_ids) <= set(self.source_unit_ids): + return PropositionStatus.INVALID + review = self.reviews.get(key) + if review is not None: + if review.decision is ReviewDecision.REJECTED: + return PropositionStatus.REJECTED + return ( + PropositionStatus.ACCEPTED + if review.applied_unit_ids == proposition.unit_ids + else PropositionStatus.ACCEPTED_MODIFIED + ) + if not set(proposition.unit_ids) <= set(self.live_unit_ids): + return PropositionStatus.STALE + return PropositionStatus.PENDING + + def reason_for(self, key): + entry = self.entry_for(key) + if entry is None: + raise KeyError(key) + if entry.invalid_reason is not None: + return entry.invalid_reason + if self.status_for(key) is PropositionStatus.INVALID: + return 'One or more unit_ids are absent from curation.json unit_ids.' + if self.status_for(key) is PropositionStatus.STALE: + missing = tuple( + unit_id + for unit_id in entry.proposition.unit_ids + if unit_id not in self.live_unit_ids + ) + return f'One or more source clusters no longer exist: {missing}.' + return None + + def project_live_ids(self, live_unit_ids): + return self._replace(live_unit_ids=_ids(live_unit_ids, name='live_unit_ids')) + + def snapshot(self): + """Return an immutable snapshot suitable for history entries.""" + return self + + def restore(self, snapshot): + if not isinstance(snapshot, MergePropositionCatalog): + raise TypeError('Expected a MergePropositionCatalog snapshot.') + return snapshot + + def reject(self, key): + self._require_reviewable(key) + return self._with_review(key, PropositionReview(ReviewDecision.REJECTED)) + + def accept(self, key, applied_unit_ids, result_unit_id): + self._require_reviewable(key) + return self._with_review( + key, + PropositionReview(ReviewDecision.ACCEPTED, applied_unit_ids, result_unit_id), + ) + + def reset(self, key): + self._require_reviewable(key) + proposition = self.entry_for(key).proposition + if not set(proposition.unit_ids) <= set(self.live_unit_ids): + raise ValueError('Cannot reset a review whose source clusters no longer exist.') + reviews = dict(self.reviews) + reviews.pop(key, None) + return self._replace(reviews=reviews) + + def review_mapping(self, *, source_filename='curation.json', source_sha256=None): + source = {'filename': source_filename} + if source_sha256 is not None: + source['sha256'] = source_sha256 + return { + 'format_version': REVIEW_FORMAT_VERSION, + 'source': source, + 'reviews': {key: review.mapping() for key, review in self.reviews.items()}, + } + + def _require_reviewable(self, key): + status = self.status_for(key) + if status in {PropositionStatus.INVALID, PropositionStatus.STALE}: + raise ValueError(f'Cannot review a {status.value} merge proposition.') + + def _with_review(self, key, review): + if self.entry_for(key) is None: + raise KeyError(key) + reviews = dict(self.reviews) + reviews[key] = review + return self._replace(reviews=reviews) + + def _replace(self, **changes): + values = { + 'source_unit_ids': self.source_unit_ids, + 'entries': self.entries, + 'reviews': self.reviews, + 'live_unit_ids': self.live_unit_ids, + 'source_mapping': self.source_mapping, + } + values.update(changes) + return type(self)(**values) + + +class MergePropositionController: + """Mutable history participant around an immutable proposition catalog. + + Only durable review changes are put on this local history stack. Projecting + live cluster IDs changes derived stale status, but is neither an unsaved + curator decision nor an independently undoable action: clustering history + supplies that context during a global undo/redo. + """ + + def __init__(self, catalog): + if not isinstance(catalog, MergePropositionCatalog): + raise TypeError('Expected a MergePropositionCatalog.') + self._catalog = catalog + self._history = History(catalog) + self._saved_reviews = self._review_fingerprint(catalog) + + @property + def catalog(self): + return self._catalog + + def snapshot(self): + return self._catalog.snapshot() + + def restore(self, snapshot): + self._catalog = self._require_catalog(snapshot) + return None + + def project_live_ids(self, live_unit_ids): + self._catalog = self._catalog.project_live_ids(tuple(map(int, live_unit_ids))) + return None + + def reject(self, key): + return self._transition(self._catalog.reject(key)) + + def accept(self, key, applied_unit_ids, result_unit_id): + return self._transition(self._catalog.accept(key, applied_unit_ids, result_unit_id)) + + def reset(self, key): + return self._transition(self._catalog.reset(key)) + + def undo(self): + if self._history.undo() is not None: + self._catalog = self._history.current_item.project_live_ids( + self._catalog.live_unit_ids + ) + return None + + def redo(self): + catalog = self._history.redo() + if catalog is not None: + self._catalog = catalog.project_live_ids(self._catalog.live_unit_ids) + return None + + def mark_saved(self): + self._saved_reviews = self._review_fingerprint(self._catalog) + + def is_dirty(self): + return self._review_fingerprint(self._catalog) != self._saved_reviews + + def _transition(self, after): + if after == self._catalog: + return None + self._catalog = after + self._history.add(after) + return None + + @staticmethod + def _require_catalog(snapshot): + if not isinstance(snapshot, MergePropositionCatalog): + raise TypeError('Expected a MergePropositionCatalog snapshot.') + return snapshot + + @staticmethod + def _review_fingerprint(catalog): + return tuple( + sorted( + (key, review.decision.value, review.applied_unit_ids, review.result_unit_id) + for key, review in catalog.reviews.items() + ) + ) + + +def decode_curation_mapping(mapping) -> MergePropositionCatalog: + """Decode a supported AIND/SpikeInterface v2 curation document. + + Invalid individual ``merges`` entries become visible invalid catalog entries; + an invalid document shell raises ``ValueError`` so callers can disable only + the proposition feature while leaving ordinary curation available. + """ + mapping = _mapping(mapping, name='curation document') + if str(mapping.get('format_version')) != CURATION_FORMAT_VERSION: + raise ValueError( + f'Unsupported curation format version: {mapping.get("format_version")!r}.' + ) + source_ids = _ids(mapping.get('unit_ids'), name='unit_ids') + merges = mapping.get('merges', ()) + if not isinstance(merges, list): + raise ValueError('merges must be a list.') + entries = [] + seen_keys = set() + for index, item in enumerate(merges): + raw = item if isinstance(item, Mapping) else {} + try: + item = _mapping(item, name=f'merges[{index}]') + # Unknown per-merge data is not part of the v2 contract; retain it + # in source_mapping but do not make a valid proposition ambiguous. + proposition = MergeProposition(item.get('unit_ids'), item.get('new_unit_id')) + if proposition.key in seen_keys: + raise ValueError('Duplicate merge proposition.') + seen_keys.add(proposition.key) + entries.append(PropositionEntry(index, proposition=proposition, raw_mapping=raw)) + except (TypeError, ValueError) as e: + entries.append(PropositionEntry(index, invalid_reason=str(e), raw_mapping=raw)) + return MergePropositionCatalog(source_ids, tuple(entries), source_mapping=mapping) + + +def encode_curation_mapping(catalog: MergePropositionCatalog): + """Return the untouched producer-owned source document as plain JSON data.""" + if not isinstance(catalog, MergePropositionCatalog): + raise TypeError('Expected a MergePropositionCatalog.') + return _thaw(catalog.source_mapping) + + +def decode_review_mapping(mapping): + """Decode a ``curation_review.json`` mapping into durable review records. + + The source descriptor is returned as a plain mapping alongside the records, + allowing the filesystem adapter to compare its source hash without any I/O + hidden in this module. + """ + mapping = _mapping(mapping, name='review document') + if str(mapping.get('format_version')) != REVIEW_FORMAT_VERSION: + raise ValueError(f'Unsupported review format version: {mapping.get("format_version")!r}.') + source = _mapping(mapping.get('source'), name='review source') + filename = source.get('filename') + if not isinstance(filename, str) or not filename: + raise ValueError('review source filename must be a non-empty string.') + if 'sha256' in source and (not isinstance(source['sha256'], str) or not source['sha256']): + raise ValueError('review source sha256 must be a non-empty string.') + reviews_mapping = _mapping(mapping.get('reviews'), name='reviews') + reviews = {} + for key, value in reviews_mapping.items(): + if not isinstance(key, str) or not key.startswith('merge:'): + raise ValueError('Review keys must be merge proposition keys.') + value = _mapping(value, name=f'review {key}') + reviews[key] = PropositionReview( + value.get('decision'), + value.get('applied_unit_ids'), + value.get('result_unit_id'), + ) + return MappingProxyType(dict(source)), MappingProxyType(reviews) + + +def encode_review_mapping(reviews, *, source_filename='curation.json', source_sha256=None): + """Serialize review records without requiring a catalog instance.""" + catalog = MergePropositionCatalog((), (), reviews=reviews) + return catalog.review_mapping( + source_filename=source_filename, + source_sha256=source_sha256, + ) diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index d0852870..fbdb9298 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -42,11 +42,14 @@ class MergeSession: reference_id: int ordered_ids: tuple[int, ...] entry_snapshot: NormalWorkflowSnapshot + proposition_id: str | None = None def __post_init__(self): ordered = _as_unique_ids(self.ordered_ids) if not ordered or ordered[0] != self.reference_id: raise ValueError('The merge reference must be the first staged cluster.') + if self.proposition_id is not None and not self.proposition_id: + raise ValueError('The merge proposition ID cannot be empty.') object.__setattr__(self, 'ordered_ids', ordered) @@ -322,6 +325,31 @@ def enter_merge_mode(self, workflow_context=None): ) ) + def enter_merge_proposition(self, proposition_id, ordered_ids, workflow_context=None): + """Stage an external merge proposition without changing its entry snapshot.""" + self._require_normal_mode() + ordered = _as_unique_ids(ordered_ids) + if len(ordered) < 2: + raise ValueError('A merge proposition requires at least two cluster IDs.') + if not proposition_id: + raise ValueError('The merge proposition ID cannot be empty.') + current = self._state + merge = MergeSession( + ordered[0], + ordered, + NormalWorkflowSnapshot(current, workflow_context), + proposition_id=str(proposition_id), + ) + return self._apply( + CurationSelectionState( + mode=WorkflowMode.MERGE, + reference_id=ordered[0], + presentation_order=ordered, + color_slots=ordered, + merge=merge, + ) + ) + def cancel_merge_mode(self): self._require_merge_mode() return self._apply(self._state.merge.entry_snapshot.selection) @@ -338,7 +366,12 @@ def add_to_merge(self, cluster_ids, insertion=None): if not 1 <= insertion <= len(ids): raise ValueError('Merge insertion must follow the fixed reference.') ids[insertion:insertion] = new - merge = MergeSession(current.reference_id, tuple(ids), current.merge.entry_snapshot) + merge = MergeSession( + current.reference_id, + tuple(ids), + current.merge.entry_snapshot, + proposition_id=current.merge.proposition_id, + ) similar = tuple(cluster_id for cluster_id in current.similar_ids if cluster_id not in new) effective = _ordered_union(merge.ordered_ids, similar) return self._apply( @@ -363,6 +396,7 @@ def remove_from_merge(self, cluster_ids): current.reference_id, tuple(i for i in current.merge_ids if i not in removed), current.merge.entry_snapshot, + proposition_id=current.merge.proposition_id, ) similar = _ordered_union(current.similar_ids, removed) effective = _ordered_union(merge.ordered_ids, similar) @@ -388,7 +422,12 @@ def reorder_merge(self, cluster_ids, insertion): if not 1 <= insertion <= len(remain): raise ValueError('Merge insertion must follow the fixed reference.') remain[insertion:insertion] = moving - merge = MergeSession(current.reference_id, tuple(remain), current.merge.entry_snapshot) + merge = MergeSession( + current.reference_id, + tuple(remain), + current.merge.entry_snapshot, + proposition_id=current.merge.proposition_id, + ) return self._apply( CurationSelectionState( mode=WorkflowMode.MERGE, diff --git a/phy/cluster/tests/test_propositions.py b/phy/cluster/tests/test_propositions.py new file mode 100644 index 00000000..7363577a --- /dev/null +++ b/phy/cluster/tests/test_propositions.py @@ -0,0 +1,214 @@ +"""Tests for the pure merge-proposition domain and codecs.""" + +from dataclasses import FrozenInstanceError + +from pytest import raises + +from .._propositions import ( + MergeProposition, + MergePropositionCatalog, + MergePropositionController, + PropositionEntry, + PropositionReview, + PropositionStatus, + ReviewDecision, + decode_curation_mapping, + decode_review_mapping, + encode_curation_mapping, + encode_review_mapping, + proposition_key, +) + + +def _source(merges=None): + return { + 'format_version': '2', + 'unit_ids': [41, 56, 72], + 'merges': merges if merges is not None else [{'unit_ids': [41, 56]}], + 'manual_labels': {'41': ['good']}, + } + + +def test_aind_v2_decode_preserves_source_and_proposition_order(): + source = _source([{'unit_ids': [56, 41], 'new_unit_id': 1000}]) + + catalog = decode_curation_mapping(source) + proposition = catalog.propositions[0] + + assert proposition.unit_ids == (56, 41) + assert proposition.reference_id == 56 + assert proposition.new_unit_id == 1000 + assert catalog.status_for(proposition.key) is PropositionStatus.PENDING + assert encode_curation_mapping(catalog) == source + + # The catalog owns an immutable source snapshot, rather than the caller's + # mutable producer mapping. + source['merges'][0]['unit_ids'][0] = 999 + assert encode_curation_mapping(catalog)['merges'][0]['unit_ids'] == [56, 41] + + +def test_stable_keys_are_ordered_and_propositions_are_immutable(): + key = proposition_key([41, 56]) + + assert key == proposition_key((41, 56)) + assert key != proposition_key((56, 41)) + assert key.startswith('merge:') + assert len(key) == len('merge:') + 16 + + proposition = MergeProposition((41, 56)) + with raises(FrozenInstanceError): + proposition.unit_ids = (56, 41) + + +def test_invalid_individual_merges_remain_visible_alongside_valid_entries(): + catalog = decode_curation_mapping( + _source( + [ + {'unit_ids': [41, 56]}, + {'unit_ids': [41]}, + {'unit_ids': [41, 41]}, + {'unit_ids': ['56', 72]}, + {'new_unit_id': 99}, + {'unit_ids': [41, 56]}, + 'not an object', + ] + ) + ) + + assert len(catalog.entries) == 7 + assert len(catalog.propositions) == 1 + assert catalog.entries[1].invalid_reason == 'unit_ids must contain at least 2 IDs.' + assert 'unique' in catalog.entries[2].invalid_reason + assert 'integer' in catalog.entries[3].invalid_reason + assert 'list' in catalog.entries[4].invalid_reason + assert catalog.entries[5].invalid_reason == 'Duplicate merge proposition.' + assert catalog.entries[6].invalid_reason == 'merges[6] must be an object.' + + +def test_document_shell_errors_disable_only_the_proposition_feature(): + with raises(ValueError, match='Unsupported'): + decode_curation_mapping({'format_version': '1', 'unit_ids': [], 'merges': []}) + with raises(ValueError, match='unit_ids'): + decode_curation_mapping({'format_version': '2', 'merges': []}) + with raises(ValueError, match='merges'): + decode_curation_mapping({'format_version': '2', 'unit_ids': [], 'merges': {}}) + + +def test_source_and_live_id_validity_are_derived_not_persisted(): + catalog = decode_curation_mapping(_source([{'unit_ids': [41, 99]}, {'unit_ids': [41, 56]}])) + invalid, valid = catalog.entries + + assert catalog.status_for(invalid.key) is PropositionStatus.INVALID + assert 'absent' in catalog.reason_for(invalid.key) + assert catalog.status_for(valid.key) is PropositionStatus.PENDING + + stale = catalog.project_live_ids([41, 72]) + assert stale.status_for(valid.key) is PropositionStatus.STALE + assert '56' in stale.reason_for(valid.key) + assert catalog.status_for(valid.key) is PropositionStatus.PENDING + + +def test_review_transitions_are_immutable_and_modified_acceptance_is_derived(): + catalog = decode_curation_mapping(_source()) + key = catalog.propositions[0].key + + rejected = catalog.reject(key) + assert rejected.status_for(key) is PropositionStatus.REJECTED + assert catalog.status_for(key) is PropositionStatus.PENDING + assert rejected.reset(key).status_for(key) is PropositionStatus.PENDING + + accepted = catalog.accept(key, [41, 56], 1001) + modified = catalog.accept(key, [41, 56, 72], 1001) + assert accepted.status_for(key) is PropositionStatus.ACCEPTED + assert modified.status_for(key) is PropositionStatus.ACCEPTED_MODIFIED + assert accepted.reviews[key].result_unit_id == 1001 + assert accepted.project_live_ids([72]).status_for(key) is PropositionStatus.ACCEPTED + + with raises(ValueError, match='source clusters'): + accepted.project_live_ids([72]).reset(key) + + snapshot = accepted.snapshot() + assert modified.restore(snapshot) is snapshot + + +def test_invalid_and_stale_entries_cannot_receive_or_reset_reviews(): + catalog = decode_curation_mapping(_source([{'unit_ids': [41, 99]}, {'unit_ids': [41, 56]}])) + invalid_key = catalog.entries[0].key + valid_key = catalog.entries[1].key + + with raises(ValueError, match='invalid'): + catalog.reject(invalid_key) + stale = catalog.project_live_ids([41]) + with raises(ValueError, match='stale'): + stale.accept(valid_key, [41, 56], 1001) + + +def test_review_sidecar_round_trip_preserves_orphans(): + catalog = decode_curation_mapping(_source()).accept(proposition_key([41, 56]), [41, 56], 1001) + reviews = dict(catalog.reviews) + reviews['merge:orphaned'] = PropositionReview(ReviewDecision.REJECTED) + + mapping = encode_review_mapping( + reviews, source_filename='curation.json', source_sha256='a' * 64 + ) + source, loaded = decode_review_mapping(mapping) + + assert source == {'filename': 'curation.json', 'sha256': 'a' * 64} + assert loaded == reviews + reloaded = MergePropositionCatalog( + catalog.source_unit_ids, catalog.entries, loaded, source_mapping=catalog.source_mapping + ) + assert reloaded.orphaned_reviews == { + 'merge:orphaned': PropositionReview(ReviewDecision.REJECTED) + } + + +def test_review_validation_and_catalog_invariants(): + with raises(ValueError, match='applied_unit_ids'): + PropositionReview(ReviewDecision.ACCEPTED, None, 1) + with raises(ValueError, match='Rejected'): + PropositionReview(ReviewDecision.REJECTED, (1, 2), 3) + with raises(ValueError, match='Duplicate merge'): + MergePropositionCatalog( + (1, 2), + ( + PropositionEntry(0, MergeProposition((1, 2)), raw_mapping={'unit_ids': [1, 2]}), + PropositionEntry(1, MergeProposition((1, 2)), raw_mapping={'unit_ids': [1, 2]}), + ), + ) + with raises(ValueError, match='Review keys'): + decode_review_mapping( + {'format_version': '1', 'source': {'filename': 'curation.json'}, 'reviews': {'x': {}}} + ) + + +def test_controller_has_history_compatible_review_undo_redo_and_dirty_tracking(): + controller = MergePropositionController(decode_curation_mapping(_source())) + key = controller.catalog.propositions[0].key + + assert not controller.is_dirty() + assert controller.reject(key) is None + assert controller.catalog.status_for(key) is PropositionStatus.REJECTED + assert controller.is_dirty() + + # Live validity is derived context and must not itself create unsaved work. + controller.mark_saved() + controller.project_live_ids([41]) + assert controller.catalog.status_for(key) is PropositionStatus.REJECTED + assert not controller.is_dirty() + + controller.undo() + assert controller.catalog.status_for(key) is PropositionStatus.STALE + assert controller.is_dirty() + controller.project_live_ids([41, 56, 72]) + assert controller.catalog.status_for(key) is PropositionStatus.PENDING + assert controller.is_dirty() + controller.redo() + assert controller.catalog.status_for(key) is PropositionStatus.REJECTED + assert not controller.is_dirty() + + snapshot = controller.snapshot() + controller.restore(snapshot) + assert controller.catalog is snapshot + with raises(TypeError, match='snapshot'): + controller.restore(None) diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index 0e9a6c38..4bd6b3ba 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -273,6 +273,50 @@ def test_enter_merge_mode_stages_normal_presentation_order(): assert not change.presentation_changed +def test_enter_merge_proposition_preserves_entry_and_uses_proposed_reference(): + initial = CurationSelectionState( + cluster_ids=(3, 1), + similar_ids=(4,), + reference_id=3, + color_slots=(3, 1, 4), + ) + context = {'cluster_filter': 'group == good'} + controller = CurationSelectionController(initial) + + change = controller.enter_merge_proposition('merge:8,2', (8, 2), context) + + assert change.after.merge_ids == (8, 2) + assert change.after.reference_id == 8 + assert change.after.color_slots == (8, 2) + assert change.after.merge.proposition_id == 'merge:8,2' + assert change.after.merge.entry_snapshot.selection is initial + assert change.after.merge.entry_snapshot.workflow_context is context + assert controller.cancel_merge_mode().after is initial + + +def test_enter_merge_proposition_validates_identity_and_membership(): + controller = CurationSelectionController(CurationSelectionState(cluster_ids=(1,))) + + with raises(ValueError, match='at least two'): + controller.enter_merge_proposition('p', (1,)) + with raises(ValueError, match='cannot be empty'): + controller.enter_merge_proposition('', (1, 2)) + with raises(ValueError, match='unique'): + controller.enter_merge_proposition('p', (1, 1)) + + +def test_merge_workspace_edits_preserve_proposition_identity(): + controller = CurationSelectionController(CurationSelectionState(cluster_ids=(1,))) + controller.enter_merge_proposition('p', (1, 2)) + + controller.add_to_merge((3,)) + assert controller.state.merge.proposition_id == 'p' + controller.remove_from_merge((2,)) + assert controller.state.merge.proposition_id == 'p' + controller.reorder_merge((3,), 1) + assert controller.state.merge.proposition_id == 'p' + + def test_enter_merge_mode_requires_cluster_selection(): controller = CurationSelectionController() with raises(ValueError, match='Cluster View selection'): From 579d6a0ac894aea00b8df5b12d0ef2b0a503c961 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 09:32:35 +0200 Subject: [PATCH 083/110] feat: integrate merge proposition review workflow --- phy/apps/_proposition_io.py | 111 ++++++++++++++ phy/apps/base.py | 74 +++++++++ phy/apps/template/gui.py | 1 + phy/apps/tests/test_base.py | 40 +++++ phy/apps/tests/test_proposition_io.py | 73 +++++++++ phy/cluster/_proposition_view.py | 211 ++++++++++++++++++++++++++ phy/cluster/supervisor.py | 180 ++++++++++++++++++++-- phy/cluster/tests/test_supervisor.py | 127 ++++++++++++++++ 8 files changed, 808 insertions(+), 9 deletions(-) create mode 100644 phy/apps/_proposition_io.py create mode 100644 phy/apps/tests/test_proposition_io.py create mode 100644 phy/cluster/_proposition_view.py diff --git a/phy/apps/_proposition_io.py b/phy/apps/_proposition_io.py new file mode 100644 index 00000000..e673e112 --- /dev/null +++ b/phy/apps/_proposition_io.py @@ -0,0 +1,111 @@ +"""Filesystem boundary for merge-proposition input and review decisions.""" + +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +CURATION_FILENAME = 'curation.json' +REVIEW_FILENAME = 'curation_review.json' + + +class PropositionSourceChangedError(RuntimeError): + """Raised when producer-owned proposition input changed during curation.""" + + +@dataclass(frozen=True) +class PropositionDocuments: + curation: Mapping | None + curation_sha256: str | None + review: Mapping | None + + +def file_sha256(path): + """Return a SHA-256 digest of the exact bytes at *path*.""" + digest = hashlib.sha256() + with Path(path).open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def read_json_mapping(path, *, missing_ok=False): + """Read one JSON object, optionally treating a missing file as absent.""" + path = Path(path) + try: + with path.open(encoding='utf8') as stream: + value = json.load(stream) + except FileNotFoundError: + if missing_ok: + return None + raise + except json.JSONDecodeError as e: + raise ValueError(f'Invalid JSON in {path.name}: {e.msg}.') from e + if not isinstance(value, dict): + raise ValueError(f'{path.name} must contain a JSON object.') + return value + + +def load_proposition_documents(dataset_dir): + """Load optional dataset-local proposition input and review sidecar.""" + dataset_dir = Path(dataset_dir) + curation_path = dataset_dir / CURATION_FILENAME + curation = read_json_mapping(curation_path, missing_ok=True) + digest = file_sha256(curation_path) if curation is not None else None + review = read_json_mapping(dataset_dir / REVIEW_FILENAME, missing_ok=True) + return PropositionDocuments(curation, digest, review) + + +def write_json_atomic(path, mapping): + """Durably replace *path* with a deterministic JSON representation.""" + if not isinstance(mapping, Mapping): + raise TypeError('Atomic JSON output must be a mapping.') + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + dir=path.parent, + prefix=f'.{path.name}.', + suffix='.tmp', + ) + temporary_path = Path(temporary_name) + try: + with os.fdopen(descriptor, 'w', encoding='utf8') as stream: + json.dump(mapping, stream, indent=2, sort_keys=True) + stream.write('\n') + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary_path, path) + try: + directory_fd = os.open(path.parent, os.O_RDONLY) + except OSError: # pragma: no cover - platform/filesystem dependent + return + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except BaseException: + temporary_path.unlink(missing_ok=True) + raise + + +def write_review_document(dataset_dir, mapping, *, expected_curation_sha256=None): + """Write the review sidecar unless its producer input changed meanwhile.""" + dataset_dir = Path(dataset_dir) + source_path = dataset_dir / CURATION_FILENAME + if expected_curation_sha256 is not None: + try: + current = file_sha256(source_path) + except FileNotFoundError as e: + raise PropositionSourceChangedError( + f'{CURATION_FILENAME} disappeared after it was loaded.' + ) from e + if current != expected_curation_sha256: + raise PropositionSourceChangedError( + f'{CURATION_FILENAME} changed after it was loaded; review state was not written.' + ) + write_json_atomic(dataset_dir / REVIEW_FILENAME, mapping) diff --git a/phy/apps/base.py b/phy/apps/base.py index f31795f7..381dff86 100644 --- a/phy/apps/base.py +++ b/phy/apps/base.py @@ -20,6 +20,12 @@ from phylib.utils._misc import write_tsv from scipy.signal import butter, lfilter +from phy.cluster._propositions import ( + MergePropositionCatalog, + MergePropositionController, + decode_curation_mapping, + decode_review_mapping, +) from phy.cluster._utils import RotatingProperty from phy.cluster.supervisor import Supervisor from phy.cluster.views import ( @@ -48,6 +54,7 @@ from phy.utils.context import Context, _cache_methods from phy.utils.plugin import attach_plugins +from ._proposition_io import load_proposition_documents, write_review_document from ._utils import _close_trace_reader logger = logging.getLogger(__name__) @@ -1088,6 +1095,7 @@ class BaseController: gui_name = 'BaseGUI' gui_version = 2 + enable_merge_propositions = False # Default value of the 'show_mapped_channels' param if it is not set in params.py. default_show_mapped_channels = True @@ -1328,6 +1336,8 @@ def _set_supervisor(self): # Cluster groups. cluster_groups = self.model.metadata.get('group', {}) + merge_propositions = self._load_merge_propositions() + # Create the Supervisor instance. supervisor = Supervisor( spike_clusters=self.model.spike_clusters, @@ -1337,6 +1347,7 @@ def _set_supervisor(self): similarity=self.similarity_functions[self.similarity], new_cluster_id=new_cluster_id, context=self.context, + merge_propositions=merge_propositions, ) # Load the non-group metadata from the model to the cluster_meta. for name in sorted(self.model.metadata): @@ -1349,9 +1360,58 @@ def _set_supervisor(self): # Connect the `save_clustering` event raised by the supervisor when saving # to the model's saving functions. connect(self.on_save_clustering, sender=supervisor) + if merge_propositions is not None: + connect( + self.on_save_proposition_reviews, + event='save_proposition_reviews', + sender=supervisor, + ) self.supervisor = supervisor + def _load_merge_propositions(self): + """Load optional AIND/SI merge propositions for supported applications.""" + self._merge_proposition_sha256 = None + if not self.enable_merge_propositions: + return None + try: + documents = load_proposition_documents(self.dir_path) + except ValueError as e: + logger.warning('Merge Propositions disabled: %s', e) + return None + if documents.curation is None: + if documents.review is not None: + logger.warning('Ignoring curation_review.json because curation.json is absent.') + return None + try: + catalog = decode_curation_mapping(documents.curation) + except (TypeError, ValueError) as e: + logger.warning('Merge Propositions disabled: %s', e) + return None + self._merge_proposition_sha256 = documents.curation_sha256 + reviews = {} + if documents.review is not None: + try: + source, reviews = decode_review_mapping(documents.review) + except (TypeError, ValueError) as e: + logger.warning('Merge Propositions disabled: invalid curation_review.json: %s', e) + return None + else: + review_hash = source.get('sha256') + if review_hash and review_hash != documents.curation_sha256: + logger.warning( + 'curation.json changed since its reviews were saved; matching ' + 'proposition decisions were retained and unmatched reviews are orphaned.' + ) + catalog = MergePropositionCatalog( + catalog.source_unit_ids, + catalog.entries, + reviews=reviews, + live_unit_ids=tuple(map(int, np.unique(self.model.spike_clusters))), + source_mapping=catalog.source_mapping, + ) + return MergePropositionController(catalog) + def _set_selector(self): """Set the Selector instance.""" @@ -1494,6 +1554,20 @@ def on_save_clustering(self, sender, spike_clusters, groups, *labels): self.model.save_metadata(name, values) self._save_cluster_info() + def on_save_proposition_reviews(self, sender, mapping): + """Atomically save phy-owned review state after cluster assignments.""" + mapping = dict(mapping) + source = dict(mapping.get('source', {})) + source['filename'] = 'curation.json' + if self._merge_proposition_sha256 is not None: + source['sha256'] = self._merge_proposition_sha256 + mapping['source'] = source + write_review_document( + self.dir_path, + mapping, + expected_curation_sha256=self._merge_proposition_sha256, + ) + def _save_cluster_info(self): """Save all the contents of the cluster view into `cluster_info.tsv`.""" # HACK: rename id to cluster_id for consistency in the cluster_info.tsv file. diff --git a/phy/apps/template/gui.py b/phy/apps/template/gui.py index 1bbbc8ca..5e6abfbe 100644 --- a/phy/apps/template/gui.py +++ b/phy/apps/template/gui.py @@ -59,6 +59,7 @@ class TemplateController(WaveformMixin, FeatureMixin, TemplateMixin, TraceMixin, """ gui_name = 'TemplateGUI' + enable_merge_propositions = True # Specific views implemented in this class. _new_views = ('TemplateFeatureView',) diff --git a/phy/apps/tests/test_base.py b/phy/apps/tests/test_base.py index 6d728b9e..437abf5a 100644 --- a/phy/apps/tests/test_base.py +++ b/phy/apps/tests/test_base.py @@ -4,6 +4,7 @@ # Imports # ------------------------------------------------------------------------------ +import json import logging import os import shutil @@ -26,6 +27,7 @@ from pytest import mark from pytestqt.plugin import QtBot +from phy.cluster._propositions import PropositionStatus from phy.cluster.clustering import Clustering from phy.cluster.views import ( AmplitudeView, @@ -150,6 +152,10 @@ class MyControllerFull(TemplateMixin, WaveformMixin, FeatureMixin, TraceMixin, M """With everything.""" +class MyPropositionController(MyController): + enable_merge_propositions = True + + def _mock_controller(tempdir, cls): model = MyModel() return cls( @@ -161,6 +167,40 @@ def _mock_controller(tempdir, cls): ) +def test_controller_loads_and_reopens_merge_proposition_reviews(tempdir): + source = { + 'format_version': '2', + 'unit_ids': list(range(MyModel.n_clusters)), + 'merges': [{'unit_ids': [1, 2]}], + } + (tempdir / 'curation.json').write_text(json.dumps(source), encoding='utf8') + controller = _mock_controller(tempdir, MyPropositionController) + proposition = controller.supervisor.merge_propositions.catalog.propositions[0] + + controller.supervisor.merge_propositions.reject(proposition.key) + controller.supervisor.save() + + sidecar = json.loads((tempdir / 'curation_review.json').read_text(encoding='utf8')) + assert sidecar['source']['filename'] == 'curation.json' + assert len(sidecar['source']['sha256']) == 64 + assert sidecar['reviews'][proposition.key]['decision'] == 'rejected' + + reopened = _mock_controller(tempdir, MyPropositionController) + assert ( + reopened.supervisor.merge_propositions.catalog.status_for(proposition.key) + is PropositionStatus.REJECTED + ) + + +def test_invalid_curation_json_does_not_prevent_ordinary_controller(tempdir, caplog): + (tempdir / 'curation.json').write_text('{bad', encoding='utf8') + + controller = _mock_controller(tempdir, MyPropositionController) + + assert controller.supervisor.merge_propositions is None + assert 'Merge Propositions disabled' in caplog.text + + def test_allocate_spike_counts_redistributes_total_budget(): np.testing.assert_array_equal( _allocate_spike_counts([0, 1, 100], per_cluster=10, total=7), diff --git a/phy/apps/tests/test_proposition_io.py b/phy/apps/tests/test_proposition_io.py new file mode 100644 index 00000000..1546630b --- /dev/null +++ b/phy/apps/tests/test_proposition_io.py @@ -0,0 +1,73 @@ +"""Tests for merge-proposition filesystem persistence.""" + +import json + +from pytest import raises + +from .._proposition_io import ( + PropositionSourceChangedError, + file_sha256, + load_proposition_documents, + read_json_mapping, + write_json_atomic, + write_review_document, +) + + +def test_load_optional_proposition_documents_and_exact_hash(tmp_path): + assert load_proposition_documents(tmp_path).curation is None + + source = b'{"format_version":"2","unit_ids":[],"merges":[]}\n' + (tmp_path / 'curation.json').write_bytes(source) + (tmp_path / 'curation_review.json').write_text( + '{"format_version":"1","source":{},"reviews":{}}', encoding='utf8' + ) + + documents = load_proposition_documents(tmp_path) + assert documents.curation['format_version'] == '2' + assert documents.curation_sha256 == file_sha256(tmp_path / 'curation.json') + assert documents.review['reviews'] == {} + + +def test_read_json_mapping_reports_malformed_and_non_object_json(tmp_path): + path = tmp_path / 'bad.json' + path.write_text('{bad', encoding='utf8') + with raises(ValueError, match='Invalid JSON in bad.json'): + read_json_mapping(path) + + path.write_text('[]', encoding='utf8') + with raises(ValueError, match='JSON object'): + read_json_mapping(path) + + +def test_atomic_json_write_replaces_existing_file_without_temp_leak(tmp_path): + path = tmp_path / 'curation_review.json' + path.write_text('{"old": true}', encoding='utf8') + + write_json_atomic(path, {'reviews': {'p': {'decision': 'rejected'}}}) + + assert json.loads(path.read_text(encoding='utf8'))['reviews']['p']['decision'] == 'rejected' + assert list(tmp_path.glob('.curation_review.json.*.tmp')) == [] + + +def test_review_write_detects_source_replacement(tmp_path): + source = tmp_path / 'curation.json' + source.write_text('{"format_version":"2"}', encoding='utf8') + expected = file_sha256(source) + source.write_text('{"format_version":"2", "merges":[]}', encoding='utf8') + + with raises(PropositionSourceChangedError, match='changed'): + write_review_document(tmp_path, {'reviews': {}}, expected_curation_sha256=expected) + assert not (tmp_path / 'curation_review.json').exists() + + +def test_atomic_write_cleans_temporary_file_when_replace_fails(tmp_path, monkeypatch): + from .. import _proposition_io + + def fail_replace(source, target): + raise OSError('replace failed') + + monkeypatch.setattr(_proposition_io.os, 'replace', fail_replace) + with raises(OSError, match='replace failed'): + write_json_atomic(tmp_path / 'curation_review.json', {'reviews': {}}) + assert list(tmp_path.glob('.curation_review.json.*.tmp')) == [] diff --git a/phy/cluster/_proposition_view.py b/phy/cluster/_proposition_view.py new file mode 100644 index 00000000..da000dce --- /dev/null +++ b/phy/cluster/_proposition_view.py @@ -0,0 +1,211 @@ +"""Presentation-only table for automatic merge propositions. + +The view intentionally accepts plain row dictionaries. It has no dependency on +the proposition catalog or on a :class:`~phy.cluster.supervisor.Supervisor`, so +the GUI cannot become an alternative source of curation state. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping + +from phylib.utils import emit + +from phy.gui.qt import QAbstractItemView, QHBoxLayout, QPushButton +from phy.gui.widgets import Table + + +class MergePropositionsView(Table): + """A persistent, local-selection table for merge-proposition projections. + + Rows supplied to :meth:`set_propositions` require a string ``key`` and may + provide ``unit_ids``, ``status``, ``reason``, and ``new_unit_id``. Button + events carry only the stable proposition key: + + * ``review_merge_proposition`` + * ``reject_merge_proposition`` + * ``skip_merge_proposition`` + * ``reset_merge_proposition`` + + Selecting a row is deliberately local presentation state: unlike a cluster + table it never emits ``select`` and never starts a merge review. + """ + + _columns = ( + 'key', + 'unit_ids', + 'status', + 'reason', + 'n_clusters', + 'reference', + 'new_unit_id', + ) + _action_events = { + 'review': 'review_merge_proposition', + 'reject': 'reject_merge_proposition', + 'skip': 'skip_merge_proposition', + 'reset': 'reset_merge_proposition', + } + + def __init__(self, *args, data=None, **kwargs): + super().__init__( + *args, + title='MERGE PROPOSITIONS', + columns=['id', *self._columns], + value_names=['id', *self._columns], + data=[], + sort=('status', 'asc'), + debounce_events=(), + skip_masked=False, + **kwargs, + ) + self._key_by_id = {} + self._id_by_key = {} + self._current_key = None + self.table_view.setSelectionMode(QAbstractItemView.SingleSelection) + self._create_actions() + self.set_propositions(data or ()) + + @property + def current_key(self): + """The key of the locally highlighted proposition, if any.""" + return self._current_key + + def _create_actions(self): + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + self.action_buttons = {} + for action, label in ( + ('review', 'Review'), + ('reject', 'Reject'), + ('skip', 'Skip'), + ('reset', 'Reset review'), + ): + button = QPushButton(label, self) + button.setObjectName(f'merge-proposition-{action}') + button.clicked.connect(lambda _checked=False, action=action: self.trigger(action)) + layout.addWidget(button) + self.action_buttons[action] = button + # The Table layout is [filter, table]. Put controls between them. + self.layout().insertLayout(1, layout) + self._update_action_buttons() + + @staticmethod + def _as_ordered_ids(value): + if value is None: + return () + if isinstance(value, (str, bytes)): + return (value,) + return tuple(value) + + def _normalize_row(self, row, index): + if not isinstance(row, Mapping): + raise TypeError('Merge proposition rows must be mappings.') + key = row.get('key') + if not isinstance(key, str) or not key: + raise ValueError('Every merge proposition row requires a non-empty string key.') + unit_ids = self._as_ordered_ids(row.get('unit_ids')) + status = str(row.get('status', 'pending')) + invalid_or_stale = status in {'invalid', 'stale'} + can_review = bool(row.get('can_review', not invalid_or_stale)) + can_reject = bool(row.get('can_reject', status == 'pending' and not invalid_or_stale)) + can_skip = bool(row.get('can_skip', status == 'pending' and not invalid_or_stale)) + can_reset = bool( + row.get( + 'can_reset', + status in {'accepted', 'accepted_modified', 'rejected'} and not invalid_or_stale, + ) + ) + return { + # Table itself uses integer IDs. The catalog key remains a separate, + # visible column and is recovered through ``_key_by_id`` after every + # sort or filter operation. + 'id': index, + 'key': key, + 'unit_ids': unit_ids, + 'status': status, + 'reason': row.get('reason') or '', + 'n_clusters': len(unit_ids), + 'reference': unit_ids[0] if unit_ids else '', + 'new_unit_id': row.get('new_unit_id', ''), + '_can_review': can_review, + '_can_reject': can_reject, + '_can_skip': can_skip, + '_can_reset': can_reset, + } + + def set_propositions(self, rows: Iterable[Mapping]): + """Replace the table projection while retaining a surviving local row. + + The integer table row IDs are regenerated only for rendering. Action + identity is always recovered from the proposition key, so sorting and + filtering cannot redirect a button action to another proposition. + """ + previous_key = self._current_key + normalized = [self._normalize_row(row, index) for index, row in enumerate(rows)] + keys = [row['key'] for row in normalized] + if len(set(keys)) != len(keys): + raise ValueError('Merge proposition row keys must be unique.') + self._key_by_id = {row['id']: row['key'] for row in normalized} + self._id_by_key = {key: row_id for row_id, key in self._key_by_id.items()} + self.remove_all_and_add(normalized, fit_columns=not self._column_widths_fitted) + self._current_key = previous_key if previous_key in self._id_by_key else None + if self._current_key is not None: + self.set_selected_ids([self._id_by_key[self._current_key]]) + self._update_action_buttons() + + def select_key(self, key): + """Highlight ``key`` locally, without emitting a workflow event.""" + row_id = self._id_by_key.get(key) + if row_id is None: + return False + self._current_key = key + self.set_selected_ids([row_id]) + self.scroll_to(row_id) + self._update_action_buttons() + return True + + def _on_row_clicked(self, index): + if not index.isValid(): + return + row_id = self._visible_ids()[index.row()] + self._current_key = self._key_by_id[row_id] + self.set_selected_ids([row_id]) + self._update_action_buttons() + + def _row_for_current_key(self): + row_id = self._id_by_key.get(self._current_key) + return self._model.row_by_id(row_id) if row_id is not None else None + + def can_trigger(self, action): + """Whether an explicit action is currently valid for the local row.""" + if action not in self._action_events: + raise ValueError(f'Unknown merge proposition action: {action}.') + row = self._row_for_current_key() + return bool(row and row.get(f'_can_{action}')) + + def trigger(self, action): + """Emit one explicit action for the currently highlighted proposition.""" + if not self.can_trigger(action): + return False + key = self._current_key + emit(self._action_events[action], self, key) + if action == 'skip': + self._select_next_pending() + return True + + def _select_next_pending(self): + visible_ids = self._visible_ids() + if not visible_ids: + return + current_id = self._id_by_key.get(self._current_key) + start = visible_ids.index(current_id) + 1 if current_id in visible_ids else 0 + for row_id in visible_ids[start:] + visible_ids[:start]: + row = self._model.row_by_id(row_id) + if row and row.get('_can_skip'): + self.select_key(self._key_by_id[row_id]) + return + + def _update_action_buttons(self): + for action, button in getattr(self, 'action_buttons', {}).items(): + button.setEnabled(self.can_trigger(action)) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index fc05c7f7..10245287 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -22,6 +22,8 @@ from phy.utils.selection import SelectionIntent, SelectionMutation from ._history import GlobalHistory +from ._proposition_view import MergePropositionsView +from ._propositions import PropositionStatus from ._selection import CurationSelectionController, SelectionChange from ._utils import create_cluster_meta from .clustering import Clustering @@ -38,16 +40,15 @@ def _process_ups(ups): # pragma: no cover """This function processes the UpdateInfo instances of the two undo stacks (clustering and cluster metadata) and concatenates them into a single UpdateInfo instance.""" + ups = tuple(up for up in ups if up is not None) if len(ups) == 0: return elif len(ups) == 1: return ups[0] - elif len(ups) == 2: - up = ups[0] - up.update(ups[1]) - return up - else: - raise NotImplementedError() + up = ups[0] + for other in ups[1:]: + up.update(other) + return up def _ensure_all_ints(l): @@ -752,6 +753,7 @@ def __init__( context=None, n_similar_clusters_to_select=None, skip_masked_clusters=True, + merge_propositions=None, ): super().__init__() self.context = context @@ -759,6 +761,8 @@ def __init__( self.actions = None # will be set when attaching the GUI self.gui = None self.merge_view = None + self.merge_propositions_view = None + self.merge_propositions = merge_propositions self._merge_close_callback = None self._merge_dock_state = None self._suspend_presentation_order_sync = False @@ -1255,7 +1259,55 @@ def _merge_status_text(self): state = self.selection.state staged = len(state.merge_ids) similar = len(state.similar_ids) - return f'MERGE MODE — {staged} staged + {similar} selected similar = {staged + similar} clusters' + source = ( + f' — PROPOSITION {state.merge.proposition_id}' + if state.merge is not None and state.merge.proposition_id + else '' + ) + return ( + f'MERGE MODE{source} — {staged} staged + {similar} selected similar ' + f'= {staged + similar} clusters' + ) + + def _proposition_rows(self): + """Project durable propositions and current live-cluster validity for the view.""" + if self.merge_propositions is None: + return [] + self.merge_propositions.project_live_ids(self.clustering.cluster_ids) + catalog = self.merge_propositions.catalog + rows = [] + for entry in catalog.entries: + proposition = entry.proposition + key = entry.key or f'invalid:{entry.index}' + status = ( + catalog.status_for(key) if entry.key is not None else PropositionStatus.INVALID + ) + unit_ids = proposition.unit_ids if proposition is not None else () + rows.append( + { + 'key': key, + 'unit_ids': unit_ids, + 'status': status.value, + 'reason': entry.invalid_reason + or (catalog.reason_for(key) if entry.key is not None else None), + 'new_unit_id': proposition.new_unit_id if proposition is not None else None, + 'can_review': status is PropositionStatus.PENDING, + 'can_reject': status is PropositionStatus.PENDING, + 'can_skip': status is PropositionStatus.PENDING, + 'can_reset': status + in { + PropositionStatus.ACCEPTED, + PropositionStatus.ACCEPTED_MODIFIED, + PropositionStatus.REJECTED, + } + and set(unit_ids) <= set(catalog.live_unit_ids), + } + ) + return rows + + def _refresh_propositions(self): + if self.merge_propositions_view is not None: + self.merge_propositions_view.set_propositions(self._proposition_rows()) def _apply_selection_change( self, change, callback=None, refresh_similarity=True, publish=True, sync_presentation=True @@ -1401,6 +1453,7 @@ def _restore_history_context(self, selection, workflow_context, direction): self._restore_workflow_context(context) else: self._close_merge_view() + self._refresh_propositions() @staticmethod def _is_merge_history_context(context): @@ -1562,6 +1615,8 @@ def _set_busy(self, busy): self.similarity_view.set_busy(busy) if self.merge_view is not None: self.merge_view.set_busy(busy) + if self.merge_propositions_view is not None: + self.merge_propositions_view.set_busy(busy) # If the GUI is no longer busy, deliver the latest selection on the next timer tick. # Keeping this asynchronous avoids re-entering the task queue during a busy transition. if not busy: @@ -1702,6 +1757,26 @@ def on_default_actions_created(sender): gui.add_view(self.cluster_view, position='left', closable=False) gui.add_view(self.similarity_view, position='left', closable=False) + if self.merge_propositions is not None and self.merge_propositions.catalog.entries: + self.merge_propositions_view = MergePropositionsView( + gui, data=self._proposition_rows() + ) + gui.add_view(self.merge_propositions_view, position='left', closable=False) + connect( + self._review_merge_proposition, + event='review_merge_proposition', + sender=self.merge_propositions_view, + ) + connect( + self._reject_merge_proposition, + event='reject_merge_proposition', + sender=self.merge_propositions_view, + ) + connect( + self._reset_merge_proposition, + event='reset_merge_proposition', + sender=self.merge_propositions_view, + ) # Create all supervisor actions (edit and view menu). self.action_creator.attach(gui) @@ -1729,6 +1804,13 @@ def on_is_busy(sender, is_busy): @connect(sender=gui) def on_close(e): unconnect(on_is_busy, self) + if self.merge_propositions_view is not None: + unconnect( + self.merge_propositions_view, + self._review_merge_proposition, + self._reject_merge_proposition, + self._reset_merge_proposition, + ) @connect(sender=self.cluster_view) def on_ready(sender): @@ -1783,6 +1865,11 @@ def merge(self, cluster_ids=None, to=None): logger.warning('Select at least one additional candidate before merging.') return selection_before = self.selection.snapshot() + proposition_id = ( + selection_before.merge.proposition_id + if merge_mode and selection_before.merge is not None + else None + ) workflow_context = ( {'mode': 'merge', 'tables': self._workflow_context()} if merge_mode else None ) @@ -1801,8 +1888,13 @@ def merge(self, cluster_ids=None, to=None): if merge_mode: self._set_merge_mode_ui(False) self._close_merge_view() + controllers = [self.clustering] + if proposition_id is not None: + self.merge_propositions.accept(proposition_id, tuple(cluster_ids), int(out.added[0])) + controllers.append(self.merge_propositions) + self._refresh_propositions() self._global_history.action( - self.clustering, + *controllers, description='merge', selection_before=selection_before, selection_after=self.selection.snapshot(), @@ -1829,6 +1921,7 @@ def split(self, spike_ids=None, spike_clusters_rel=0): out = self.clustering.split(spike_ids, spike_clusters_rel=spike_clusters_rel) if not getattr(task_logger, '_processing', False): self._select_after_split(out) + self._refresh_propositions() self._global_history.action( self.clustering, description='split', @@ -1950,6 +2043,60 @@ def toggle_merge_mode(self, callback=None): self._apply_selection_change(change, callback=callback) return change.after + def _review_merge_proposition(self, sender, key): + """Open one actionable proposition in the ordinary Merge workspace.""" + if self.selection.state.is_merge_mode: + logger.warning('Finish or cancel the active Merge workspace first.') + return + self.merge_propositions.project_live_ids(self.clustering.cluster_ids) + catalog = self.merge_propositions.catalog + if catalog.status_for(key) is not PropositionStatus.PENDING: + logger.warning('Merge proposition %s is not actionable.', key) + self._refresh_propositions() + return + proposition = catalog.entry_for(key).proposition + self.cluster_view.debouncer.flush() + self.similarity_view.debouncer.flush() + change = self.selection.enter_merge_proposition( + key, proposition.unit_ids, self._workflow_context() + ) + self._create_merge_view() + self._set_merge_mode_ui(True) + self._apply_selection_change(change) + return change.after + + def _reject_merge_proposition(self, sender, key): + if self.selection.state.is_merge_mode: + logger.warning('Cancel the active Merge workspace before rejecting a proposition.') + return + before = self.selection.snapshot() + self.merge_propositions.project_live_ids(self.clustering.cluster_ids) + self.merge_propositions.reject(key) + self._global_history.action( + self.merge_propositions, + description=f'reject merge proposition {key}', + selection_before=before, + selection_after=before, + ) + self._refresh_propositions() + self._update_save_feedback() + + def _reset_merge_proposition(self, sender, key): + if self.selection.state.is_merge_mode: + logger.warning('Cancel the active Merge workspace before resetting a review.') + return + before = self.selection.snapshot() + self.merge_propositions.project_live_ids(self.clustering.cluster_ids) + self.merge_propositions.reset(key) + self._global_history.action( + self.merge_propositions, + description=f'reset merge proposition {key}', + selection_before=before, + selection_after=before, + ) + self._refresh_propositions() + self._update_save_feedback() + def add_to_merge(self, cluster_ids, insertion=None, callback=None): """Transfer candidate IDs into the Merge workspace.""" cluster_ids = tuple(cluster_ids) @@ -2028,7 +2175,15 @@ def last(self, callback=None): def is_dirty(self): """Return whether there are any pending changes.""" - return self._is_dirty if self._is_dirty in (False, True) else len(self._global_history) > 1 + data_dirty = ( + self._is_dirty + if self._is_dirty in (False, True) + else self._global_history.current_position > 0 + ) + review_dirty = bool( + self.merge_propositions is not None and self.merge_propositions.is_dirty() + ) + return data_dirty or review_dirty def _update_save_feedback(self, saved=False): """Reflect the current curation-save state in the attached GUI.""" @@ -2085,6 +2240,13 @@ def save(self): if field not in ('next_cluster') ] emit('save_clustering', self, spike_clusters, groups, *labels) + if self.merge_propositions is not None: + emit( + 'save_proposition_reviews', + self, + self.merge_propositions.catalog.review_mapping(), + ) + self.merge_propositions.mark_saved() # Cache the spikes_per_cluster array. self._save_spikes_per_cluster() self._is_dirty = False diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index b17afa6c..aa113afe 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -22,6 +22,11 @@ from phy.utils.context import Context from .. import supervisor as _supervisor +from .._propositions import ( + MergePropositionController, + PropositionStatus, + decode_curation_mapping, +) from ..supervisor import ( ActionCreator, ClusterView, @@ -736,6 +741,128 @@ def fail(*args, **kwargs): assert supervisor.cluster_view._interaction_blocked +def _proposition_supervisor(gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir): + catalog = decode_curation_mapping( + { + 'format_version': '2', + 'unit_ids': cluster_ids, + 'merges': [{'unit_ids': [30, 20]}, {'unit_ids': [20, 10]}], + } + ) + supervisor = Supervisor( + np.repeat(cluster_ids, 2), + cluster_groups=cluster_groups, + cluster_labels=cluster_labels, + similarity=similarity, + context=Context(tempdir), + merge_propositions=MergePropositionController(catalog), + ) + supervisor.attach(gui) + barrier = Barrier() + connect(barrier('cluster_view'), event='ready', sender=supervisor.cluster_view) + connect(barrier('similarity_view'), event='ready', sender=supervisor.similarity_view) + barrier.wait() + return supervisor + + +def test_merge_proposition_review_cancel_restores_exact_entry( + gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir +): + supervisor = _proposition_supervisor( + gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir + ) + _select(supervisor, [11], [10]) + entry = supervisor.selection.snapshot() + key = supervisor.merge_propositions.catalog.propositions[0].key + view = supervisor.merge_propositions_view + + view.sort_by('key', 'desc') + assert view.select_key(key) + assert supervisor.selection.snapshot() is entry + assert view.trigger('review') + + assert supervisor.selected_merge == [30, 20] + assert supervisor.selection.state.reference_id == 30 + assert supervisor.selection.state.color_indices[30] == 0 + assert supervisor.selection.state.merge.proposition_id == key + assert 'PROPOSITION merge:' in supervisor.merge_view.dock.status + + supervisor.toggle_merge_mode() + assert supervisor.selection.state == entry + assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.PENDING + + +def test_merge_proposition_accept_overlap_and_coupled_undo_redo( + gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir +): + supervisor = _proposition_supervisor( + gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir + ) + first, overlap = supervisor.merge_propositions.catalog.propositions + assignments_before = supervisor.clustering.spike_clusters.copy() + supervisor._review_merge_proposition(supervisor.merge_propositions_view, first.key) + workspace = supervisor.selection.snapshot() + + up = supervisor.merge() + supervisor.block() + + assert ( + supervisor.merge_propositions.catalog.status_for(first.key) is PropositionStatus.ACCEPTED + ) + assert supervisor.merge_propositions.catalog.status_for(overlap.key) is PropositionStatus.STALE + assert supervisor.merge_propositions.catalog.reviews[first.key].applied_unit_ids == (30, 20) + assert supervisor.merge_propositions_view.select_key(overlap.key) + assert not supervisor.merge_propositions_view.can_trigger('review') + assignments_after = supervisor.clustering.spike_clusters.copy() + + supervisor.undo() + supervisor.block() + ae(supervisor.clustering.spike_clusters, assignments_before) + assert supervisor.selection.state == workspace + assert supervisor.merge_propositions.catalog.status_for(first.key) is PropositionStatus.PENDING + assert ( + supervisor.merge_propositions.catalog.status_for(overlap.key) is PropositionStatus.PENDING + ) + + supervisor.redo() + supervisor.block() + ae(supervisor.clustering.spike_clusters, assignments_after) + assert ( + supervisor.merge_propositions.catalog.status_for(first.key) is PropositionStatus.ACCEPTED + ) + assert supervisor.merge_propositions.catalog.status_for(overlap.key) is PropositionStatus.STALE + assert supervisor.selected == list(up.added) + + +def test_failed_proposition_merge_and_reject_history( + monkeypatch, gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir +): + supervisor = _proposition_supervisor( + gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir + ) + key = supervisor.merge_propositions.catalog.propositions[0].key + supervisor._review_merge_proposition(supervisor.merge_propositions_view, key) + workspace = supervisor.selection.snapshot() + + def fail(*args, **kwargs): + raise RuntimeError('merge failed') + + monkeypatch.setattr(supervisor.clustering, 'merge', fail) + with raises(RuntimeError, match='merge failed'): + supervisor.merge() + assert supervisor.selection.state is workspace + assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.PENDING + + supervisor.toggle_merge_mode() + assert supervisor.merge_propositions_view.select_key(key) + assert supervisor.merge_propositions_view.trigger('reject') + assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.REJECTED + supervisor.undo() + assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.PENDING + supervisor.redo() + assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.REJECTED + + def test_saving_gui_state_cancels_transient_merge_selection(supervisor): _select(supervisor, [30], [20]) entry = supervisor.selection.snapshot() From 891f9e8a898d4b85733c05a0902827cd3363b1aa Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 09:32:45 +0200 Subject: [PATCH 084/110] docs: document phy 2.2 merge workflows --- design/README.md | 27 +-- design/amplitude-threshold-splitting.md | 2 +- design/merge-propositions.md | 219 +++++++++++++++++++++++ design/merge-view-architecture.md | 27 +-- design/merge-view-workflow.md | 40 ++--- design/selection-order-color-refactor.md | 4 +- docs/api.md | 27 +++ docs/clustering.md | 23 +++ docs/quickstart.md | 14 ++ 9 files changed, 336 insertions(+), 47 deletions(-) create mode 100644 design/merge-propositions.md diff --git a/design/README.md b/design/README.md index a49b01c3..b00334a1 100644 --- a/design/README.md +++ b/design/README.md @@ -1,7 +1,7 @@ # Design documents -These documents describe proposed or in-progress changes that are not yet part -of the released user documentation. +These documents record implemented and proposed work targeting phy 2.2.0. User +documentation remains authoritative for released behavior. ## Merge View @@ -9,26 +9,28 @@ Read these documents in order: 1. [Merge View workflow specification](merge-view-workflow.md) fixes the agreed user-visible behavior. -2. [Merge View architecture proposal](merge-view-architecture.md) describes the - internal refactor and implementation path needed to support that behavior. +2. [Merge View architecture record](merge-view-architecture.md) describes the + internal refactor supporting that behavior. +3. [Merge Propositions specification](merge-propositions.md) defines review of + AIND/SpikeInterface format-version 2 `curation.json` merge propositions. The workflow specification is the authority for user behavior. The architecture -proposal may evolve as implementation reveals constraints, but changes must not +record may evolve as implementation reveals constraints, but changes must not silently alter the workflow contract. ### Current status -- The manual Merge View workflow and its supporting architecture are implemented - on the feature branch. -- Merge Propositions and `curation.json` are intentionally deferred. -- The remaining work is release review and validation of the implemented - workflow; Merge Propositions remain a separate future project. +- Manual Merge View is implemented on the unreleased phy 2.2 branch. +- Merge Propositions are implemented for Template GUI datasets on that branch. +- Automated release validation is complete (`make test-full`, lint, formatting, + strict documentation build, and package build). Remaining work is maintainer + acceptance and manual dataset smoke testing before release. Agents continuing this work should first read the repository `AGENTS.md`, then both Merge View documents completely. Merge, selection, undo/redo, saved cluster assignments, colors, and cross-view consistency are safety-sensitive; do not declare the feature complete without the regression coverage and verification -listed in the architecture proposal. +listed in the architecture record and proposition specification. ## Amplitude-threshold splitting @@ -36,3 +38,6 @@ The [amplitude-threshold splitting implementation plan](amplitude-threshold-spli defines the user interaction, safety invariants, controller/view boundaries, delegable work packages, and verification required for amplitude-based split previews in Amplitude View and Waveform View. + +The implementation and user documentation are complete on the unreleased phy +2.2 branch. Final large-dataset smoke testing and save/reopen validation remain. diff --git a/design/amplitude-threshold-splitting.md b/design/amplitude-threshold-splitting.md index 7c6e02fd..99b72c26 100644 --- a/design/amplitude-threshold-splitting.md +++ b/design/amplitude-threshold-splitting.md @@ -1,6 +1,6 @@ # Amplitude-threshold splitting implementation plan -Status: proposed implementation plan +Status: implemented on the unreleased phy 2.2 branch; final integration and manual validation pending ## 1. Goal diff --git a/design/merge-propositions.md b/design/merge-propositions.md new file mode 100644 index 00000000..9095acdd --- /dev/null +++ b/design/merge-propositions.md @@ -0,0 +1,219 @@ +# Merge Propositions workflow and implementation specification + +Status: implemented and automatically validated on the unreleased phy 2.2 branch; +manual dataset smoke testing and release acceptance remain + +Companion documents: + +- [Merge View workflow specification](merge-view-workflow.md) +- [Merge View architecture record](merge-view-architecture.md) + +## 1. Goal + +Allow a curator to review automatic merge suggestions from an AIND/SpikeInterface +format-version 2 `curation.json` file through the existing Merge View. Reviewing, +editing, or cancelling a proposition must not bypass the ordinary merge path. +Only `G` commits cluster assignments. + +This is a curation-integrity feature. Proposition input, review decisions, +clustering assignments, undo/redo context, and saved output must remain mutually +consistent. + +## 2. Input contract + +Phy reads dataset-local `curation.json` as an AIND/SpikeInterface curation model. +The first implementation consumes these fields: + +```json +{ + "format_version": "2", + "unit_ids": [41, 56, 72], + "merges": [ + {"unit_ids": [41, 56]}, + {"unit_ids": [56, 72], "new_unit_id": 1000} + ] +} +``` + +Other top-level fields, including `label_definitions`, `manual_labels`, +`removed`, and `splits`, are preserved as input but are not applied by the Merge +Propositions workflow in phy 2.2. + +For each `merges` entry: + +- `unit_ids` is an ordered list containing at least two unique integer cluster + IDs; +- the first ID is the blue reference; +- every ID must appear in the file's top-level `unit_ids` and in the clustering + loaded by phy before the proposition can be reviewed; +- `new_unit_id` is accepted and preserved as provenance, but phy continues to + allocate merge result IDs through its ordinary clustering model; and +- an internal stable key is derived from the ordered `unit_ids`. Exact duplicate + entries are invalid rather than silently coalesced. + +Unsupported format versions or invalid top-level JSON disable the proposition +workflow with a clear warning but never prevent ordinary curation. An invalid +individual merge entry remains visible with its reason when the rest of the +file can be decoded safely. + +## 3. Review state and persistence + +`curation.json` is producer-owned input and is never overwritten by phy. +Dataset-local `curation_review.json` stores phy review decisions: + +```json +{ + "format_version": "1", + "source": { + "filename": "curation.json", + "sha256": "..." + }, + "reviews": { + "merge:0123456789abcdef": { + "decision": "accepted", + "applied_unit_ids": [41, 56], + "result_unit_id": 1001 + } + } +} +``` + +Persisted decisions are `accepted` and `rejected`. `pending` is represented by +the absence of a decision. `accepted_modified` is derived when an accepted +record's `applied_unit_ids` differ from its source proposition. `invalid` and +`stale` are derived from the source and current clustering and are never stored +as curator decisions. + +The source hash detects replacement of `curation.json`. Matching proposition +keys retain their decisions after a source update; unmatched old review records +are preserved as orphaned provenance and reported rather than applied to another +proposition. + +Review state participates in phy's dirty/save lifecycle. On Save, phy first +saves cluster assignments and metadata through the existing model path, then +atomically writes `curation_review.json` through a temporary sibling and replace. +The dirty state is cleared only after both succeed. Closing with unsaved review +decisions uses the existing save prompt. + +## 4. User-visible workflow + +When a valid `curation.json` contains merges, phy creates a persistent **Merge +Propositions** view. It shows status, proposition key, ordered cluster IDs, +cluster count, reference, and `new_unit_id` when present. + +Available operations are: + +- **Review**: open the proposition in Merge mode; +- **Reject**: record a reversible rejection without changing clustering; +- **Skip / Next pending**: navigate without changing review state; and +- **Reset review**: return an accepted or rejected proposition to pending when + its source clusters still exist. + +Invalid and stale propositions remain visible with a reason but cannot be +reviewed. + +Review is explicit; ordinary row selection does not enter Merge mode. Starting a +review snapshots the complete current Normal workspace, then stages the ordered +proposition IDs directly. It must not first project those IDs into Cluster View, +because cancellation must restore the curator's pre-review state. + +While reviewing: + +- the existing Merge View, Similarity View, transfer, reorder, and color rules + remain authoritative; +- the Merge status identifies the source proposition; +- the curator may add, remove, or reorder candidates; +- `V`, Cancel, or closing Merge View restores the exact Normal entry snapshot + and leaves the proposition pending; and +- another proposition cannot be reviewed concurrently. + +On `G`, phy calls the ordinary merge implementation. Only after that call +succeeds does it record the proposition as accepted, including the actual +ordered merge IDs and result cluster ID. A changed workspace produces the +derived `accepted_modified` status. Failure leaves the workspace and review +state unchanged. + +Reject creates a review-history entry and is undoable. Skip does not create +history. Reset review is explicit and undoable. + +## 5. Overlap, stale IDs, and clustering changes + +Overlapping propositions are allowed in the input. After any merge or split, +pending propositions are revalidated against live cluster IDs. A proposition +with a missing source ID becomes stale and cannot be partially applied. + +Phy never remaps a stale proposition through clustering descendants. In +particular, a split may produce several descendants, and choosing one +automatically could commit an unintended merge. + +Undoing an accepted proposition restores: + +- the original spike assignments; +- the proposition's prior review state; +- the complete pre-commit Merge workspace; +- the Normal-entry snapshot used by cancellation; and +- derived validity of overlapping propositions. + +Redo reapplies the merge, restores the accepted decision, and exits Merge mode. + +## 6. Architecture boundaries + +Durable proposition state is separate from `CurationSelectionState`: + +```text +MergePropositionController + | begin review + v +CurationSelectionController + | successful G + v +Clustering + proposition resolution in one GlobalHistory entry +``` + +The pure proposition module owns decoding, validation, review transitions, +serialization, and live-ID validity projection. It performs no Qt, filesystem, +or spike-array work. + +`MergeSession` gains optional proposition provenance. The selection controller +gains one atomic operation that stages arbitrary validated proposition IDs while +capturing the current Normal snapshot. + +`Supervisor` remains the workflow facade. The application/controller layer owns +dataset paths and file I/O. Neither the proposition view nor the selection state +loads or writes JSON. + +The initial implementation applies to Template GUI datasets. Legacy Kwik +support is deferred. + +## 7. Required regression coverage + +- Format-v2 round trip, optional fields, unsupported versions, malformed JSON, + duplicate entries, missing IDs, and mixed valid/invalid merges. +- Stable proposition keys and source-file change detection. +- Exact Normal-state cancellation after starting a review. +- Ordered staging with the first ID fixed as blue reference. +- Invalid and stale propositions cannot enter Merge mode. +- Failed merge preserves review state and the complete workspace. +- Successful and modified acceptance record exact applied and result IDs. +- Overlapping propositions become stale without automatic remapping. +- Manual merge and split safely revalidate pending propositions. +- Undo/redo couples assignments, review decisions, colors, presentation, table + context, and Merge workspace. +- Reject/reset undo and redo. +- Save/reopen after accept and reject, atomic-write failure, and external source + replacement. +- View close/reopen and controller shutdown release callbacks. +- Catalog updates operate on cluster/proposition IDs and never scan spikes. + +## 8. Implementation packages + +1. Pure proposition domain, codec, and tests. +2. Standalone Merge Propositions view and Qt tests. +3. Atomic review persistence adapter and tests. +4. Selection, Supervisor, history, and failure-atomicity integration. +5. BaseController loading/saving and application regressions. +6. User documentation, changelog, generated documentation, and final audit. + +Packages 1-3 may run in parallel after this contract is frozen. Package 4 has a +single integration owner because selection, merge, undo/redo, and saved curation +are safety-sensitive. diff --git a/design/merge-view-architecture.md b/design/merge-view-architecture.md index 81b08408..79a76c6d 100644 --- a/design/merge-view-architecture.md +++ b/design/merge-view-architecture.md @@ -1,9 +1,10 @@ -# Merge View architecture proposal +# Merge View architecture record -Status: implemented for phy 2.2.0 +Status: implemented and automatically validated on the unreleased phy 2.2 branch; +manual dataset smoke testing and release acceptance remain -This document describes the internal architecture and incremental refactor -recommended for implementing the user behavior fixed in the +This document records the internal architecture and incremental refactor used to +implement the user behavior fixed in the [Merge View workflow specification](merge-view-workflow.md). The workflow specification is authoritative when this document discusses implementation tradeoffs. @@ -25,16 +26,16 @@ The architectural goal is to support this workflow while improving the current selection, action, and history boundaries. The refactor should be incremental and should preserve public plugin APIs. -The following are out of scope: +The following were out of scope for the manual-workflow implementation: -- Merge Propositions and proposition review states; -- `curation.json` or external proposition-file schemas; +- Merge Propositions and proposition review states, now specified separately in + [Merge Propositions](merge-propositions.md); - a global application-state framework; - a rewrite of scientific/OpenGL views; - clustering-algorithm changes; and - unrelated GUI modernization. -## 2. Current architecture and constraints +## 2. Pre-implementation architecture and constraints ### 2.1 Selection authority is indirect @@ -522,7 +523,7 @@ The initial Merge-mode action policy is: - disable split, group/metadata changes, Cluster navigation, and Cluster selection; -- allow Similarity navigation, filtering, sorting, Ctrl+Space, Backspace, `C`, +- allow Similarity navigation, filtering, sorting, Ctrl+Space, Backspace, `V`, `G`, and save; - reject unsafe direct or plugin calls explicitly without partially mutating the workspace; @@ -574,7 +575,7 @@ restoration is best effort where Qt exposes a reliable value. ### Phase 5: Merge mode without drag-and-drop - Add `MergeSession` and mode transitions. -- Add Merge View, `C`, Ctrl+right-click transfers, Backspace behavior, mode +- Add Merge View, `V`, Ctrl+right-click transfers, Backspace behavior, mode indication, cancellation, and `G` semantics. - Add exact cancel and merge undo/redo restoration tests. @@ -699,8 +700,10 @@ before their implementation phase: - whether the first contextual-history implementation uses the existing `request_undo_state` hook as a transition step. -Merge Propositions, persistence of proposition state, overlapping proposals, and -external JSON remain separate future design work. +TaskLogger no longer owns selection state, but its optional structural split into +separate action-runner and post-action-policy classes remains deferred cleanup. +Merge Propositions and their persistence are specified in +[Merge Propositions](merge-propositions.md). ## 13. Handoff for future agents diff --git a/design/merge-view-workflow.md b/design/merge-view-workflow.md index 08fb70ae..2e4197bb 100644 --- a/design/merge-view-workflow.md +++ b/design/merge-view-workflow.md @@ -1,13 +1,13 @@ # Merge View workflow specification -Status: implemented for phy 2.2.0 +Status: implemented and automatically validated on the unreleased phy 2.2 branch; +manual dataset smoke testing and release acceptance remain -Companion document: [Merge View architecture proposal](merge-view-architecture.md) +Companion document: [Merge View architecture record](merge-view-architecture.md) -This document fixes the intended user-facing behavior of the manual Merge View -workflow before implementation. It deliberately does not specify Merge -Propositions, their JSON format, or their review states; those will be designed -separately after the manual workflow is validated. +This document fixes the user-facing behavior of the manual Merge View workflow. +Merge Propositions extend this contract in +[their own specification](merge-propositions.md). ## Purpose @@ -30,15 +30,15 @@ The GUI has two mutually exclusive modes: The effective selection is what graphical and scientific views display. It is also what `G` merges. Transferring a cluster between Similarity View and Merge -View does not change the effective selection and therefore must not cause an -effective selection update or unnecessary redraw. +View does not change effective membership. It publishes a render update only +when the transfer also changes presentation order. Merge mode is active exactly while Merge View contains its blue reference cluster. ## Entering Merge mode -`C` enters Merge mode. At least one cluster must be selected in Cluster View. If +`V` enters Merge mode. At least one cluster must be selected in Cluster View. If there is no Cluster View selection, the action does nothing and reports why. Before changing the UI, phy snapshots the complete state needed to restore the @@ -59,8 +59,9 @@ All selected clusters are transferred into Merge View in this order: After the transfer: - Cluster View and Similarity View have no selected rows; -- Cluster View remains visible but is clearly disabled and cannot be selected, - navigated, filtered, sorted, or used as a drag source or target; +- Cluster View remains visible and scrollable but is clearly read-only: it + cannot be selected, navigated, filtered, sorted, or used as a drag source or + target; - Similarity View remains enabled and remains calculated relative to the blue reference; and - the effective selection and graphical displays initially remain unchanged. @@ -159,7 +160,7 @@ If the merge fails, the complete Merge-mode state remains unchanged. All of the following cancel Merge mode: -- pressing `C` while Merge mode is active; +- pressing `V` while Merge mode is active; - activating a prominent **Cancel Merge Mode** control; or - closing Merge View. @@ -199,21 +200,18 @@ entries in the clustering undo stack. | State | Action | Result | | --- | --- | --- | -| Normal | `C` | Snapshot state, transfer all selections, enter Merge mode | +| Normal | `V` | Snapshot state, transfer all selections, enter Merge mode | | Merge | Ctrl+right-click Similarity | Transfer clicked candidate to Merge | | Merge | Ctrl+right-click removable Merge row | Transfer candidate to Similarity | | Merge | Ctrl+Space | Select the next Similarity candidates | | Merge | Backspace | Clear only the Similarity selection | | Merge | `G` | Merge Merge contents plus selected Similarity candidates | -| Merge | `C`, Cancel, or close Merge View | Restore the entry snapshot exactly | +| Merge | `V`, Cancel, or close Merge View | Restore the entry snapshot exactly | | After Merge-mode merge | Undo | Restore clusters and pre-commit Merge workspace | | Restored after undo | Redo | Reapply merge and return to normal mode | -## Out of scope +## Extension -The following are intentionally deferred: - -- Merge Propositions view and workflow; -- `curation.json` and external proposition-file schemas; -- proposition review and resolution states; and -- partial, overlapping, or invalid propositions. +The [Merge Propositions specification](merge-propositions.md) defines how +external propositions enter this workspace without changing the manual workflow +contract. diff --git a/design/selection-order-color-refactor.md b/design/selection-order-color-refactor.md index 6027ad3f..e0a729e8 100644 --- a/design/selection-order-color-refactor.md +++ b/design/selection-order-color-refactor.md @@ -1,10 +1,10 @@ # Selection ordering and color-state refactor -Status: implementation specification +Status: implemented on the unreleased phy 2.2 branch; final coverage audit pending ## 1. Motivation -Cluster selection currently has three partially independent representations: +Before this refactor, cluster selection had three partially independent representations: - role membership and `presentation_order` in `CurationSelectionState`; - a mutable `_selection_color_order` in `Supervisor`; and diff --git a/docs/api.md b/docs/api.md index 48bdfd0f..10504dda 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12563,6 +12563,15 @@ Save the modified data. --- +#### BaseController.on_save_proposition_reviews + + +**`BaseController.on_save_proposition_reviews(self, sender, mapping)`** + +Atomically save phy-owned review state after cluster assignments. + +--- + #### BaseController.peak_channel_similarity @@ -13277,6 +13286,15 @@ Save the modified data. --- +#### TemplateController.on_save_proposition_reviews + + +**`TemplateController.on_save_proposition_reviews(self, sender, mapping)`** + +Atomically save phy-owned review state after cluster assignments. + +--- + #### TemplateController.peak_channel_similarity @@ -13940,6 +13958,15 @@ Save the modified data. --- +#### KwikController.on_save_proposition_reviews + + +**`KwikController.on_save_proposition_reviews(self, sender, mapping)`** + +Atomically save phy-owned review state after cluster assignments. + +--- + #### KwikController.peak_channel_similarity diff --git a/docs/clustering.md b/docs/clustering.md index 4733d2a8..3c142f1b 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -36,6 +36,29 @@ use **Cancel Merge Mode**, or close Merge View to cancel and restore the exact s entry. Undoing a committed Merge-mode merge restores the complete workspace as it appeared just before `G`; Redo reapplies the merge and returns to the normal workflow. +### Reviewing merge propositions + +For Template GUI datasets, phy can also review automatic merge propositions from a +dataset-local AIND/SpikeInterface format-version 2 `curation.json`. When the file +contains valid `merges`, the persistent **Merge Propositions** view lists their +ordered cluster IDs, status, and any supplied `new_unit_id`. The first ID is the +blue reference; `new_unit_id` is provenance only because phy allocates the result +through its ordinary merge model. + +Select a proposition and choose **Review** to stage it in the normal Merge View. +You can still add, remove, and reorder candidates there. Press `G` to accept only +after the ordinary merge succeeds; an edited set of candidates is marked +`accepted_modified`. Press `V`, use Cancel, or close Merge View to leave the +proposition pending. **Reject** and **Reset review** are undoable; **Skip / Next +pending** only navigates. Overlapping propositions are allowed, but one whose +source cluster no longer exists after a merge or split becomes stale and is never +automatically remapped. + +phy never overwrites producer-owned `curation.json`. It stores accepted and +rejected decisions in dataset-local `curation_review.json`, atomically after the +ordinary clustering files have been saved. Undo and redo restore both the +clustering result and the associated proposition review state. + ## Splitting clusters diff --git a/docs/quickstart.md b/docs/quickstart.md index 11cb2786..b9b990ec 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -119,6 +119,16 @@ For a longer comparison, press `V` first. Merge View keeps the candidates staged continue exploring Similarity View. Its status shows exactly how many clusters `G` will merge. Press `V` again or close Merge View to cancel without changing the clustering. +If the Template GUI dataset includes an AIND/SpikeInterface format-version 2 +`curation.json` with merge suggestions, use the persistent **Merge Propositions** +view to review them. Choose **Review** to stage its ordered IDs in Merge View (the +first is blue), then use `G` to accept the ordinary merge; changes you make to the +proposal are recorded as `accepted_modified`. **Reject** and **Reset review** are +undoable, while **Skip** leaves it pending. A proposition whose source clusters +were changed becomes stale rather than being remapped. phy leaves `curation.json` +unchanged and atomically saves decisions to `curation_review.json` with the rest +of the curation results. + Splitting requires selecting spikes in a view that supports lasso or polygon selection, commonly the Feature View, and pressing `K`. It is worth learning merge, undo, and save on the example dataset before attempting a scientific @@ -154,10 +164,14 @@ dataset directory are: - other `cluster_.tsv` files for additional labels; - `cluster_info.tsv`: a convenient snapshot of the columns currently exported from the Cluster View. +- `curation_review.json`: accepted and rejected Merge Propositions decisions, + when that workflow is in use. The original `spike_templates.npy` is not changed by merges or splits. `cluster_info.tsv` is a derived summary; use `spike_clusters.npy` and the `cluster_.tsv` files as the primary curation results. +`curation.json`, when present, remains producer-owned input and is never +overwritten. phy does **not** automatically make a backup before overwriting these files. Keep the pre-curation copy made above, and consider dated snapshots or version From e9c8953a9017e3b20220d73e07862abca30f59f8 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 09:32:54 +0200 Subject: [PATCH 085/110] chore: begin phy 2.2 development --- docs/changelog.md | 9 ++++++++- docs/index.md | 2 +- docs/release.md | 2 +- phy/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 6 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 382aab98..261ae823 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,7 +4,7 @@ This file records user-visible changes to phy. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) where practical. -## [Unreleased] — 2.1.1.dev0 +## [Unreleased] — 2.2.0.dev0 Changes below are available from the latest source checkout but have not yet been included in a stable release. The current entries cover all user-visible @@ -35,6 +35,13 @@ behavior they verify rather than listed separately. View remains scrollable. Scientific views follow Merge View order and then selected Similarity rows in visible table order. Cancellation restores the entry state, and undo restores the full pre-merge workspace. +- Review AIND/SpikeInterface format-version 2 merge propositions from + dataset-local `curation.json` in a persistent **Merge Propositions** view. + Review stages the ordered proposition in Merge View (with the first unit blue); + `G` accepts the ordinary merge, while edited acceptance is marked + `accepted_modified`. Reject and reset are undoable, stale overlapping proposals + are never remapped, and decisions are atomically saved in + `curation_review.json` without overwriting `curation.json`. - Select the first eligible clusters in the Similarity View with `Control+Space`; repeat the shortcut to select successive batches. The diff --git a/docs/index.md b/docs/index.md index 9b26f14f..13fab182 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ phy is an open-source graphical interface for visualizing and manually curating electrophysiology datasets. It is optimized for high-density probes, including Neuropixels, and provides both an interactive curation workflow and Python extension points for specialized labs. -> **Current stable release:** phy 2.1.0. The source tree is developing phy 2.1.1. See the +> **Current stable release:** phy 2.1.0. The source tree is developing phy 2.2.0. See the > [release notes](release.md) and [changelog](changelog.md). [![Template GUI](https://user-images.githubusercontent.com/1942359/74028054-c284b880-49a9-11ea-8815-1b7e727a8644.png)](https://user-images.githubusercontent.com/1942359/74028054-c284b880-49a9-11ea-8815-1b7e727a8644.png) diff --git a/docs/release.md b/docs/release.md index ec1be080..cb61407f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,7 +1,7 @@ # Release notes The current stable release is phy 2.1.0, published on 17 July 2026. The source -tree is currently developing phy 2.1.1. +tree is currently developing phy 2.2.0. This page describes the latest stable release. For a version-by-version list of user-visible changes, including work not yet released, see the diff --git a/phy/__init__.py b/phy/__init__.py index 5da8995b..7c7e227a 100644 --- a/phy/__init__.py +++ b/phy/__init__.py @@ -27,7 +27,7 @@ __author__ = 'Cyrille Rossant' __email__ = 'cyrille.rossant at gmail.com' -__version__ = '2.1.1.dev0' +__version__ = '2.2.0.dev0' __version_git__ = __version__ + _git_version() diff --git a/pyproject.toml b/pyproject.toml index 9cac0665..0186ae8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "phy" -version = "2.1.1.dev0" # No dynamic lookup needed +version = "2.2.0.dev0" # No dynamic lookup needed description = "Interactive visualization and manual spike sorting of large-scale ephys data" readme = "README.md" license = "BSD-3-Clause" diff --git a/uv.lock b/uv.lock index a1c16972..3b3641f5 100644 --- a/uv.lock +++ b/uv.lock @@ -1415,7 +1415,7 @@ wheels = [ [[package]] name = "phy" -version = "2.1.1.dev0" +version = "2.2.0.dev0" source = { editable = "." } dependencies = [ { name = "click" }, From 8f76e06fa79e5e9a1db7293c2a003138cfbaf66f Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 09:48:43 +0200 Subject: [PATCH 086/110] fix: release supervisor callbacks on GUI close --- docs/changelog.md | 3 +++ phy/cluster/supervisor.py | 32 ++++++++++++++++++------- phy/cluster/tests/test_supervisor.py | 35 ++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 261ae823..1f40ac06 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -65,6 +65,9 @@ behavior they verify rather than listed separately. ### Fixed +- Release GUI, Supervisor, table, dock, and curation event callbacks when a + dataset window closes, preventing retained Qt widgets and intermittent + process crashes during shutdown. - Closing the Merge View now restores staged clusters to their original Cluster and Similarity View rows, selections, and table positions. - Show the active sort column and direction in Cluster and Similarity View diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 10245287..f808536b 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1803,14 +1803,30 @@ def on_is_busy(sender, is_busy): @connect(sender=gui) def on_close(e): - unconnect(on_is_busy, self) - if self.merge_propositions_view is not None: - unconnect( - self.merge_propositions_view, - self._review_merge_proposition, - self._reject_merge_proposition, - self._reset_merge_proposition, - ) + # The event registry holds strong references to senders and callbacks. + # Release every object owned by this attachment before Qt destroys its + # widgets; otherwise deleted QWidget wrappers can survive until Python + # interpreter shutdown and make Qt teardown nondeterministic. + views = ( + self.cluster_view, + self.similarity_view, + self.merge_view, + self.merge_propositions_view, + ) + docks = tuple(view.dock for view in views if view is not None) + unconnect( + on_is_busy, + self._merge_close_callback, + gui, + self, + self.action_creator, + self.clustering, + self.cluster_meta, + self.merge_propositions, + *views, + *docks, + ) + self._merge_close_callback = None @connect(sender=self.cluster_view) def on_ready(sender): diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index aa113afe..a669d658 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -11,6 +11,7 @@ import numpy as np from numpy.testing import assert_array_equal as ae from phylib.utils import Bunch, connect, emit, unconnect +from phylib.utils.event import _EVENT from pytest import fixture, raises from phy.gui import GUI @@ -863,6 +864,40 @@ def fail(*args, **kwargs): assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.REJECTED +def test_supervisor_close_releases_owned_event_callbacks( + gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir +): + supervisor = _proposition_supervisor( + gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir + ) + key = supervisor.merge_propositions.catalog.propositions[0].key + supervisor._review_merge_proposition(supervisor.merge_propositions_view, key) + views = ( + supervisor.cluster_view, + supervisor.similarity_view, + supervisor.merge_view, + supervisor.merge_propositions_view, + ) + owned = { + gui, + supervisor, + supervisor.action_creator, + supervisor.clustering, + supervisor.cluster_meta, + supervisor.merge_propositions, + *views, + *(view.dock for view in views), + } + + gui.close() + + assert supervisor._merge_close_callback is None + assert not any( + sender in owned or getattr(callback, '__self__', None) in owned + for _, sender, callback, _ in _EVENT._callbacks + ) + + def test_saving_gui_state_cancels_transient_merge_selection(supervisor): _select(supervisor, [30], [20]) entry = supervisor.selection.snapshot() From 1c20ebca9f4bbd960feafb7ec8b49acd68d6c542 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 11:44:02 +0200 Subject: [PATCH 087/110] feat: streamline merge proposition review --- phy/cluster/_history.py | 10 +- phy/cluster/_proposition_view.py | 266 +++++++++++-------- phy/cluster/supervisor.py | 294 +++++++++++++++++++-- phy/cluster/tests/test_history.py | 30 +++ phy/cluster/tests/test_proposition_view.py | 49 ++++ phy/cluster/tests/test_supervisor.py | 103 +++++++- phy/cluster/views/trace.py | 4 +- phy/gui/widgets.py | 2 + 8 files changed, 602 insertions(+), 156 deletions(-) create mode 100644 phy/cluster/tests/test_proposition_view.py diff --git a/phy/cluster/_history.py b/phy/cluster/_history.py index 0038645c..04daec1f 100644 --- a/phy/cluster/_history.py +++ b/phy/cluster/_history.py @@ -136,6 +136,7 @@ class CurationHistoryEntry: selection_before: object = None selection_after: object = None workflow_context: object = None + workflow_context_after: object = None class GlobalHistory(History): @@ -153,6 +154,7 @@ def action( selection_before=None, selection_after=None, workflow_context=None, + workflow_context_after=None, ): """Register one or several controllers for this action.""" self.add( @@ -162,6 +164,7 @@ def action( selection_before=selection_before, selection_after=selection_after, workflow_context=workflow_context, + workflow_context_after=workflow_context_after, ) ) @@ -179,7 +182,12 @@ def update_current_context(self, **kwargs): def _restore(self, entry, selection, direction): if self.restore_context is not None and selection is not None: - self.restore_context(selection, entry.workflow_context, direction) + context = ( + entry.workflow_context_after + if direction == 'redo' and entry.workflow_context_after is not None + else entry.workflow_context + ) + self.restore_context(selection, context, direction) def undo(self): """Undo the last action. diff --git a/phy/cluster/_proposition_view.py b/phy/cluster/_proposition_view.py index da000dce..9f913484 100644 --- a/phy/cluster/_proposition_view.py +++ b/phy/cluster/_proposition_view.py @@ -1,8 +1,8 @@ -"""Presentation-only table for automatic merge propositions. +"""Compact presentation table for automatic merge propositions. -The view intentionally accepts plain row dictionaries. It has no dependency on -the proposition catalog or on a :class:`~phy.cluster.supervisor.Supervisor`, so -the GUI cannot become an alternative source of curation state. +This view owns only presentation state. Proposition identity is always the +catalog's stable string key; the integer ``id`` used by :class:`Table` is an +ephemeral rendering implementation detail. """ from __future__ import annotations @@ -11,35 +11,32 @@ from phylib.utils import emit -from phy.gui.qt import QAbstractItemView, QHBoxLayout, QPushButton +from phy.gui.qt import QAbstractItemView, QColor from phy.gui.widgets import Table class MergePropositionsView(Table): - """A persistent, local-selection table for merge-proposition projections. + """A compact, persistent table of merge-proposition projections. - Rows supplied to :meth:`set_propositions` require a string ``key`` and may - provide ``unit_ids``, ``status``, ``reason``, and ``new_unit_id``. Button - events carry only the stable proposition key: - - * ``review_merge_proposition`` - * ``reject_merge_proposition`` - * ``skip_merge_proposition`` - * ``reset_merge_proposition`` - - Selecting a row is deliberately local presentation state: unlike a cluster - table it never emits ``select`` and never starts a merge review. + Rows supplied to :meth:`set_propositions` require a non-empty string + ``key`` and may provide ``unit_ids``, ``status``, ``reason``, and + ``new_unit_id``. Clicking a row emits ``activate_merge_proposition`` with + that stable key. It does not itself alter curation or merge-workspace + state. """ - _columns = ( - 'key', - 'unit_ids', - 'status', - 'reason', - 'n_clusters', - 'reference', - 'new_unit_id', - ) + _columns = ('proposition',) + _status_colors = { + 'active': '#5ca8ff', + 'accepted': '#86d16d', + 'accepted_modified': '#e6ad4c', + 'rejected': '#888888', + 'stale': '#e58b3c', + 'invalid': '#e05a5a', + } + # Kept as a programmatic compatibility surface while the visible action + # buttons are deliberately removed. New callers should use activation and + # navigation methods above; the Supervisor still owns these mutations. _action_events = { 'review': 'review_merge_proposition', 'reject': 'reject_merge_proposition', @@ -51,10 +48,10 @@ def __init__(self, *args, data=None, **kwargs): super().__init__( *args, title='MERGE PROPOSITIONS', - columns=['id', *self._columns], - value_names=['id', *self._columns], + columns=self._columns, + value_names=self._columns, data=[], - sort=('status', 'asc'), + sort=None, debounce_events=(), skip_masked=False, **kwargs, @@ -63,33 +60,13 @@ def __init__(self, *args, data=None, **kwargs): self._id_by_key = {} self._current_key = None self.table_view.setSelectionMode(QAbstractItemView.SingleSelection) - self._create_actions() self.set_propositions(data or ()) @property def current_key(self): - """The key of the locally highlighted proposition, if any.""" + """The locally highlighted proposition key, if any.""" return self._current_key - def _create_actions(self): - layout = QHBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - self.action_buttons = {} - for action, label in ( - ('review', 'Review'), - ('reject', 'Reject'), - ('skip', 'Skip'), - ('reset', 'Reset review'), - ): - button = QPushButton(label, self) - button.setObjectName(f'merge-proposition-{action}') - button.clicked.connect(lambda _checked=False, action=action: self.trigger(action)) - layout.addWidget(button) - self.action_buttons[action] = button - # The Table layout is [filter, table]. Put controls between them. - self.layout().insertLayout(1, layout) - self._update_action_buttons() - @staticmethod def _as_ordered_ids(value): if value is None: @@ -98,6 +75,18 @@ def _as_ordered_ids(value): return (value,) return tuple(value) + @staticmethod + def _format_proposition(unit_ids, new_unit_id=None): + """Return the compact, scan-friendly proposition label.""" + labels = tuple(map(str, unit_ids)) + if len(labels) <= 4: + text = ', '.join(labels) + else: + text = f'{labels[0]}, {labels[1]}, …, {labels[-1]} ({len(labels)})' + if new_unit_id is not None and new_unit_id != '': + text = f'{text} ⇒ {new_unit_id}' + return text + def _normalize_row(self, row, index): if not isinstance(row, Mapping): raise TypeError('Merge proposition rows must be mappings.') @@ -106,41 +95,48 @@ def _normalize_row(self, row, index): raise ValueError('Every merge proposition row requires a non-empty string key.') unit_ids = self._as_ordered_ids(row.get('unit_ids')) status = str(row.get('status', 'pending')) + new_unit_id = row.get('new_unit_id') + reference = row.get('reference', unit_ids[0] if unit_ids else None) invalid_or_stale = status in {'invalid', 'stale'} - can_review = bool(row.get('can_review', not invalid_or_stale)) - can_reject = bool(row.get('can_reject', status == 'pending' and not invalid_or_stale)) - can_skip = bool(row.get('can_skip', status == 'pending' and not invalid_or_stale)) - can_reset = bool( - row.get( - 'can_reset', - status in {'accepted', 'accepted_modified', 'rejected'} and not invalid_or_stale, - ) - ) + full_proposition = ', '.join(map(str, unit_ids)) + if new_unit_id is not None and new_unit_id != '': + full_proposition = f'{full_proposition} ⇒ {new_unit_id}' + tooltip = f'{full_proposition}\nStatus: {status}' + if reference is not None: + tooltip = f'{tooltip}\nReference: {reference}' + tooltip = f'{tooltip}\nKey: {key}' + if row.get('reason'): + tooltip = f'{tooltip}\n{row["reason"]}' return { - # Table itself uses integer IDs. The catalog key remains a separate, - # visible column and is recovered through ``_key_by_id`` after every - # sort or filter operation. 'id': index, + 'proposition': self._format_proposition(unit_ids, new_unit_id), + # Retain full metadata in the model for filtering, status text, and + # stable-key recovery, but do not expose it as a table column. 'key': key, 'unit_ids': unit_ids, 'status': status, + 'reference': reference, 'reason': row.get('reason') or '', - 'n_clusters': len(unit_ids), - 'reference': unit_ids[0] if unit_ids else '', - 'new_unit_id': row.get('new_unit_id', ''), - '_can_review': can_review, - '_can_reject': can_reject, - '_can_skip': can_skip, - '_can_reset': can_reset, + 'new_unit_id': new_unit_id, + '_proposition_tooltip': tooltip, + '_can_review': bool( + row.get('can_review', status == 'pending' and not invalid_or_stale) + ), + '_can_reject': bool( + row.get('can_reject', status == 'pending' and not invalid_or_stale) + ), + '_can_skip': bool(row.get('can_skip', status == 'pending' and not invalid_or_stale)), + '_can_reset': bool( + row.get( + 'can_reset', + status in {'accepted', 'accepted_modified', 'rejected'} + and not invalid_or_stale, + ) + ), } def set_propositions(self, rows: Iterable[Mapping]): - """Replace the table projection while retaining a surviving local row. - - The integer table row IDs are regenerated only for rendering. Action - identity is always recovered from the proposition key, so sorting and - filtering cannot redirect a button action to another proposition. - """ + """Replace the projection while retaining a still-present highlight.""" previous_key = self._current_key normalized = [self._normalize_row(row, index) for index, row in enumerate(rows)] keys = [row['key'] for row in normalized] @@ -152,60 +148,98 @@ def set_propositions(self, rows: Iterable[Mapping]): self._current_key = previous_key if previous_key in self._id_by_key else None if self._current_key is not None: self.set_selected_ids([self._id_by_key[self._current_key]]) - self._update_action_buttons() + self._set_dock_status(self._current_key) + + def visible_keys(self): + """Return stable keys in the table's current visible sorted order.""" + return tuple(self._key_by_id[row_id] for row_id in self._visible_ids()) + + def actionable_keys(self): + """Return visible pending/reviewable keys in their current table order.""" + return tuple( + self._key_by_id[row_id] + for row_id in self._visible_ids() + if self._model.row_by_id(row_id).get('_can_review') + ) - def select_key(self, key): - """Highlight ``key`` locally, without emitting a workflow event.""" + def is_actionable_key(self, key): row_id = self._id_by_key.get(key) - if row_id is None: - return False - self._current_key = key - self.set_selected_ids([row_id]) - self.scroll_to(row_id) - self._update_action_buttons() - return True - - def _on_row_clicked(self, index): - if not index.isValid(): - return - row_id = self._visible_ids()[index.row()] - self._current_key = self._key_by_id[row_id] - self.set_selected_ids([row_id]) - self._update_action_buttons() - - def _row_for_current_key(self): - row_id = self._id_by_key.get(self._current_key) - return self._model.row_by_id(row_id) if row_id is not None else None + row = self._model.row_by_id(row_id) if row_id is not None else None + return bool(row and row.get('_can_review')) def can_trigger(self, action): - """Whether an explicit action is currently valid for the local row.""" + """Whether the legacy programmatic action is valid for the current row.""" if action not in self._action_events: raise ValueError(f'Unknown merge proposition action: {action}.') - row = self._row_for_current_key() + row_id = self._id_by_key.get(self._current_key) + row = self._model.row_by_id(row_id) if row_id is not None else None return bool(row and row.get(f'_can_{action}')) def trigger(self, action): - """Emit one explicit action for the currently highlighted proposition.""" + """Emit a legacy programmatic mutation intent for the current stable key.""" if not self.can_trigger(action): return False - key = self._current_key - emit(self._action_events[action], self, key) + emit(self._action_events[action], self, self._current_key) if action == 'skip': - self._select_next_pending() + self.select_next_actionable() + return True + + def select_key(self, key): + """Highlight ``key`` locally, without emitting an activation event.""" + row_id = self._id_by_key.get(key) + if row_id is None: + return False + self._current_key = key + self.set_selected_ids([row_id]) + self.scroll_to(row_id) + self._set_dock_status(key) return True - def _select_next_pending(self): + def _select_actionable(self, direction): + keys = self.actionable_keys() + if not keys: + return None + if self._current_key not in keys: + key = keys[0] if direction == 'next' else keys[-1] + else: + index = keys.index(self._current_key) + (1 if direction == 'next' else -1) + index %= len(keys) + key = keys[index] + return key if self.select_key(key) else None + + def select_next_actionable(self): + """Select the next visible actionable entry, wrapping once.""" + return self._select_actionable('next') + + def select_previous_actionable(self): + """Select the previous visible actionable entry, wrapping once.""" + return self._select_actionable('previous') + + def _on_row_clicked(self, index): + if not index.isValid(): + return visible_ids = self._visible_ids() - if not visible_ids: + if not 0 <= index.row() < len(visible_ids): + return + key = self._key_by_id[visible_ids[index.row()]] + self.select_key(key) + emit('activate_merge_proposition', self, key) + + def _set_dock_status(self, key): + """Expose hidden metadata through the dock status line when available.""" + row_id = self._id_by_key.get(key) + row = self._model.row_by_id(row_id) if row_id is not None else None + dock = getattr(self, 'dock', None) + if row is None or dock is None: return - current_id = self._id_by_key.get(self._current_key) - start = visible_ids.index(current_id) + 1 if current_id in visible_ids else 0 - for row_id in visible_ids[start:] + visible_ids[:start]: - row = self._model.row_by_id(row_id) - if row and row.get('_can_skip'): - self.select_key(self._key_by_id[row_id]) - return - - def _update_action_buttons(self): - for action, button in getattr(self, 'action_buttons', {}).items(): - button.setEnabled(self.can_trigger(action)) + detail = f'{row["proposition"]} · {row["status"]}' + if row['reference'] is not None: + detail = f'{detail} · reference {row["reference"]}' + if row['reason']: + detail = f'{detail} · {row["reason"]}' + dock.set_status(detail) + + def _foreground_color(self, row, column): + """Tint complete rows by lifecycle state, like cluster-group rows.""" + color = self._status_colors.get(row.get('status')) + return QColor(color) if color is not None else super()._foreground_color(row, column) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index f808536b..6ef8dee0 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -17,7 +17,17 @@ from phylib.utils import Bunch, connect, emit, unconnect from phy.gui.actions import Actions -from phy.gui.qt import QAbstractItemView, QHeaderView, Qt, _block, _wait, set_busy +from phy.gui.qt import ( + QAbstractItemView, + QApplication, + QHeaderView, + QLineEdit, + QPlainTextEdit, + Qt, + _block, + _wait, + set_busy, +) from phy.gui.widgets import Barrier, Table, _uniq from phy.utils.selection import SelectionIntent, SelectionMutation @@ -482,6 +492,11 @@ class ActionCreator: 'toggle_merge_mode': 'v', 'next_best': 'down', 'previous_best': 'up', + # Merge propositions. + 'next_merge_proposition': 'alt+down', + 'previous_merge_proposition': 'alt+up', + 'reject_merge_proposition': 'alt+backspace', + 'reset_merge_proposition': 'alt+shift+backspace', # Misc. 'undo': 'ctrl+z', 'redo': ('ctrl+shift+z', 'ctrl+y'), @@ -636,6 +651,12 @@ def _create_select_actions(self): self.add(w, 'next_best', icon='f0a9', submenu=submenu) self.add(w, 'previous_best', icon='f0a8', submenu=submenu) + proposition_submenu = 'Merge propositions' + self.add(w, 'next_merge_proposition', submenu=proposition_submenu) + self.add(w, 'previous_merge_proposition', submenu=proposition_submenu) + self.add(w, 'reject_merge_proposition', submenu=proposition_submenu) + self.add(w, 'reset_merge_proposition', submenu=proposition_submenu) + def _create_toolbar(self, gui): gui._toolbar.addAction(self.select_actions.get('reset_wizard')) gui._toolbar.addAction(self.select_actions.get('previous_best')) @@ -1275,39 +1296,92 @@ def _proposition_rows(self): return [] self.merge_propositions.project_live_ids(self.clustering.cluster_ids) catalog = self.merge_propositions.catalog + live_ids = set(catalog.live_unit_ids) + active_key = self._active_merge_proposition_key() rows = [] for entry in catalog.entries: proposition = entry.proposition key = entry.key or f'invalid:{entry.index}' - status = ( + catalog_status = ( catalog.status_for(key) if entry.key is not None else PropositionStatus.INVALID ) + status = 'active' if key == active_key else catalog_status.value unit_ids = proposition.unit_ids if proposition is not None else () rows.append( { 'key': key, 'unit_ids': unit_ids, - 'status': status.value, + 'status': status, 'reason': entry.invalid_reason or (catalog.reason_for(key) if entry.key is not None else None), 'new_unit_id': proposition.new_unit_id if proposition is not None else None, - 'can_review': status is PropositionStatus.PENDING, - 'can_reject': status is PropositionStatus.PENDING, - 'can_skip': status is PropositionStatus.PENDING, - 'can_reset': status + 'can_review': catalog_status is PropositionStatus.PENDING, + 'can_reject': catalog_status is PropositionStatus.PENDING, + 'can_skip': catalog_status is PropositionStatus.PENDING, + 'can_reset': catalog_status in { PropositionStatus.ACCEPTED, PropositionStatus.ACCEPTED_MODIFIED, PropositionStatus.REJECTED, } - and set(unit_ids) <= set(catalog.live_unit_ids), + and set(unit_ids) <= live_ids, } ) return rows + def _active_merge_proposition_key(self): + state = self.selection.state + if state.is_merge_mode and state.merge is not None: + return state.merge.proposition_id + return None + + def _update_proposition_actions(self): + """Keep proposition shortcuts aligned with the current queue state.""" + if self.select_actions is None: + return + names = { + 'next_merge_proposition', + 'previous_merge_proposition', + 'reject_merge_proposition', + 'reset_merge_proposition', + } + if not names <= set(self.select_actions._actions_dict): + return + view = self.merge_propositions_view + current = self._active_merge_proposition_key() or ( + view.current_key if view is not None else None + ) + actionable = view.actionable_keys() if view is not None else () + can_navigate = any(key != current for key in actionable) or ( + current is None and bool(actionable) + ) + can_reject = bool( + view is not None + and current is not None + and self._active_merge_proposition_key() == current + and view.can_trigger('reject') + ) + can_reset = bool( + view is not None + and not self.selection.state.is_merge_mode + and view.can_trigger('reset') + ) + enabled = { + 'next_merge_proposition': can_navigate, + 'previous_merge_proposition': can_navigate, + 'reject_merge_proposition': can_reject, + 'reset_merge_proposition': can_reset, + } + for name, value in enabled.items(): + (self.select_actions.enable if value else self.select_actions.disable)(name) + def _refresh_propositions(self): if self.merge_propositions_view is not None: self.merge_propositions_view.set_propositions(self._proposition_rows()) + active_key = self._active_merge_proposition_key() + if active_key is not None: + self.merge_propositions_view.select_key(active_key) + self._update_proposition_actions() def _apply_selection_change( self, change, callback=None, refresh_similarity=True, publish=True, sync_presentation=True @@ -1350,14 +1424,24 @@ def _set_merge_mode_ui(self, active): self.cluster_view.dock.set_status(f'clusters: {", ".join(map(str, ids))}') if self.actions is not None: can_redo_merge = False + can_undo_proposition = False if active: + can_undo_proposition = ( + self._active_merge_proposition_key() is not None + and self._global_history.current_position > 0 + ) index = self._global_history.current_position + 1 history = self._global_history._history can_redo_merge = index < len(history) and self._is_merge_history_context( history[index].workflow_context ) for name in self.actions._actions_dict: - enabled = not active or name == 'merge' or (name == 'redo' and can_redo_merge) + enabled = ( + not active + or name == 'merge' + or (name == 'undo' and can_undo_proposition) + or (name == 'redo' and can_redo_merge) + ) (self.actions.enable if enabled else self.actions.disable)(name) if self.select_actions is not None: allowed = { @@ -1368,6 +1452,10 @@ def _set_merge_mode_ui(self, active): 'next', 'previous', 'skip_noise_and_mua', + 'next_merge_proposition', + 'previous_merge_proposition', + 'reject_merge_proposition', + 'reset_merge_proposition', } for name in self.select_actions._actions_dict: ( @@ -1375,6 +1463,7 @@ def _set_merge_mode_ui(self, active): if not active or name in allowed else self.select_actions.disable )(name) + self._update_proposition_actions() def _close_merge_view(self): view = self.merge_view @@ -1521,7 +1610,7 @@ def _remove_merge_candidate_on_right_click(self, sender, cluster_id): def _on_action(self, sender, name, *args): """Called when an action is triggered: enqueue and process the task.""" assert sender == self.action_creator - if self.selection.state.is_merge_mode and name in { + blocked_in_merge = { 'split', 'label', 'move', @@ -1535,7 +1624,10 @@ def _on_action(self, sender, name, *args): 'next_best', 'previous_best', 'undo', - }: + } + if name == 'undo' and self._active_merge_proposition_key() is not None: + blocked_in_merge.remove('undo') + if self.selection.state.is_merge_mode and name in blocked_in_merge: logger.warning('Action `%s` is unavailable in Merge mode.', name) return # Ignore wizard navigation requests triggered while another selection task is still @@ -1762,6 +1854,11 @@ def on_default_actions_created(sender): gui, data=self._proposition_rows() ) gui.add_view(self.merge_propositions_view, position='left', closable=False) + connect( + self._activate_merge_proposition, + event='activate_merge_proposition', + sender=self.merge_propositions_view, + ) connect( self._review_merge_proposition, event='review_merge_proposition', @@ -1783,6 +1880,7 @@ def on_default_actions_created(sender): self.actions = self.action_creator.edit_actions # clustering actions self.select_actions = self.action_creator.select_actions self.view_actions = gui.view_actions + self._update_proposition_actions() emit('attach_gui', self) # Call supervisor.save() when the save/ctrl+s action is triggered in the GUI. @@ -1889,6 +1987,11 @@ def merge(self, cluster_ids=None, to=None): workflow_context = ( {'mode': 'merge', 'tables': self._workflow_context()} if merge_mode else None ) + proposition_order = ( + self.merge_propositions_view.visible_keys() + if proposition_id is not None and self.merge_propositions_view is not None + else () + ) # A merge synchronously emits several related table mutations: metadata # inheritance, addition of the merged cluster, and removal of its # ancestors. Fit each attached table once after the complete operation @@ -1909,13 +2012,24 @@ def merge(self, cluster_ids=None, to=None): self.merge_propositions.accept(proposition_id, tuple(cluster_ids), int(out.added[0])) controllers.append(self.merge_propositions) self._refresh_propositions() + if proposition_id is not None: + next_key = self._pending_proposition_relative_to( + proposition_id, 'next', proposition_order + ) + if next_key is not None: + self._activate_merge_proposition(self.merge_propositions_view, next_key) + else: + self.merge_propositions_view.select_key(proposition_id) self._global_history.action( *controllers, description='merge', selection_before=selection_before, selection_after=self.selection.snapshot(), workflow_context=workflow_context, + workflow_context_after=self._merge_workflow_history_context(), ) + if self.selection.state.is_merge_mode: + self._set_merge_mode_ui(True) return out def split(self, spike_ids=None, spike_clusters_rel=0): @@ -2059,16 +2173,54 @@ def toggle_merge_mode(self, callback=None): self._apply_selection_change(change, callback=callback) return change.after - def _review_merge_proposition(self, sender, key): - """Open one actionable proposition in the ordinary Merge workspace.""" + @staticmethod + def _proposition_shortcut_blocked_by_text_input(): + return isinstance(QApplication.focusWidget(), (QLineEdit, QPlainTextEdit)) + + def _merge_workflow_history_context(self): + if not self.selection.state.is_merge_mode: + return None + return {'mode': 'merge', 'tables': self._workflow_context()} + + def _pending_proposition_relative_to(self, key, direction, ordered_keys=None): + """Find another pending proposition in visible order, wrapping once.""" + view = self.merge_propositions_view + if view is None: + return None + ordered = tuple(ordered_keys or view.visible_keys()) + if not ordered: + return None + if key in ordered: + start = ordered.index(key) + else: + start = -1 if direction == 'next' else 0 + step = 1 if direction == 'next' else -1 + catalog = self.merge_propositions.catalog + for offset in range(1, len(ordered) + 1): + candidate = ordered[(start + step * offset) % len(ordered)] + if candidate == key or catalog.entry_for(candidate) is None: + continue + if catalog.status_for(candidate) is PropositionStatus.PENDING: + return candidate + return None + + def _activate_merge_proposition(self, sender, key): + """Replace any temporary Merge workspace with the clicked proposition.""" + active_key = self._active_merge_proposition_key() + if active_key == key: + self.merge_propositions_view.select_key(key) + return self.selection.state if self.selection.state.is_merge_mode: - logger.warning('Finish or cancel the active Merge workspace first.') - return + self._cancel_merge_mode() + self.merge_propositions_view.select_key(key) self.merge_propositions.project_live_ids(self.clustering.cluster_ids) catalog = self.merge_propositions.catalog - if catalog.status_for(key) is not PropositionStatus.PENDING: - logger.warning('Merge proposition %s is not actionable.', key) + if ( + catalog.entry_for(key) is None + or catalog.status_for(key) is not PropositionStatus.PENDING + ): self._refresh_propositions() + self.merge_propositions_view.select_key(key) return proposition = catalog.entry_for(key).proposition self.cluster_view.debouncer.flush() @@ -2079,39 +2231,124 @@ def _review_merge_proposition(self, sender, key): self._create_merge_view() self._set_merge_mode_ui(True) self._apply_selection_change(change) + self._refresh_propositions() return change.after - def _reject_merge_proposition(self, sender, key): - if self.selection.state.is_merge_mode: - logger.warning('Cancel the active Merge workspace before rejecting a proposition.') + def _review_merge_proposition(self, sender, key): + """Compatibility alias for explicit programmatic review requests.""" + return self._activate_merge_proposition(sender, key) + + def _navigate_merge_proposition(self, direction, callback=None, ordered_keys=None): + if self._proposition_shortcut_blocked_by_text_input(): + if callback: + callback(self.selection.state) return + current = self._active_merge_proposition_key() or ( + self.merge_propositions_view.current_key + if self.merge_propositions_view is not None + else None + ) + if self.selection.state.is_merge_mode: + self._cancel_merge_mode() + self.merge_propositions.project_live_ids(self.clustering.cluster_ids) + key = self._pending_proposition_relative_to(current, direction, ordered_keys) + if key is not None: + self._activate_merge_proposition(self.merge_propositions_view, key) + else: + self._refresh_propositions() + if current is not None: + self.merge_propositions_view.select_key(current) + if callback: + callback(self.selection.state) + return self.selection.state + + def next_merge_proposition(self, callback=None): + """Cancel the current workspace and review the next pending proposition.""" + return self._navigate_merge_proposition('next', callback=callback) + + def previous_merge_proposition(self, callback=None): + """Cancel the current workspace and review the previous pending proposition.""" + return self._navigate_merge_proposition('previous', callback=callback) + + def _reject_merge_proposition(self, sender, key): before = self.selection.snapshot() + workflow_before = self._merge_workflow_history_context() + ordered_keys = self.merge_propositions_view.visible_keys() + if self.selection.state.is_merge_mode: + if self._active_merge_proposition_key() != key: + logger.warning('Only the active merge proposition can be rejected.') + return + self._cancel_merge_mode() self.merge_propositions.project_live_ids(self.clustering.cluster_ids) self.merge_propositions.reject(key) + self._refresh_propositions() + next_key = self._pending_proposition_relative_to(key, 'next', ordered_keys) + if next_key is not None: + self._activate_merge_proposition(self.merge_propositions_view, next_key) self._global_history.action( self.merge_propositions, description=f'reject merge proposition {key}', selection_before=before, - selection_after=before, + selection_after=self.selection.snapshot(), + workflow_context=workflow_before, + workflow_context_after=self._merge_workflow_history_context(), ) - self._refresh_propositions() + if self.selection.state.is_merge_mode: + self._set_merge_mode_ui(True) self._update_save_feedback() + return self.selection.state + + def reject_merge_proposition(self, callback=None): + """Reject the active proposition and review the next pending one.""" + if self._proposition_shortcut_blocked_by_text_input(): + if callback: + callback(self.selection.state) + return + key = self._active_merge_proposition_key() + if key is not None: + self._reject_merge_proposition(self.merge_propositions_view, key) + if callback: + callback(self.selection.state) + return self.selection.state def _reset_merge_proposition(self, sender, key): if self.selection.state.is_merge_mode: - logger.warning('Cancel the active Merge workspace before resetting a review.') - return + self._cancel_merge_mode() before = self.selection.snapshot() self.merge_propositions.project_live_ids(self.clustering.cluster_ids) - self.merge_propositions.reset(key) + try: + self.merge_propositions.reset(key) + except ValueError as e: + logger.warning('%s', e) + self._refresh_propositions() + self.merge_propositions_view.select_key(key) + return + self._refresh_propositions() + self._activate_merge_proposition(self.merge_propositions_view, key) self._global_history.action( self.merge_propositions, description=f'reset merge proposition {key}', selection_before=before, - selection_after=before, + selection_after=self.selection.snapshot(), + workflow_context_after=self._merge_workflow_history_context(), ) - self._refresh_propositions() + self._set_merge_mode_ui(True) self._update_save_feedback() + return self.selection.state + + def reset_merge_proposition(self, callback=None): + """Reset the highlighted review and reopen it when actionable.""" + if self._proposition_shortcut_blocked_by_text_input(): + if callback: + callback(self.selection.state) + return + view = self.merge_propositions_view + key = view.current_key if view is not None else None + if key is not None and view.can_trigger('reset'): + self._reset_merge_proposition(view, key) + if callback: + callback(self.selection.state) + return self.selection.state def add_to_merge(self, cluster_ids, insertion=None, callback=None): """Transfer candidate IDs into the Merge workspace.""" @@ -2215,7 +2452,7 @@ def _update_save_feedback(self, saved=False): def undo(self): """Undo the last action.""" - if self.selection.state.is_merge_mode: + if self.selection.state.is_merge_mode and self._active_merge_proposition_key() is None: logger.warning('Undo is unavailable while a Merge workspace is active.') return # Selection-only exploration does not create history entries. Preserve the exact @@ -2223,6 +2460,7 @@ def undo(self): if self._global_history.current_position > 0: self._global_history.update_current_context( selection_after=self.selection.snapshot(), + workflow_context_after=self._merge_workflow_history_context(), ) self._global_history.undo() diff --git a/phy/cluster/tests/test_history.py b/phy/cluster/tests/test_history.py index 84067d2f..48a5e4a7 100644 --- a/phy/cluster/tests/test_history.py +++ b/phy/cluster/tests/test_history.py @@ -184,3 +184,33 @@ def restore(selection, workflow, direction): 'controller redo', ('redo', 'after', 'normal'), ] + + +def test_global_history_restores_direction_specific_workflow_contexts(): + calls = [] + + class Controller(History): + pass + + controller = Controller() + controller.add('action') + history = GlobalHistory( + restore_context=lambda selection, context, direction: calls.append( + (direction, selection, context) + ) + ) + history.action( + controller, + selection_before='current proposition', + selection_after='next proposition', + workflow_context='current tables', + workflow_context_after='next tables', + ) + + history.undo() + history.redo() + + assert calls == [ + ('undo', 'current proposition', 'current tables'), + ('redo', 'next proposition', 'next tables'), + ] diff --git a/phy/cluster/tests/test_proposition_view.py b/phy/cluster/tests/test_proposition_view.py new file mode 100644 index 00000000..46acce8e --- /dev/null +++ b/phy/cluster/tests/test_proposition_view.py @@ -0,0 +1,49 @@ +"""Tests for the compact merge-proposition queue.""" + +from phylib.utils import connect, unconnect + +from phy.gui.qt import Qt +from phy.gui.tests.test_widgets import _wait_until_table_ready + +from .._proposition_view import MergePropositionsView + + +def test_merge_propositions_compact_projection_and_activation(qtbot): + view = MergePropositionsView( + data=[ + { + 'key': 'merge:1', + 'unit_ids': (1, 2, 3, 4, 5, 6), + 'status': 'accepted_modified', + 'reason': 'reviewed with an extra unit', + 'new_unit_id': 42, + }, + {'key': 'merge:2', 'unit_ids': (7, 8), 'status': 'pending'}, + ] + ) + _wait_until_table_ready(qtbot, view) + + assert view.columns == ['proposition'] + assert not hasattr(view, 'action_buttons') + assert view._model.row_by_id(0)['proposition'] == '1, 2, …, 6 (6) ⇒ 42' + index = view._model.index(0, 0) + assert view._model.data(index, Qt.ToolTipRole) == ( + '1, 2, 3, 4, 5, 6 ⇒ 42\n' + 'Status: accepted_modified\n' + 'Reference: 1\n' + 'Key: merge:1\n' + 'reviewed with an extra unit' + ) + assert view._model.data(index, Qt.ForegroundRole).name() == '#e6ad4c' + + activated = [] + + @connect(event='activate_merge_proposition', sender=view) + def on_activate(sender, key): + activated.append(key) + + view._on_row_clicked(view._proxy_index_for_id(1)) + assert activated == ['merge:2'] + assert view.current_key == 'merge:2' + unconnect(on_activate) + view.close() diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index a669d658..7eef71f4 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -747,7 +747,11 @@ def _proposition_supervisor(gui, cluster_ids, cluster_groups, cluster_labels, si { 'format_version': '2', 'unit_ids': cluster_ids, - 'merges': [{'unit_ids': [30, 20]}, {'unit_ids': [20, 10]}], + 'merges': [ + {'unit_ids': [30, 20]}, + {'unit_ids': [20, 10]}, + {'unit_ids': [11, 1]}, + ], } ) supervisor = Supervisor( @@ -777,10 +781,14 @@ def test_merge_proposition_review_cancel_restores_exact_entry( key = supervisor.merge_propositions.catalog.propositions[0].key view = supervisor.merge_propositions_view - view.sort_by('key', 'desc') + assert view.columns == ['proposition'] + assert not hasattr(view, 'action_buttons') assert view.select_key(key) assert supervisor.selection.snapshot() is entry - assert view.trigger('review') + supervisor.toggle_merge_mode() + assert supervisor.selection.state.is_merge_mode + assert supervisor.selection.state.merge.proposition_id is None + view._on_row_clicked(view._proxy_index_for_id(view._id_by_key[key])) assert supervisor.selected_merge == [30, 20] assert supervisor.selection.state.reference_id == 30 @@ -788,23 +796,86 @@ def test_merge_proposition_review_cancel_restores_exact_entry( assert supervisor.selection.state.merge.proposition_id == key assert 'PROPOSITION merge:' in supervisor.merge_view.dock.status + replacement = supervisor.merge_propositions.catalog.propositions[2] + view._on_row_clicked(view._proxy_index_for_id(view._id_by_key[replacement.key])) + assert supervisor.selected_merge == [11, 1] + assert supervisor.selection.state.merge.proposition_id == replacement.key + supervisor.toggle_merge_mode() assert supervisor.selection.state == entry assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.PENDING +def test_merge_proposition_navigation_shortcuts_and_text_focus( + gui, qtbot, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir +): + supervisor = _proposition_supervisor( + gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir + ) + first, second, third = supervisor.merge_propositions.catalog.propositions + shortcuts = { + name: _get_shortcut_string(supervisor.select_actions.get(name).shortcut()) + for name in ( + 'next_merge_proposition', + 'previous_merge_proposition', + 'reject_merge_proposition', + 'reset_merge_proposition', + ) + } + assert shortcuts == { + 'next_merge_proposition': 'alt+down', + 'previous_merge_proposition': 'alt+up', + 'reject_merge_proposition': 'alt+backspace', + 'reset_merge_proposition': 'alt+shift+backspace', + } + + supervisor._activate_merge_proposition(supervisor.merge_propositions_view, first.key) + supervisor.next_merge_proposition() + assert supervisor.selection.state.merge.proposition_id == second.key + supervisor.previous_merge_proposition() + assert supervisor.selection.state.merge.proposition_id == first.key + + supervisor.merge_propositions_view.filter_edit.setFocus() + qtbot.wait(1) + supervisor.next_merge_proposition() + assert supervisor.selection.state.merge.proposition_id == first.key + assert third.key in supervisor.merge_propositions_view.actionable_keys() + + +def test_clicking_nonactionable_proposition_cancels_active_workspace( + gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir +): + supervisor = _proposition_supervisor( + gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir + ) + first, _, third = supervisor.merge_propositions.catalog.propositions + supervisor._activate_merge_proposition(supervisor.merge_propositions_view, first.key) + supervisor.reject_merge_proposition() + assert supervisor.selection.state.is_merge_mode + + view = supervisor.merge_propositions_view + view._on_row_clicked(view._proxy_index_for_id(view._id_by_key[first.key])) + + assert not supervisor.selection.state.is_merge_mode + assert view.current_key == first.key + assert ( + supervisor.merge_propositions.catalog.status_for(first.key) is PropositionStatus.REJECTED + ) + assert third.key in view.actionable_keys() + + def test_merge_proposition_accept_overlap_and_coupled_undo_redo( gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir ): supervisor = _proposition_supervisor( gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir ) - first, overlap = supervisor.merge_propositions.catalog.propositions + first, overlap, next_proposition = supervisor.merge_propositions.catalog.propositions assignments_before = supervisor.clustering.spike_clusters.copy() supervisor._review_merge_proposition(supervisor.merge_propositions_view, first.key) workspace = supervisor.selection.snapshot() - up = supervisor.merge() + supervisor.merge() supervisor.block() assert ( @@ -812,6 +883,10 @@ def test_merge_proposition_accept_overlap_and_coupled_undo_redo( ) assert supervisor.merge_propositions.catalog.status_for(overlap.key) is PropositionStatus.STALE assert supervisor.merge_propositions.catalog.reviews[first.key].applied_unit_ids == (30, 20) + assert supervisor.selection.state.merge.proposition_id == next_proposition.key + assert supervisor.selected_merge == [11, 1] + assert supervisor.merge_propositions_view.current_key == next_proposition.key + assert supervisor.actions.get('undo').isEnabled() assert supervisor.merge_propositions_view.select_key(overlap.key) assert not supervisor.merge_propositions_view.can_trigger('review') assignments_after = supervisor.clustering.spike_clusters.copy() @@ -832,7 +907,8 @@ def test_merge_proposition_accept_overlap_and_coupled_undo_redo( supervisor.merge_propositions.catalog.status_for(first.key) is PropositionStatus.ACCEPTED ) assert supervisor.merge_propositions.catalog.status_for(overlap.key) is PropositionStatus.STALE - assert supervisor.selected == list(up.added) + assert supervisor.selection.state.merge.proposition_id == next_proposition.key + assert supervisor.selected_merge == [11, 1] def test_failed_proposition_merge_and_reject_history( @@ -854,14 +930,23 @@ def fail(*args, **kwargs): assert supervisor.selection.state is workspace assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.PENDING - supervisor.toggle_merge_mode() - assert supervisor.merge_propositions_view.select_key(key) - assert supervisor.merge_propositions_view.trigger('reject') + supervisor.reject_merge_proposition() assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.REJECTED + next_key = supervisor.merge_propositions.catalog.propositions[1].key + assert supervisor.selection.state.merge.proposition_id == next_key + assert supervisor.actions.get('undo').isEnabled() supervisor.undo() assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.PENDING + assert supervisor.selection.state == workspace supervisor.redo() assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.REJECTED + assert supervisor.selection.state.merge.proposition_id == next_key + + supervisor._activate_merge_proposition(supervisor.merge_propositions_view, key) + assert not supervisor.selection.state.is_merge_mode + supervisor.reset_merge_proposition() + assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.PENDING + assert supervisor.selection.state.merge.proposition_id == key def test_supervisor_close_releases_owned_event_callbacks( diff --git a/phy/cluster/views/trace.py b/phy/cluster/views/trace.py index 86751586..70de38ab 100644 --- a/phy/cluster/views/trace.py +++ b/phy/cluster/views/trace.py @@ -132,8 +132,8 @@ class TraceView(ScalingMixin, BaseColorView, ManualClusteringView): 'change_trace_size': 'ctrl+wheel', 'switch_color_scheme': 'shift+wheel', 'navigate': 'alt+wheel', - 'decrease': 'alt+down', - 'increase': 'alt+up', + 'decrease': 'ctrl+alt+down', + 'increase': 'ctrl+alt+up', 'go_left': 'alt+left', 'go_right': 'alt+right', 'jump_left': 'shift+alt+left', diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index 2b6d630e..2951ddde 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -384,6 +384,8 @@ def data(self, index, role=Qt.DisplayRole): if role == Qt.DisplayRole and column == 'n_spikes' and isinstance(value, int): return f'{value:,}' return value + if role == Qt.ToolTipRole: + return row.get(f'_{column}_tooltip') or row.get('_tooltip') if role == Qt.BackgroundRole and column == 'id': color = self._table._selection_background(row.get('id')) if color is not None: From a2fa61f33531b5f6e7a179a992474c9e8380b3fc Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 11:44:08 +0200 Subject: [PATCH 088/110] docs: document merge proposition workflows --- design/merge-propositions.md | 64 +++++++++++++++++++++++------------- docs/api.md | 36 ++++++++++++++++++++ docs/changelog.md | 18 +++++++--- docs/clustering.md | 41 +++++++++++++++-------- docs/gui.md | 6 ++++ docs/quickstart.md | 19 +++++++---- docs/shortcuts.md | 8 +++-- docs/sorting_user_guide.md | 6 ++-- docs/visualization.md | 4 +-- 9 files changed, 147 insertions(+), 55 deletions(-) diff --git a/design/merge-propositions.md b/design/merge-propositions.md index 9095acdd..2c788b4d 100644 --- a/design/merge-propositions.md +++ b/design/merge-propositions.md @@ -98,24 +98,37 @@ decisions uses the existing save prompt. ## 4. User-visible workflow When a valid `curation.json` contains merges, phy creates a persistent **Merge -Propositions** view. It shows status, proposition key, ordered cluster IDs, -cluster count, reference, and `new_unit_id` when present. - -Available operations are: - -- **Review**: open the proposition in Merge mode; -- **Reject**: record a reversible rejection without changing clustering; -- **Skip / Next pending**: navigate without changing review state; and -- **Reset review**: return an accepted or rejected proposition to pending when - its source clusters still exist. - -Invalid and stale propositions remain visible with a reason but cannot be -reviewed. - -Review is explicit; ordinary row selection does not enter Merge mode. Starting a -review snapshots the complete current Normal workspace, then stages the ordered -proposition IDs directly. It must not first project those IDs into Cluster View, -because cancellation must restore the curator's pre-review state. +Propositions** view with no row action buttons. Each row displays its ordered +unit IDs compactly: all IDs for four or fewer units, or the first two, an +ellipsis, the last, and the total count for larger propositions. A supplied +`new_unit_id` is appended as `⇒ new_unit_id`. The row tooltip provides the key, +full ordered IDs, status, reference, and any invalid/stale reason; the dock status +summarizes the compact proposition, status, reference, and reason. Lifecycle +colors distinguish the active review, +accepted, accepted-modified, rejected, stale, and invalid states. + +Clicking a pending, reviewable row immediately starts its review. It cancels and +replaces any active manual or proposition Merge workspace, snapshots the complete +current Normal workspace, and stages the ordered proposition IDs directly. It +must not first project those IDs into Cluster View, because cancellation must +restore the curator's pre-review state. Clicking an accepted, accepted-modified, +rejected, stale, or invalid row first cancels any active workspace, then only +highlights that row; it does not enter Merge mode. Invalid and stale propositions +remain visible with their reason but cannot be reviewed. + +The **Select > Merge propositions** commands are also available without visible +row buttons: + +- `Alt+Down` and `Alt+Up` cancel the current workspace and review the next or + previous pending proposition in the current visible table order, wrapping at + either end; +- `Alt+Backspace` rejects the active proposition and advances to the next pending + proposition in that same visible order; and +- `Alt+Shift+Backspace` resets the highlighted completed review (accepted, + accepted-modified, or rejected) to pending and immediately reopens it when its + source clusters still exist. + +These proposition shortcuts are suppressed while a text input has focus. While reviewing: @@ -130,11 +143,14 @@ While reviewing: On `G`, phy calls the ordinary merge implementation. Only after that call succeeds does it record the proposition as accepted, including the actual ordered merge IDs and result cluster ID. A changed workspace produces the -derived `accepted_modified` status. Failure leaves the workspace and review -state unchanged. +derived `accepted_modified` status. Successful proposition commits then open the +next pending proposition using the visible table order captured immediately +before the merge. A manual merge and a failed merge do not advance proposition +review; failure leaves the workspace and review state unchanged. -Reject creates a review-history entry and is undoable. Skip does not create -history. Reset review is explicit and undoable. +Reject creates a review-history entry and advances as described above. Reset +review is explicit and undoable. Undo and redo restore the exact before/after +proposition workspaces, including any automatically opened next proposition. ## 5. Overlap, stale IDs, and clustering changes @@ -154,7 +170,9 @@ Undoing an accepted proposition restores: - the Normal-entry snapshot used by cancellation; and - derived validity of overlapping propositions. -Redo reapplies the merge, restores the accepted decision, and exits Merge mode. +Redo reapplies the merge and accepted decision, then restores the exact +post-commit context: the automatically opened next proposition, or Normal mode +when no pending proposition remains. ## 6. Architecture boundaries diff --git a/docs/api.md b/docs/api.md index 10504dda..84a57d5b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10215,6 +10215,15 @@ Select the next best cluster in the cluster view. --- +#### Supervisor.next_merge_proposition + + +**`Supervisor.next_merge_proposition(self, callback=None)`** + +Cancel the current workspace and review the next pending proposition. + +--- + #### Supervisor.previous @@ -10233,6 +10242,15 @@ Select the previous best cluster in the cluster view. --- +#### Supervisor.previous_merge_proposition + + +**`Supervisor.previous_merge_proposition(self, callback=None)`** + +Cancel the current workspace and review the previous pending proposition. + +--- + #### Supervisor.redo @@ -10242,6 +10260,15 @@ Undo the last undone action. --- +#### Supervisor.reject_merge_proposition + + +**`Supervisor.reject_merge_proposition(self, callback=None)`** + +Reject the active proposition and review the next pending one. + +--- + #### Supervisor.remove_from_merge @@ -10260,6 +10287,15 @@ Reorder staged candidates and their scientific presentation order. --- +#### Supervisor.reset_merge_proposition + + +**`Supervisor.reset_merge_proposition(self, callback=None)`** + +Reset the highlighted review and reopen it when actionable. + +--- + #### Supervisor.reset_wizard diff --git a/docs/changelog.md b/docs/changelog.md index 1f40ac06..1c125993 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -37,11 +37,15 @@ behavior they verify rather than listed separately. undo restores the full pre-merge workspace. - Review AIND/SpikeInterface format-version 2 merge propositions from dataset-local `curation.json` in a persistent **Merge Propositions** view. - Review stages the ordered proposition in Merge View (with the first unit blue); - `G` accepts the ordinary merge, while edited acceptance is marked - `accepted_modified`. Reject and reset are undoable, stale overlapping proposals - are never remapped, and decisions are atomically saved in - `curation_review.json` without overwriting `curation.json`. + Its compact, lifecycle-colored rows have no action buttons: click a pending + row to stage it in Merge View (with the first unit blue), while tooltips retain + the full IDs, status, key, and reason. `Alt+Down`/`Alt+Up` navigate pending + rows, `Alt+Backspace` rejects and advances, and `Alt+Shift+Backspace` resets a + highlighted completed review. `G` accepts the ordinary merge, marks edited + acceptance `accepted_modified`, and opens the next pending proposition in the + pre-merge visible order. Undo/redo restore exact proposition workspaces; stale + overlapping proposals are never remapped, and decisions are atomically saved + in `curation_review.json` without overwriting `curation.json`. - Select the first eligible clusters in the Similarity View with `Control+Space`; repeat the shortcut to select successive batches. The @@ -104,6 +108,10 @@ behavior they verify rather than listed separately. - Put content-specific actions first in every view menu, followed by a consistent Auto-update, Screenshot, and Close utility footer. +- Move Trace View scale shortcuts from `Alt+Up`/`Alt+Down` to + `Control+Alt+Up`/`Control+Alt+Down`, leaving `Alt+Up`/`Alt+Down` available for + Merge Propositions navigation. + - Group cluster traversal commands under **Select > Navigation**. - Group available views under **View > Add view** and keep global view options diff --git a/docs/clustering.md b/docs/clustering.md index 3c142f1b..b1d40f12 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -40,19 +40,34 @@ before `G`; Redo reapplies the merge and returns to the normal workflow. For Template GUI datasets, phy can also review automatic merge propositions from a dataset-local AIND/SpikeInterface format-version 2 `curation.json`. When the file -contains valid `merges`, the persistent **Merge Propositions** view lists their -ordered cluster IDs, status, and any supplied `new_unit_id`. The first ID is the -blue reference; `new_unit_id` is provenance only because phy allocates the result -through its ordinary merge model. - -Select a proposition and choose **Review** to stage it in the normal Merge View. -You can still add, remove, and reorder candidates there. Press `G` to accept only -after the ordinary merge succeeds; an edited set of candidates is marked -`accepted_modified`. Press `V`, use Cancel, or close Merge View to leave the -proposition pending. **Reject** and **Reset review** are undoable; **Skip / Next -pending** only navigates. Overlapping propositions are allowed, but one whose -source cluster no longer exists after a merge or split becomes stale and is never -automatically remapped. +contains valid `merges`, the persistent **Merge Propositions** view shows a +compact ordered unit list: all IDs for four or fewer units, or the first two, +an ellipsis, the last, and the total count for larger propositions. A supplied +`new_unit_id` follows `⇒`; it is provenance only because phy allocates the result +through its ordinary merge model. There are no row action buttons. Hover a row +for its full IDs, key, status, reference, and any reason. Row colors identify +the active, accepted, accepted-modified, rejected, stale, and invalid lifecycle +states. + +Click a pending row to stage it in Merge View immediately; this cancels and +replaces any manual or proposition workspace already open. Clicking a completed, +stale, or invalid row cancels any active workspace and only highlights that row. +You can still add, remove, and reorder candidates in a pending review. `Alt+Down` +and `Alt+Up` cancel the current workspace and open the next or previous pending +proposition in the current visible table order, wrapping at either end. +`Alt+Backspace` rejects the active proposition and advances; `Alt+Shift+Backspace` +resets the highlighted completed review and reopens it when reviewable. +These shortcuts do nothing while a text input has focus. + +Press `G` to accept only after the ordinary merge succeeds; an edited set of +candidates is marked `accepted_modified`. A successful proposition merge opens +the next pending proposition using the visible order captured before the merge. +Manual and failed merges do not advance proposition review. Press `V`, use +Cancel, or close Merge View to leave the current proposition pending. Reject and +reset are undoable, and undo/redo restore the exact before/after proposition +workspaces. Overlapping propositions are allowed, but one whose source cluster +no longer exists after a merge or split becomes stale and is never automatically +remapped. phy never overwrites producer-owned `curation.json`. It stores accepted and rejected decisions in dataset-local `curation_review.json`, atomically after the diff --git a/docs/gui.md b/docs/gui.md index cdd0559a..139264ba 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -64,6 +64,12 @@ rows between Merge and Similarity views, or drag inside Merge View to reorder ca to commit or `V` to cancel. See [Staging candidates in Merge View](clustering.md#staging-candidates-in-merge-view). +When a Template GUI dataset provides `curation.json` merge suggestions, the +persistent **Merge Propositions** table is a button-free review queue. Clicking +a pending row immediately opens it in Merge View and replaces any active merge +workspace; clicking a nonactionable row only highlights it after cancelling the +workspace. See [Reviewing merge propositions](clustering.md#reviewing-merge-propositions). + ## Sorting and filtering Click a Cluster View column header to sort the table. Enter a boolean expression in the filter box diff --git a/docs/quickstart.md b/docs/quickstart.md index b9b990ec..e19d3386 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -121,13 +121,18 @@ Press `V` again or close Merge View to cancel without changing the clustering. If the Template GUI dataset includes an AIND/SpikeInterface format-version 2 `curation.json` with merge suggestions, use the persistent **Merge Propositions** -view to review them. Choose **Review** to stage its ordered IDs in Merge View (the -first is blue), then use `G` to accept the ordinary merge; changes you make to the -proposal are recorded as `accepted_modified`. **Reject** and **Reset review** are -undoable, while **Skip** leaves it pending. A proposition whose source clusters -were changed becomes stale rather than being remapped. phy leaves `curation.json` -unchanged and atomically saves decisions to `curation_review.json` with the rest -of the curation results. +view to review them. Its button-free rows compactly show the proposed units and +any `⇒ new_unit_id`; hover for full details and lifecycle status. Click a pending +row to stage its ordered IDs in Merge View (the first is blue), replacing any +active merge workspace. `Alt+Down`/`Alt+Up` move through pending rows in the +current visible order and wrap; `Alt+Backspace` rejects and advances, while +`Alt+Shift+Backspace` resets the highlighted completed review and reopens it. +These shortcuts are suppressed while typing in a text input. Use `G` to accept; +an edited merge is recorded as `accepted_modified`, then the next pending row in +the pre-merge visible order opens automatically. Manual or failed merges do not +advance. A proposition whose source clusters were changed becomes stale rather +than being remapped. phy leaves `curation.json` unchanged and atomically saves +decisions to `curation_review.json` with the rest of the curation results. Splitting requires selecting spikes in a view that supports lasso or polygon selection, commonly the Feature View, and pressing `K`. It is worth learning diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 4c5ca762..3554bd77 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -32,10 +32,14 @@ Keyboard shortcuts - move_similar_to_unsorted ctrl+u - next space - next_best down +- next_merge_proposition alt+down - previous shift+space - previous_best up +- previous_merge_proposition alt+up - redo ctrl+shift+z, ctrl+y +- reject_merge_proposition alt+backspace - reset ctrl+alt+space +- reset_merge_proposition alt+shift+backspace - select_first_similar ctrl+space - split k - toggle_merge_mode v @@ -211,7 +215,7 @@ TraceView Keyboard shortcuts - change_trace_size ctrl+wheel -- decrease alt+down +- decrease ctrl+alt+down - go_left alt+left - go_right alt+right - go_to alt+t @@ -219,7 +223,7 @@ Keyboard shortcuts - go_to_next_spike alt+pgdown - go_to_previous_spike alt+pgup - go_to_start alt+home -- increase alt+up +- increase ctrl+alt+up - jump_left shift+alt+left - jump_right shift+alt+right - narrow alt++ diff --git a/docs/sorting_user_guide.md b/docs/sorting_user_guide.md index c73daee3..febc2b54 100644 --- a/docs/sorting_user_guide.md +++ b/docs/sorting_user_guide.md @@ -135,7 +135,7 @@ The raw traces are plotted in [TraceView](#TraceView) automatically when the app ![single cluster](https://raw.githubusercontent.com/kwikteam/phy-contrib/master/docs/screenshots/single_cluster.png) -The scaling of the traces can be adjusted using the drop down toolbar for TraceView, using the mouse (`right click + drag`) or [keyboard shortcuts](#keyboard-shortcuts). TraceView shows only a subset of the total trace at any given time. The length of visible trace can be adjusted by using the command 'Widen Trace' (`Ctrl + Alt + right`) in the drop down menu. +The scaling of the traces can be adjusted using the drop down toolbar for TraceView, using the mouse (`right click + drag`) or [keyboard shortcuts](#keyboard-shortcuts). TraceView shows only a subset of the total trace at any given time. The length of visible trace can be adjusted by using the command 'Widen Trace' (`Alt + -`) in the drop down menu. The displayed interval is visible at the bottom of the GUI if you hover the mouse over TraceView. @@ -233,8 +233,8 @@ Classify selected similar cluster(s) as 'noise': `Ctrl + N` Drag: `Left mouse button` Zoom: `Right mouse button and drag` -Increase scaling: `Alt + up` -Decrease scaling: `Alt + down` +Increase TraceView scaling: `Ctrl + Alt + up` +Decrease TraceView scaling: `Ctrl + Alt + down` ### Misc diff --git a/docs/visualization.md b/docs/visualization.md index 7af56353..1a5ed260 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -376,7 +376,7 @@ Keyboard shortcuts for TraceView Keyboard shortcuts - change_trace_size ctrl+wheel -- decrease alt+down +- decrease ctrl+alt+down - go_left alt+left - go_right alt+right - go_to alt+t @@ -384,7 +384,7 @@ Keyboard shortcuts - go_to_next_spike alt+pgdown - go_to_previous_spike alt+pgup - go_to_start alt+home -- increase alt+up +- increase ctrl+alt+up - jump_left shift+alt+left - jump_right shift+alt+right - narrow alt++ From 9ea1e68b5bc58abd63a38ec216bab47a6ce973f0 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 11:55:51 +0200 Subject: [PATCH 089/110] feat: label merge propositions by source order --- phy/cluster/_proposition_view.py | 10 +++++++--- phy/cluster/supervisor.py | 1 + phy/cluster/tests/test_proposition_view.py | 5 +++-- phy/cluster/tests/test_supervisor.py | 1 + 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/phy/cluster/_proposition_view.py b/phy/cluster/_proposition_view.py index 9f913484..ff58ada2 100644 --- a/phy/cluster/_proposition_view.py +++ b/phy/cluster/_proposition_view.py @@ -76,7 +76,7 @@ def _as_ordered_ids(value): return tuple(value) @staticmethod - def _format_proposition(unit_ids, new_unit_id=None): + def _format_proposition(unit_ids, new_unit_id=None, display_id=None): """Return the compact, scan-friendly proposition label.""" labels = tuple(map(str, unit_ids)) if len(labels) <= 4: @@ -85,6 +85,8 @@ def _format_proposition(unit_ids, new_unit_id=None): text = f'{labels[0]}, {labels[1]}, …, {labels[-1]} ({len(labels)})' if new_unit_id is not None and new_unit_id != '': text = f'{text} ⇒ {new_unit_id}' + if display_id: + text = f'{display_id} · {text}' return text def _normalize_row(self, row, index): @@ -94,6 +96,7 @@ def _normalize_row(self, row, index): if not isinstance(key, str) or not key: raise ValueError('Every merge proposition row requires a non-empty string key.') unit_ids = self._as_ordered_ids(row.get('unit_ids')) + display_id = str(row.get('display_id') or f'P{index + 1}') status = str(row.get('status', 'pending')) new_unit_id = row.get('new_unit_id') reference = row.get('reference', unit_ids[0] if unit_ids else None) @@ -101,7 +104,7 @@ def _normalize_row(self, row, index): full_proposition = ', '.join(map(str, unit_ids)) if new_unit_id is not None and new_unit_id != '': full_proposition = f'{full_proposition} ⇒ {new_unit_id}' - tooltip = f'{full_proposition}\nStatus: {status}' + tooltip = f'{display_id} · {full_proposition}\nStatus: {status}' if reference is not None: tooltip = f'{tooltip}\nReference: {reference}' tooltip = f'{tooltip}\nKey: {key}' @@ -109,10 +112,11 @@ def _normalize_row(self, row, index): tooltip = f'{tooltip}\n{row["reason"]}' return { 'id': index, - 'proposition': self._format_proposition(unit_ids, new_unit_id), + 'proposition': self._format_proposition(unit_ids, new_unit_id, display_id), # Retain full metadata in the model for filtering, status text, and # stable-key recovery, but do not expose it as a table column. 'key': key, + 'display_id': display_id, 'unit_ids': unit_ids, 'status': status, 'reference': reference, diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 6ef8dee0..648f61b4 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1310,6 +1310,7 @@ def _proposition_rows(self): rows.append( { 'key': key, + 'display_id': f'P{entry.index + 1}', 'unit_ids': unit_ids, 'status': status, 'reason': entry.invalid_reason diff --git a/phy/cluster/tests/test_proposition_view.py b/phy/cluster/tests/test_proposition_view.py index 46acce8e..953857c3 100644 --- a/phy/cluster/tests/test_proposition_view.py +++ b/phy/cluster/tests/test_proposition_view.py @@ -13,6 +13,7 @@ def test_merge_propositions_compact_projection_and_activation(qtbot): data=[ { 'key': 'merge:1', + 'display_id': 'P12', 'unit_ids': (1, 2, 3, 4, 5, 6), 'status': 'accepted_modified', 'reason': 'reviewed with an extra unit', @@ -25,10 +26,10 @@ def test_merge_propositions_compact_projection_and_activation(qtbot): assert view.columns == ['proposition'] assert not hasattr(view, 'action_buttons') - assert view._model.row_by_id(0)['proposition'] == '1, 2, …, 6 (6) ⇒ 42' + assert view._model.row_by_id(0)['proposition'] == 'P12 · 1, 2, …, 6 (6) ⇒ 42' index = view._model.index(0, 0) assert view._model.data(index, Qt.ToolTipRole) == ( - '1, 2, 3, 4, 5, 6 ⇒ 42\n' + 'P12 · 1, 2, 3, 4, 5, 6 ⇒ 42\n' 'Status: accepted_modified\n' 'Reference: 1\n' 'Key: merge:1\n' diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 7eef71f4..ea6db26b 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -783,6 +783,7 @@ def test_merge_proposition_review_cancel_restores_exact_entry( assert view.columns == ['proposition'] assert not hasattr(view, 'action_buttons') + assert [view._model.row_by_id(i)['display_id'] for i in range(3)] == ['P1', 'P2', 'P3'] assert view.select_key(key) assert supervisor.selection.snapshot() is entry supervisor.toggle_merge_mode() From ba54b91ce85ff73fea6086d7928aa508d079d125 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 11:55:56 +0200 Subject: [PATCH 090/110] docs: define merge proposition display labels --- design/merge-propositions.md | 13 +++++++++++-- docs/changelog.md | 5 +++-- docs/clustering.md | 10 ++++++++-- docs/quickstart.md | 6 ++++-- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/design/merge-propositions.md b/design/merge-propositions.md index 2c788b4d..bcb509eb 100644 --- a/design/merge-propositions.md +++ b/design/merge-propositions.md @@ -51,6 +51,14 @@ For each `merges` entry: - an internal stable key is derived from the ordered `unit_ids`. Exact duplicate entries are invalid rather than silently coalesced. +Every source list entry, including an invalid one, also receives a concise +one-based display label (`P1`, `P2`, and so on) from its original position in +`curation.json`. Sorting, filtering, and review-state changes do not renumber +these labels. They are navigation aids rather than durable identity; the stable +key remains authoritative for persistence. The `P` namespace reserves +hierarchical labels such as `P12.1` for future persisted propositions derived +from source `P12`; phy 2.2 does not synthesize such rows. + Unsupported format versions or invalid top-level JSON disable the proposition workflow with a clear warning but never prevent ordinary curation. An invalid individual merge entry remains visible with its reason when the rest of the @@ -98,8 +106,9 @@ decisions uses the existing save prompt. ## 4. User-visible workflow When a valid `curation.json` contains merges, phy creates a persistent **Merge -Propositions** view with no row action buttons. Each row displays its ordered -unit IDs compactly: all IDs for four or fewer units, or the first two, an +Propositions** view with no row action buttons. Each row begins with its `P` +display label and shows its ordered unit IDs compactly: all IDs for four or fewer +units, or the first two, an ellipsis, the last, and the total count for larger propositions. A supplied `new_unit_id` is appended as `⇒ new_unit_id`. The row tooltip provides the key, full ordered IDs, status, reference, and any invalid/stale reason; the dock status diff --git a/docs/changelog.md b/docs/changelog.md index 1c125993..648cad45 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -37,8 +37,9 @@ behavior they verify rather than listed separately. undo restores the full pre-merge workspace. - Review AIND/SpikeInterface format-version 2 merge propositions from dataset-local `curation.json` in a persistent **Merge Propositions** view. - Its compact, lifecycle-colored rows have no action buttons: click a pending - row to stage it in Merge View (with the first unit blue), while tooltips retain + Its compact, lifecycle-colored rows have no action buttons and carry stable + source-order display labels (`P1`, `P2`, ...): click a pending row to stage it + in Merge View (with the first unit blue), while tooltips retain the full IDs, status, key, and reason. `Alt+Down`/`Alt+Up` navigate pending rows, `Alt+Backspace` rejects and advances, and `Alt+Shift+Backspace` resets a highlighted completed review. `G` accepts the ordinary merge, marks edited diff --git a/docs/clustering.md b/docs/clustering.md index b1d40f12..0e1e6507 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -40,8 +40,9 @@ before `G`; Redo reapplies the merge and returns to the normal workflow. For Template GUI datasets, phy can also review automatic merge propositions from a dataset-local AIND/SpikeInterface format-version 2 `curation.json`. When the file -contains valid `merges`, the persistent **Merge Propositions** view shows a -compact ordered unit list: all IDs for four or fewer units, or the first two, +contains valid `merges`, the persistent **Merge Propositions** view labels source +entries `P1`, `P2`, and so on in their original file order, then shows a compact +ordered unit list: all IDs for four or fewer units, or the first two, an ellipsis, the last, and the total count for larger propositions. A supplied `new_unit_id` follows `⇒`; it is provenance only because phy allocates the result through its ordinary merge model. There are no row action buttons. Hover a row @@ -49,6 +50,11 @@ for its full IDs, key, status, reference, and any reason. Row colors identify the active, accepted, accepted-modified, rejected, stale, and invalid lifecycle states. +The `P` label is intended for navigation and discussion; filtering and status +changes do not renumber it. Review persistence continues to use the internal +stable key. Labels such as `P12.1` are reserved for future persisted propositions +derived from `P12`; this release does not generate derived proposition rows. + Click a pending row to stage it in Merge View immediately; this cancels and replaces any manual or proposition workspace already open. Clicking a completed, stale, or invalid row cancels any active workspace and only highlights that row. diff --git a/docs/quickstart.md b/docs/quickstart.md index e19d3386..6e7bd01a 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -121,8 +121,10 @@ Press `V` again or close Merge View to cancel without changing the clustering. If the Template GUI dataset includes an AIND/SpikeInterface format-version 2 `curation.json` with merge suggestions, use the persistent **Merge Propositions** -view to review them. Its button-free rows compactly show the proposed units and -any `⇒ new_unit_id`; hover for full details and lifecycle status. Click a pending +view to review them. Its button-free rows have source-order labels (`P1`, `P2`, +...), compactly show the proposed units and any `⇒ new_unit_id`, and retain their +labels while filtered or reviewed. Hover for full details and lifecycle status. +Click a pending row to stage its ordered IDs in Merge View (the first is blue), replacing any active merge workspace. `Alt+Down`/`Alt+Up` move through pending rows in the current visible order and wrap; `Alt+Backspace` rejects and advances, while From 84d369654fda99f7cff90cbf93bdda0369e645d5 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 12:24:15 +0200 Subject: [PATCH 091/110] fix: simplify active proposition styling --- phy/cluster/_proposition_view.py | 3 +-- phy/cluster/tests/test_proposition_view.py | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/phy/cluster/_proposition_view.py b/phy/cluster/_proposition_view.py index ff58ada2..91e8889e 100644 --- a/phy/cluster/_proposition_view.py +++ b/phy/cluster/_proposition_view.py @@ -27,7 +27,6 @@ class MergePropositionsView(Table): _columns = ('proposition',) _status_colors = { - 'active': '#5ca8ff', 'accepted': '#86d16d', 'accepted_modified': '#e6ad4c', 'rejected': '#888888', @@ -244,6 +243,6 @@ def _set_dock_status(self, key): dock.set_status(detail) def _foreground_color(self, row, column): - """Tint complete rows by lifecycle state, like cluster-group rows.""" + """Tint reviewed/problem rows while selection background marks the active row.""" color = self._status_colors.get(row.get('status')) return QColor(color) if color is not None else super()._foreground_color(row, column) diff --git a/phy/cluster/tests/test_proposition_view.py b/phy/cluster/tests/test_proposition_view.py index 953857c3..eb874868 100644 --- a/phy/cluster/tests/test_proposition_view.py +++ b/phy/cluster/tests/test_proposition_view.py @@ -36,6 +36,7 @@ def test_merge_propositions_compact_projection_and_activation(qtbot): 'reviewed with an extra unit' ) assert view._model.data(index, Qt.ForegroundRole).name() == '#e6ad4c' + assert view._foreground_color({'status': 'active'}, 'proposition') is None activated = [] From f0de3f771661067aac0c24ef8604ece50467d45a Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 12:24:15 +0200 Subject: [PATCH 092/110] docs: clarify active proposition styling --- design/merge-propositions.md | 11 ++++++----- docs/changelog.md | 12 +++++++----- docs/clustering.md | 5 +++-- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/design/merge-propositions.md b/design/merge-propositions.md index bcb509eb..6e91a0b5 100644 --- a/design/merge-propositions.md +++ b/design/merge-propositions.md @@ -108,13 +108,14 @@ decisions uses the existing save prompt. When a valid `curation.json` contains merges, phy creates a persistent **Merge Propositions** view with no row action buttons. Each row begins with its `P` display label and shows its ordered unit IDs compactly: all IDs for four or fewer -units, or the first two, an -ellipsis, the last, and the total count for larger propositions. A supplied +units, or the first two, an ellipsis, the last, and the total count for larger +propositions. A supplied `new_unit_id` is appended as `⇒ new_unit_id`. The row tooltip provides the key, full ordered IDs, status, reference, and any invalid/stale reason; the dock status -summarizes the compact proposition, status, reference, and reason. Lifecycle -colors distinguish the active review, -accepted, accepted-modified, rejected, stale, and invalid states. +summarizes the compact proposition, status, reference, and reason. The selected-row +background identifies the active review without recoloring its text. Foreground +colors distinguish accepted, accepted-modified, rejected, stale, and invalid +states; blue remains reserved for the merge reference cluster. Clicking a pending, reviewable row immediately starts its review. It cancels and replaces any active manual or proposition Merge workspace, snapshots the complete diff --git a/docs/changelog.md b/docs/changelog.md index 648cad45..a78016a2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -37,11 +37,13 @@ behavior they verify rather than listed separately. undo restores the full pre-merge workspace. - Review AIND/SpikeInterface format-version 2 merge propositions from dataset-local `curation.json` in a persistent **Merge Propositions** view. - Its compact, lifecycle-colored rows have no action buttons and carry stable - source-order display labels (`P1`, `P2`, ...): click a pending row to stage it - in Merge View (with the first unit blue), while tooltips retain - the full IDs, status, key, and reason. `Alt+Down`/`Alt+Up` navigate pending - rows, `Alt+Backspace` rejects and advances, and `Alt+Shift+Backspace` resets a + Its compact rows have no action buttons and carry stable source-order display + labels (`P1`, `P2`, ...). The selection background marks the active review; + foreground colors show completed/problem states while blue stays reserved for + the merge reference cluster. Click a pending row to stage it in Merge View, + while tooltips retain the full IDs, status, key, and reason. + `Alt+Down`/`Alt+Up` navigate pending rows, `Alt+Backspace` rejects and advances, + and `Alt+Shift+Backspace` resets a highlighted completed review. `G` accepts the ordinary merge, marks edited acceptance `accepted_modified`, and opens the next pending proposition in the pre-merge visible order. Undo/redo restore exact proposition workspaces; stale diff --git a/docs/clustering.md b/docs/clustering.md index 0e1e6507..c621aee5 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -47,8 +47,9 @@ an ellipsis, the last, and the total count for larger propositions. A supplied `new_unit_id` follows `⇒`; it is provenance only because phy allocates the result through its ordinary merge model. There are no row action buttons. Hover a row for its full IDs, key, status, reference, and any reason. Row colors identify -the active, accepted, accepted-modified, rejected, stale, and invalid lifecycle -states. +accepted, accepted-modified, rejected, stale, and invalid lifecycle states. The +selected-row background alone identifies the active review, leaving blue text +reserved for the merge reference cluster. The `P` label is intended for navigation and discussion; filtering and status changes do not renumber it. Review persistence continues to use the internal From 27363e742ef3dec5a351cb941d575e08edc76dbf Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 14:09:18 +0200 Subject: [PATCH 093/110] docs: plan stable merge dock lifecycle --- design/README.md | 2 + design/merge-view-dock-stability.md | 227 ++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 design/merge-view-dock-stability.md diff --git a/design/README.md b/design/README.md index b00334a1..a9cbf3fb 100644 --- a/design/README.md +++ b/design/README.md @@ -13,6 +13,8 @@ Read these documents in order: internal refactor supporting that behavior. 3. [Merge Propositions specification](merge-propositions.md) defines review of AIND/SpikeInterface format-version 2 `curation.json` merge propositions. +4. [Merge View dock stability plan](merge-view-dock-stability.md) proposes a + persistent dock and atomic workspace switching to avoid layout disruption. The workflow specification is the authority for user behavior. The architecture record may evolve as implementation reveals constraints, but changes must not diff --git a/design/merge-view-dock-stability.md b/design/merge-view-dock-stability.md new file mode 100644 index 00000000..36de111c --- /dev/null +++ b/design/merge-view-dock-stability.md @@ -0,0 +1,227 @@ +# Merge View dock and workspace stability plan + +Status: proposed for the unreleased phy 2.2 branch + +Companion documents: + +- [Merge View workflow specification](merge-view-workflow.md) +- [Merge View architecture record](merge-view-architecture.md) +- [Merge Propositions specification](merge-propositions.md) + +## 1. Goal + +Changing merge propositions must feel like updating an existing workspace, not +closing and reopening a tool. Entering, cancelling, hiding, reopening, undoing, +or redoing Merge mode must retain the curator's dock placement and sizing choices +without disturbing unrelated widgets. + +This plan changes presentation lifecycle only. The existing selection, +proposition, merge, history, save, and cancellation integrity contracts remain +authoritative. + +## 2. Current source of disruption + +The current proposition transition takes a destructive GUI path: + +1. activating another proposition calls `_cancel_merge_mode()`; +2. cancellation calls `_close_merge_view()`; +3. the Merge View dock has `Qt.WA_DeleteOnClose`, so the view and dock are + discarded; +4. the next proposition constructs a new Merge View and dock; and +5. `_create_merge_view()` calls `QMainWindow.restoreState()` using a previously + captured whole-window state. + +This path is correct for curation state but unnecessarily reconstructs the Qt +dock layout. Whole-window restoration can also overwrite unrelated dock changes +made by the curator. Closing Merge View through its own close button follows a +different path and may not capture the same dock state before removal. + +The resulting risks are: + +- visible flicker while moving between propositions; +- changes to neighboring dock sizes and splitter proportions; +- loss of a floating dock's size or position; +- loss of docking area or tab relationships; +- repeated event connection and Qt resource lifecycle work; and +- different behavior for `V`, proposition navigation, automatic advancement, + undo/redo, and the dock close button. + +## 3. Target lifecycle + +Merge View and its `QDockWidget` become persistent, dataset-scoped GUI objects. +They are created lazily on the first Merge-mode entry and destroyed only when +the Supervisor or GUI closes. + +The lifecycle is: + +```text +first Merge entry + -> create view and dock once + -> show and populate + +manual Merge <-> proposition P1 <-> proposition P2 + -> reuse the same view and dock + -> replace workspace contents atomically + +cancel or dock close + -> restore the Normal workspace + -> hide the existing dock + +later Merge entry + -> show the same dock + -> restore its prior local extent + -> populate the new workspace + +GUI shutdown + -> disconnect callbacks + -> close and release the persistent dock and view +``` + +The dock keeps one stable Qt object name throughout the dataset session. This +allows Qt to retain its docking area, floating state, tab group, and other local +layout metadata naturally. + +## 4. Atomic workspace replacement + +Moving from one proposition to another must not project an intermediate Normal +workspace into the GUI. The selection controller should expose an operation such +as `switch_merge_proposition(key, unit_ids)` that: + +- requires an active Merge session; +- uses the active session's original Normal-entry snapshot; +- replaces proposition provenance, ordered staged IDs, and reference ID in one + immutable selection transition; +- rebuilds the eligible Similarity rows once; +- preserves the Merge dock and its presentation state; and +- emits at most one settled selection projection. + +The same operation should support replacing a manual Merge workspace with a +proposition. Its cancellation target remains the manual workspace's original +Normal-entry snapshot. + +Automatic advancement after a successful proposition merge is slightly +different because the clustering has changed. It should construct the settled +post-merge Normal state, use that as the next proposition's entry snapshot, and +then project the next Merge workspace without hiding or recreating the dock. +Failed and manual merges retain their existing no-advancement behavior. + +Reject-and-advance and shortcut navigation use the same in-place replacement +path. Selecting a nonactionable proposition still cancels to Normal mode and +hides Merge View because no review workspace remains active. + +## 5. Persistent dock implementation + +Replace the create/close pair with explicit lifecycle helpers: + +- `_ensure_merge_view()` creates, configures, connects, and docks Merge View at + most once; +- `_show_merge_view()` records the active state and reveals the existing dock; +- `_hide_merge_view()` hides it without removing it from `gui.views` or + disconnecting its reusable interaction callbacks; and +- `_dispose_merge_view()` performs the current disconnection and release work + during GUI shutdown only. + +The persistent dock must not use `Qt.WA_DeleteOnClose`. Its close button becomes +a cancel-and-hide intent. The GUI's generic close handler must not remove the +hidden Merge View from `gui.views`. + +Merge View contents remain ordinary projections of authoritative controller +state. Hiding the dock must not retain a second curation state inside the widget. +Showing it always refreshes its rows, reference, colors, status, drag policy, and +selection from the active selection state. + +## 6. Geometry and size policy + +Do not call whole-window `restoreState()` during Merge workflow transitions. +That operation affects every dock and can undo unrelated user layout changes. + +The persistent dock should retain: + +- docked versus floating state; +- floating position and size; +- dock area and tab relationship, maintained by the persistent Qt identity; and +- its most recent docked width or height. + +Immediately before hiding, record the Merge dock's local extent. After showing a +docked view, use `QMainWindow.resizeDocks()` in the relevant orientation to +restore that extent. A floating dock retains and, if necessary, restores its own +`saveGeometry()` value. + +Hiding a dock necessarily permits neighboring widgets to occupy the released +space. The requirement is therefore: + +- proposition-to-proposition changes cause no dock movement at all; and +- hide/show may temporarily reflow visible widgets, but reopening restores the + Merge dock's previous placement and proportions without permanently changing + unrelated docks. + +If strict zero reflow while inactive is ever required, Merge View must remain +visible in an inactive/empty state or occupy a reserved placeholder. That would +consume screen space and is not the default proposed here. + +## 7. History and shutdown + +Undo and redo continue to restore authoritative before/after selection and table +contexts. They should reveal, hide, or repopulate the persistent Merge View +without reconstructing its dock. + +Keeping the view alive changes shutdown responsibilities. Supervisor close must +explicitly: + +- disconnect Merge and Similarity drag/drop callbacks; +- disconnect dock close/cancel callbacks; +- remove any event-registry references owned by the persistent view; +- close the dock after workflow state has been settled for saving; and +- release Python references before interpreter shutdown. + +This cleanup must preserve the existing regression protection for the +intermittent Qt shutdown crash. + +## 8. Implementation sequence + +1. Add characterization tests for dock identity and geometry across the current + entry, cancellation, close-button, floating, and history paths. +2. Introduce `_ensure_merge_view()`, `_show_merge_view()`, + `_hide_merge_view()`, and `_dispose_merge_view()` while retaining the current + selection transitions. +3. Convert the dock close button and `V` cancellation to cancel-and-hide. +4. Add the atomic selection-controller transition for manual/proposition and + proposition/proposition replacement. +5. Route click navigation, `Alt+Up`/`Alt+Down`, reject-and-advance, successful + merge auto-advance, undo, and redo through the reusable view. +6. Remove transition-time whole-window `saveState()`/`restoreState()` calls and + add local dock extent restoration. +7. Extend shutdown cleanup and leak/crash regressions for the persistent view. +8. Update the workflow specification, architecture record, user documentation, + changelog, generated references when applicable, and PR description. + +## 9. Required regression coverage + +- `P1 -> P2` preserves `id(merge_view)` and `id(merge_view.dock)`. +- Manual Merge to proposition review preserves those identities. +- Shortcut navigation and reject-and-advance do not hide or recreate the dock. +- Successful auto-advance updates the existing view; failed and manual merges + do not advance. +- All unrelated dock geometries remain unchanged across proposition switches. +- Cancel/hide/reopen restores the Merge dock area and docked extent. +- A floating Merge dock retains its exact position and size. +- The dock close button cancels and hides without removing the view. +- Repeated enter/cancel and proposition navigation do not multiply callbacks. +- Undo restores the exact pre-action Merge workspace in the same dock; redo + restores the exact post-action workspace in that dock. +- Closing the GUI releases the persistent view, dock, Supervisor callbacks, and + event-registry references without an intermittent Qt shutdown crash. + +## 10. Acceptance criteria + +The work is complete when: + +- proposition navigation produces no visible dock/layout change; +- Merge View reopens where and at the size the curator left it; +- unrelated layout edits made while Merge View is hidden are preserved; +- every entry, cancellation, close, commit, rejection, reset, undo, and redo path + uses one consistent dock lifecycle; +- curation and history regression suites remain green; +- repeated GUI lifecycle testing shows no retained Qt callbacks or shutdown + crash; and +- `make lint`, `make format-check`, `make doc-check`, and `make test-full` pass. From f89835d72618b9d852409b8cd68dd8ba8c7f9550 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 14:14:13 +0200 Subject: [PATCH 094/110] refactor: switch merge propositions atomically --- phy/cluster/_selection.py | 24 ++++++++++++++++++ phy/cluster/tests/test_selection.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index fbdb9298..fddf1e40 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -350,6 +350,30 @@ def enter_merge_proposition(self, proposition_id, ordered_ids, workflow_context= ) ) + def switch_merge_proposition(self, proposition_id, ordered_ids): + """Replace the active Merge workspace while preserving its Normal entry snapshot.""" + self._require_merge_mode() + ordered = _as_unique_ids(ordered_ids) + if len(ordered) < 2: + raise ValueError('A merge proposition requires at least two cluster IDs.') + if not proposition_id: + raise ValueError('The merge proposition ID cannot be empty.') + merge = MergeSession( + ordered[0], + ordered, + self._state.merge.entry_snapshot, + proposition_id=str(proposition_id), + ) + return self._apply( + CurationSelectionState( + mode=WorkflowMode.MERGE, + reference_id=ordered[0], + presentation_order=ordered, + color_slots=ordered, + merge=merge, + ) + ) + def cancel_merge_mode(self): self._require_merge_mode() return self._apply(self._state.merge.entry_snapshot.selection) diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index 4bd6b3ba..b8751ca9 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -305,6 +305,44 @@ def test_enter_merge_proposition_validates_identity_and_membership(): controller.enter_merge_proposition('p', (1, 1)) +def test_switch_merge_proposition_preserves_original_normal_entry_snapshot(): + initial = CurationSelectionState(cluster_ids=(1,), similar_ids=(2,)) + context = {'cluster_filter': 'group == good'} + controller = CurationSelectionController(initial) + controller.enter_merge_mode(context) + + change = controller.switch_merge_proposition('p1', (8, 3)) + + assert change.after.merge_ids == (8, 3) + assert change.after.reference_id == 8 + assert change.after.similar_ids == () + assert change.after.merge.proposition_id == 'p1' + assert change.after.merge.entry_snapshot.selection is initial + assert change.after.merge.entry_snapshot.workflow_context is context + + first_snapshot = change.after.merge.entry_snapshot + change = controller.switch_merge_proposition('p2', (7, 4)) + + assert change.after.merge_ids == (7, 4) + assert change.after.merge.entry_snapshot is first_snapshot + assert controller.cancel_merge_mode().after is initial + + +def test_switch_merge_proposition_requires_merge_mode_and_valid_proposition(): + controller = CurationSelectionController(CurationSelectionState(cluster_ids=(1,))) + + with raises(RuntimeError, match='requires Merge mode'): + controller.switch_merge_proposition('p', (1, 2)) + + controller.enter_merge_mode() + with raises(ValueError, match='at least two'): + controller.switch_merge_proposition('p', (1,)) + with raises(ValueError, match='cannot be empty'): + controller.switch_merge_proposition('', (1, 2)) + with raises(ValueError, match='unique'): + controller.switch_merge_proposition('p', (1, 1)) + + def test_merge_workspace_edits_preserve_proposition_identity(): controller = CurationSelectionController(CurationSelectionState(cluster_ids=(1,))) controller.enter_merge_proposition('p', (1, 2)) From bd8bd46d54d1217f72b4ba4c9e7cc2d05ed0d5f0 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 14:16:39 +0200 Subject: [PATCH 095/110] refactor: keep merge dock alive across sessions --- phy/cluster/supervisor.py | 85 ++++++++++++----------- phy/cluster/tests/test_merge_lifecycle.py | 27 ++++--- phy/cluster/tests/test_supervisor.py | 40 ++++++++--- phy/gui/gui.py | 15 +++- 4 files changed, 108 insertions(+), 59 deletions(-) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 648f61b4..ff25ac79 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -785,7 +785,6 @@ def __init__( self.merge_propositions_view = None self.merge_propositions = merge_propositions self._merge_close_callback = None - self._merge_dock_state = None self._suspend_presentation_order_sync = False self._is_dirty = None self._sort = sort # Initial sort requested in the constructor @@ -943,6 +942,7 @@ def _save_gui_state(self, gui): if self._merge_close_callback is not None: unconnect(self._merge_close_callback) self._merge_close_callback = None + self._dispose_merge_view() # The GUI is closing and Qt will destroy its native QObject. Do not retain the # corresponding Python wrapper until interpreter shutdown. self.gui = None @@ -1049,7 +1049,10 @@ def _create_views(self, gui=None, sort=None): # Change the state after every clustering action, according to the action flow. connect(self._after_action, event='cluster', sender=self) - def _create_merge_view(self, state=None): + def _ensure_merge_view(self, state=None): + """Create and connect the dataset-scoped Merge View at most once.""" + if self.merge_view is not None: + return self.merge_view state = state or self.selection.state data = [self.get_cluster_info(cluster_id) for cluster_id in state.merge_ids] self.merge_view = MergeView(self.gui, data=data, columns=self.columns) @@ -1067,15 +1070,10 @@ def _create_merge_view(self, state=None): ) connect(self._on_cluster_drop, event='cluster_drop', sender=self.merge_view) connect(self._on_cluster_drop, event='cluster_drop', sender=self.similarity_view) - self.gui.add_view(self.merge_view, position='left', closable=True) - if self._merge_dock_state is not None: - self.gui.restoreState(self._merge_dock_state['window']) - if self._merge_dock_state['floating']: - self.merge_view.dock.setFloating(True) - self.merge_view.dock.restoreGeometry(self._merge_dock_state['geometry']) - else: - self.gui.splitDockWidget(self.cluster_view.dock, self.merge_view.dock, Qt.Vertical) - self.merge_view.dock.setAttribute(Qt.WA_DeleteOnClose) + self.gui.add_view( + self.merge_view, position='left', closable=True, persistent=True + ) + self.gui.splitDockWidget(self.cluster_view.dock, self.merge_view.dock, Qt.Vertical) self.merge_view.dock.add_button( name='cancel_merge_mode', text='Cancel Merge Mode', @@ -1083,6 +1081,34 @@ def _create_merge_view(self, state=None): ) return self.merge_view + def _show_merge_view(self, state=None): + """Reveal the persistent Merge View for the active workspace.""" + view = self._ensure_merge_view(state) + self.similarity_view.configure_cluster_drag_drop( + 'similarity', accepted_roles=('merge',), drag_selected_rows=True + ) + view.dock.show() + return view + + def _hide_merge_view(self): + """Hide Merge View without releasing its stable dock identity or callbacks.""" + if self.merge_view is not None: + self.merge_view.dock.hide() + self.similarity_view.configure_cluster_drag_drop(None) + + def _dispose_merge_view(self): + """Release the persistent Merge View during GUI shutdown.""" + view = self.merge_view + if view is None: + return + self._disconnect_merge_view_events(view) + unconnect(view.dock) + if self.gui is not None and view in self.gui.views: + self.gui._views.remove(view) + view.dock.close() + self.similarity_view.configure_cluster_drag_drop(None) + self.merge_view = None + def _reset_cluster_view(self): """Recreate the cluster view.""" logger.debug('Reset the cluster view.') @@ -1466,25 +1492,8 @@ def _set_merge_mode_ui(self, active): )(name) self._update_proposition_actions() - def _close_merge_view(self): - view = self.merge_view - self.merge_view = None - if view is not None and view in self.gui.views: - self._merge_dock_state = { - 'window': self.gui.saveState(), - 'floating': view.dock.isFloating(), - 'geometry': view.dock.saveGeometry(), - } - if view is not None: - self._disconnect_merge_view_events(view) - if view is not None and view in self.gui.views: - view.dock.close() - if view is not None: - unconnect(view.dock) - self.similarity_view.configure_cluster_drag_drop(None) - def _disconnect_merge_view_events(self, view): - """Release event-registry references owned by a temporary Merge View.""" + """Release event-registry references owned by the persistent Merge View.""" unconnect( view, self._on_cluster_drop, @@ -1523,12 +1532,12 @@ def _cancel_merge_mode(self, close_view=True): self._apply_selection_change(change, refresh_similarity=True, sync_presentation=False) self._restore_workflow_context(context) if close_view: - self._close_merge_view() + self._hide_merge_view() def _restore_history_context(self, selection, workflow_context, direction): """Restore a curation snapshot after the associated data undo or redo.""" - if selection.is_merge_mode and self.merge_view is None: - self._create_merge_view(selection) + if selection.is_merge_mode: + self._ensure_merge_view(selection) self._set_merge_mode_ui(True) elif not selection.is_merge_mode: self._set_merge_mode_ui(False) @@ -1541,8 +1550,9 @@ def _restore_history_context(self, selection, workflow_context, direction): else selection.merge.entry_snapshot.workflow_context ) self._restore_workflow_context(context) + self._show_merge_view(selection) else: - self._close_merge_view() + self._hide_merge_view() self._refresh_propositions() @staticmethod @@ -1830,9 +1840,6 @@ def attach(self, gui): @connect(event='close_view') def on_close_view(view, sender): if view is self.merge_view: - self._disconnect_merge_view_events(view) - unconnect(view.dock) - self.merge_view = None self._cancel_merge_mode(close_view=False) self._merge_close_callback = on_close_view @@ -2007,7 +2014,7 @@ def merge(self, cluster_ids=None, to=None): self._select_after_merge(out, selection_before) if merge_mode: self._set_merge_mode_ui(False) - self._close_merge_view() + self._hide_merge_view() controllers = [self.clustering] if proposition_id is not None: self.merge_propositions.accept(proposition_id, tuple(cluster_ids), int(out.added[0])) @@ -2169,7 +2176,7 @@ def toggle_merge_mode(self, callback=None): logger.warning('Select at least one Cluster View row before entering Merge mode.') return change = self.selection.enter_merge_mode(self._workflow_context()) - self._create_merge_view() + self._show_merge_view() self._set_merge_mode_ui(True) self._apply_selection_change(change, callback=callback) return change.after @@ -2229,7 +2236,7 @@ def _activate_merge_proposition(self, sender, key): change = self.selection.enter_merge_proposition( key, proposition.unit_ids, self._workflow_context() ) - self._create_merge_view() + self._show_merge_view() self._set_merge_mode_ui(True) self._apply_selection_change(change) self._refresh_propositions() diff --git a/phy/cluster/tests/test_merge_lifecycle.py b/phy/cluster/tests/test_merge_lifecycle.py index d43bd8d0..756ea94d 100644 --- a/phy/cluster/tests/test_merge_lifecycle.py +++ b/phy/cluster/tests/test_merge_lifecycle.py @@ -1,4 +1,4 @@ -"""Regression tests for temporary Merge View resource cleanup.""" +"""Regression tests for persistent Merge View resource cleanup.""" from phylib.utils.event import _EVENT @@ -7,7 +7,7 @@ from .test_supervisor import _select, supervisor # noqa: F401 -def test_supervisor_merge_mode_releases_temporary_event_callbacks(supervisor): # noqa: F811 +def test_supervisor_merge_mode_reuses_callbacks_until_shutdown(supervisor): # noqa: F811 _select(supervisor, [30], [20]) def callbacks_for(callback): @@ -25,22 +25,21 @@ def retained_by_event_callback(obj): assert callbacks_for(supervisor._on_cluster_drop) == [] assert callbacks_for(supervisor._remove_merge_candidate_on_right_click) == [] + merge_view = None for _ in range(2): supervisor.toggle_merge_mode() - merge_view = supervisor.merge_view + if merge_view is None: + merge_view = supervisor.merge_view + assert supervisor.merge_view is merge_view assert len(callbacks_for(supervisor._on_cluster_drop)) == 2 assert len(callbacks_for(supervisor._remove_merge_candidate_on_right_click)) == 1 supervisor.toggle_merge_mode() - assert callbacks_for(supervisor._on_cluster_drop) == [] - assert callbacks_for(supervisor._remove_merge_candidate_on_right_click) == [] - assert all( - sender not in (merge_view, merge_view.dock) for _, sender, _, _ in _EVENT._callbacks - ) - assert retained_by_event_callback(merge_view) == [] - assert retained_by_event_callback(merge_view.dock) == [] + assert len(callbacks_for(supervisor._on_cluster_drop)) == 2 + assert len(callbacks_for(supervisor._remove_merge_candidate_on_right_click)) == 1 + assert merge_view.dock.isHidden() close_callback = supervisor._merge_close_callback assert close_callback is not None @@ -49,5 +48,13 @@ def retained_by_event_callback(obj): supervisor._save_gui_state(supervisor.gui) assert callbacks_for(close_callback) == [] + assert callbacks_for(supervisor._on_cluster_drop) == [] + assert callbacks_for(supervisor._remove_merge_candidate_on_right_click) == [] + assert all( + sender not in (merge_view, merge_view.dock) for _, sender, _, _ in _EVENT._callbacks + ) + assert retained_by_event_callback(merge_view) == [] + assert retained_by_event_callback(merge_view.dock) == [] assert supervisor._merge_close_callback is None + assert supervisor.merge_view is None assert supervisor.gui is None diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index ea6db26b..7a4dbe3f 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -391,10 +391,13 @@ def on_select(sender, cluster_ids): assert events == [] supervisor.similarity_view.filter('id < 20') + merge_view = supervisor.merge_view supervisor.toggle_merge_mode() assert supervisor.selection.state == entry - assert supervisor.merge_view is None + assert supervisor.merge_view is merge_view + assert merge_view in supervisor.gui.views + assert merge_view.dock.isHidden() assert supervisor.cluster_view.isEnabled() assert not supervisor.cluster_view._interaction_blocked assert supervisor._workflow_context() == context @@ -410,12 +413,15 @@ def test_closing_merge_view_restores_original_table_rows(qtbot, supervisor): supervisor.toggle_merge_mode() assert 20 not in supervisor.similarity_view.get_ids() assert 11 not in supervisor.similarity_view.get_ids() + merge_view = supervisor.merge_view supervisor.merge_view.dock.close() qtbot.wait(10) assert not supervisor.selection.state.is_merge_mode - assert supervisor.merge_view is None + assert supervisor.merge_view is merge_view + assert merge_view in supervisor.gui.views + assert merge_view.dock.isHidden() assert supervisor.cluster_view.get_ids() == cluster_rows assert supervisor.similarity_view.get_ids() == similarity_rows assert supervisor.cluster_view.get_selected_ids() == [10, 30] @@ -427,8 +433,10 @@ def test_supervisor_merge_view_opens_below_cluster_and_restores_position(qtbot, supervisor.toggle_merge_mode() qtbot.wait(10) + merge_view = supervisor.merge_view + merge_dock = merge_view.dock cluster_rect = supervisor.cluster_view.dock.geometry() - merge_rect = supervisor.merge_view.dock.geometry() + merge_rect = merge_dock.geometry() assert merge_rect.top() >= cluster_rect.bottom() supervisor.merge_view.dock.setFloating(True) @@ -440,6 +448,8 @@ def test_supervisor_merge_view_opens_below_cluster_and_restores_position(qtbot, supervisor.toggle_merge_mode() qtbot.wait(10) + assert supervisor.merge_view is merge_view + assert supervisor.merge_view.dock is merge_dock assert supervisor.merge_view.dock.isFloating() assert supervisor.merge_view.dock.pos() == floating_position @@ -589,7 +599,9 @@ def test_closing_merge_view_cancels_mode(supervisor): merge_view.dock.close() assert supervisor.selection.state == entry - assert supervisor.merge_view is None + assert supervisor.merge_view is merge_view + assert merge_view in supervisor.gui.views + assert merge_view.dock.isHidden() assert supervisor.cluster_view.isEnabled() @@ -599,11 +611,13 @@ def test_merge_mode_action_and_cancel_control(supervisor): supervisor.select_actions.toggle_merge_mode() supervisor.block() assert supervisor.selection.state.is_merge_mode + merge_view = supervisor.merge_view supervisor.merge_view.dock.get_widget('cancel_merge_mode').click() supervisor.block() assert not supervisor.selection.state.is_merge_mode - assert supervisor.merge_view is None + assert supervisor.merge_view is merge_view + assert merge_view.dock.isHidden() def test_merge_mode_rejects_cluster_mutations(supervisor): @@ -671,7 +685,9 @@ def test_merge_mode_merge_undo_redo_restores_workspace(supervisor): merged_id = up.added[0] assert not supervisor.selection.state.is_merge_mode assert supervisor.selected == [merged_id] - assert supervisor.merge_view is None + merge_view = supervisor.merge_view + assert merge_view is not None + assert merge_view.dock.isHidden() assert set(up.deleted) == {30, 20, candidate} assignments_after = supervisor.clustering.spike_clusters.copy() events = [] @@ -687,7 +703,8 @@ def on_select(sender, cluster_ids): assert supervisor.selection.state == merge_before assert supervisor.selected_merge == [30, 20] assert supervisor.selected_similar == [candidate] - assert supervisor.merge_view is not None + assert supervisor.merge_view is merge_view + assert not merge_view.dock.isHidden() assert supervisor.actions.get('redo').isEnabled() assert events[-1] == list(merge_before.presentation_order) assert dict(supervisor.selection_color_indices) == dict(merge_before.color_indices) @@ -706,7 +723,8 @@ def on_select(sender, cluster_ids): ae(supervisor.clustering.spike_clusters, assignments_after) assert not supervisor.selection.state.is_merge_mode assert supervisor.selected == [merged_id] - assert supervisor.merge_view is None + assert supervisor.merge_view is merge_view + assert merge_view.dock.isHidden() assert events[-1] == [merged_id] unconnect(on_select) @@ -789,8 +807,12 @@ def test_merge_proposition_review_cancel_restores_exact_entry( supervisor.toggle_merge_mode() assert supervisor.selection.state.is_merge_mode assert supervisor.selection.state.merge.proposition_id is None + merge_view = supervisor.merge_view + merge_dock = merge_view.dock view._on_row_clicked(view._proxy_index_for_id(view._id_by_key[key])) + assert supervisor.merge_view is merge_view + assert supervisor.merge_view.dock is merge_dock assert supervisor.selected_merge == [30, 20] assert supervisor.selection.state.reference_id == 30 assert supervisor.selection.state.color_indices[30] == 0 @@ -799,6 +821,8 @@ def test_merge_proposition_review_cancel_restores_exact_entry( replacement = supervisor.merge_propositions.catalog.propositions[2] view._on_row_clicked(view._proxy_index_for_id(view._id_by_key[replacement.key])) + assert supervisor.merge_view is merge_view + assert supervisor.merge_view.dock is merge_dock assert supervisor.selected_merge == [11, 1] assert supervisor.selection.state.merge.proposition_id == replacement.key diff --git a/phy/gui/gui.py b/phy/gui/gui.py index 28ebb020..cc7eaeb2 100644 --- a/phy/gui/gui.py +++ b/phy/gui/gui.py @@ -848,7 +848,15 @@ def create_views(self): for i in range(n_views): self.create_and_add_view(view_name) - def add_view(self, view, position=None, closable=True, floatable=True, floating=None): + def add_view( + self, + view, + position=None, + closable=True, + floatable=True, + floating=None, + persistent=False, + ): """Add a dock widget to the main window. Parameters @@ -863,6 +871,8 @@ def add_view(self, view, position=None, closable=True, floatable=True, floating= Whether the view can be detached from the main GUI. floating : boolean Whether the view should be added in floating mode or not. + persistent : boolean + Whether closing the dock should hide it without removing the view. """ @@ -886,7 +896,8 @@ def add_view(self, view, position=None, closable=True, floatable=True, floating= # Emit the close_view event when the dock widget is closed. @connect(sender=dock) def on_close_dock_widget(sender): - self._views.remove(view) + if not persistent: + self._views.remove(view) emit('close_view', view, self) dock.show() From 3ee4d4c00951a7668bd68ba8c914925623295a37 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 14:18:44 +0200 Subject: [PATCH 096/110] feat: reuse merge dock during proposition changes --- phy/cluster/supervisor.py | 28 +++++++++++++++++----------- phy/cluster/tests/test_supervisor.py | 26 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index ff25ac79..f1bbd824 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -2012,9 +2012,6 @@ def merge(self, cluster_ids=None, to=None): out = self.clustering.merge(cluster_ids, to=to) if not getattr(getattr(self, 'task_logger', None), '_processing', False): self._select_after_merge(out, selection_before) - if merge_mode: - self._set_merge_mode_ui(False) - self._hide_merge_view() controllers = [self.clustering] if proposition_id is not None: self.merge_propositions.accept(proposition_id, tuple(cluster_ids), int(out.added[0])) @@ -2027,7 +2024,12 @@ def merge(self, cluster_ids=None, to=None): if next_key is not None: self._activate_merge_proposition(self.merge_propositions_view, next_key) else: + self._set_merge_mode_ui(False) + self._hide_merge_view() self.merge_propositions_view.select_key(proposition_id) + elif merge_mode: + self._set_merge_mode_ui(False) + self._hide_merge_view() self._global_history.action( *controllers, description='merge', @@ -2218,8 +2220,6 @@ def _activate_merge_proposition(self, sender, key): if active_key == key: self.merge_propositions_view.select_key(key) return self.selection.state - if self.selection.state.is_merge_mode: - self._cancel_merge_mode() self.merge_propositions_view.select_key(key) self.merge_propositions.project_live_ids(self.clustering.cluster_ids) catalog = self.merge_propositions.catalog @@ -2227,15 +2227,20 @@ def _activate_merge_proposition(self, sender, key): catalog.entry_for(key) is None or catalog.status_for(key) is not PropositionStatus.PENDING ): + if self.selection.state.is_merge_mode: + self._cancel_merge_mode() self._refresh_propositions() self.merge_propositions_view.select_key(key) return proposition = catalog.entry_for(key).proposition self.cluster_view.debouncer.flush() self.similarity_view.debouncer.flush() - change = self.selection.enter_merge_proposition( - key, proposition.unit_ids, self._workflow_context() - ) + if self.selection.state.is_merge_mode: + change = self.selection.switch_merge_proposition(key, proposition.unit_ids) + else: + change = self.selection.enter_merge_proposition( + key, proposition.unit_ids, self._workflow_context() + ) self._show_merge_view() self._set_merge_mode_ui(True) self._apply_selection_change(change) @@ -2256,13 +2261,13 @@ def _navigate_merge_proposition(self, direction, callback=None, ordered_keys=Non if self.merge_propositions_view is not None else None ) - if self.selection.state.is_merge_mode: - self._cancel_merge_mode() self.merge_propositions.project_live_ids(self.clustering.cluster_ids) key = self._pending_proposition_relative_to(current, direction, ordered_keys) if key is not None: self._activate_merge_proposition(self.merge_propositions_view, key) else: + if self.selection.state.is_merge_mode: + self._cancel_merge_mode() self._refresh_propositions() if current is not None: self.merge_propositions_view.select_key(current) @@ -2286,13 +2291,14 @@ def _reject_merge_proposition(self, sender, key): if self._active_merge_proposition_key() != key: logger.warning('Only the active merge proposition can be rejected.') return - self._cancel_merge_mode() self.merge_propositions.project_live_ids(self.clustering.cluster_ids) self.merge_propositions.reject(key) self._refresh_propositions() next_key = self._pending_proposition_relative_to(key, 'next', ordered_keys) if next_key is not None: self._activate_merge_proposition(self.merge_propositions_view, next_key) + elif self.selection.state.is_merge_mode: + self._cancel_merge_mode() self._global_history.action( self.merge_propositions, description=f'reject merge proposition {key}', diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 7a4dbe3f..c946fc38 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -809,10 +809,18 @@ def test_merge_proposition_review_cancel_restores_exact_entry( assert supervisor.selection.state.merge.proposition_id is None merge_view = supervisor.merge_view merge_dock = merge_view.dock + events = [] + + @connect(sender=supervisor) + def on_select(sender, cluster_ids): + events.append(cluster_ids) + view._on_row_clicked(view._proxy_index_for_id(view._id_by_key[key])) assert supervisor.merge_view is merge_view assert supervisor.merge_view.dock is merge_dock + assert not merge_dock.isHidden() + assert events == [[30, 20]] assert supervisor.selected_merge == [30, 20] assert supervisor.selection.state.reference_id == 30 assert supervisor.selection.state.color_indices[30] == 0 @@ -823,12 +831,15 @@ def test_merge_proposition_review_cancel_restores_exact_entry( view._on_row_clicked(view._proxy_index_for_id(view._id_by_key[replacement.key])) assert supervisor.merge_view is merge_view assert supervisor.merge_view.dock is merge_dock + assert not merge_dock.isHidden() + assert events == [[30, 20], [11, 1]] assert supervisor.selected_merge == [11, 1] assert supervisor.selection.state.merge.proposition_id == replacement.key supervisor.toggle_merge_mode() assert supervisor.selection.state == entry assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.PENDING + unconnect(on_select) def test_merge_proposition_navigation_shortcuts_and_text_focus( @@ -855,9 +866,16 @@ def test_merge_proposition_navigation_shortcuts_and_text_focus( } supervisor._activate_merge_proposition(supervisor.merge_propositions_view, first.key) + merge_view = supervisor.merge_view + merge_dock = merge_view.dock supervisor.next_merge_proposition() + assert supervisor.merge_view is merge_view + assert supervisor.merge_view.dock is merge_dock + assert not merge_dock.isHidden() assert supervisor.selection.state.merge.proposition_id == second.key supervisor.previous_merge_proposition() + assert supervisor.merge_view is merge_view + assert supervisor.merge_view.dock is merge_dock assert supervisor.selection.state.merge.proposition_id == first.key supervisor.merge_propositions_view.filter_edit.setFocus() @@ -899,6 +917,8 @@ def test_merge_proposition_accept_overlap_and_coupled_undo_redo( assignments_before = supervisor.clustering.spike_clusters.copy() supervisor._review_merge_proposition(supervisor.merge_propositions_view, first.key) workspace = supervisor.selection.snapshot() + merge_view = supervisor.merge_view + merge_dock = merge_view.dock supervisor.merge() supervisor.block() @@ -910,6 +930,9 @@ def test_merge_proposition_accept_overlap_and_coupled_undo_redo( assert supervisor.merge_propositions.catalog.reviews[first.key].applied_unit_ids == (30, 20) assert supervisor.selection.state.merge.proposition_id == next_proposition.key assert supervisor.selected_merge == [11, 1] + assert supervisor.merge_view is merge_view + assert supervisor.merge_view.dock is merge_dock + assert not merge_dock.isHidden() assert supervisor.merge_propositions_view.current_key == next_proposition.key assert supervisor.actions.get('undo').isEnabled() assert supervisor.merge_propositions_view.select_key(overlap.key) @@ -945,6 +968,7 @@ def test_failed_proposition_merge_and_reject_history( key = supervisor.merge_propositions.catalog.propositions[0].key supervisor._review_merge_proposition(supervisor.merge_propositions_view, key) workspace = supervisor.selection.snapshot() + merge_view = supervisor.merge_view def fail(*args, **kwargs): raise RuntimeError('merge failed') @@ -959,6 +983,8 @@ def fail(*args, **kwargs): assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.REJECTED next_key = supervisor.merge_propositions.catalog.propositions[1].key assert supervisor.selection.state.merge.proposition_id == next_key + assert supervisor.merge_view is merge_view + assert not merge_view.dock.isHidden() assert supervisor.actions.get('undo').isEnabled() supervisor.undo() assert supervisor.merge_propositions.catalog.status_for(key) is PropositionStatus.PENDING From a849b32481cbf9ecfc06b2332870a081c8d092fd Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 14:19:46 +0200 Subject: [PATCH 097/110] fix: restore merge dock local geometry --- phy/cluster/supervisor.py | 21 +++++++++++++++++++-- phy/cluster/tests/test_supervisor.py | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index f1bbd824..982da073 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -785,6 +785,7 @@ def __init__( self.merge_propositions_view = None self.merge_propositions = merge_propositions self._merge_close_callback = None + self._merge_dock_state = None self._suspend_presentation_order_sync = False self._is_dirty = None self._sort = sort # Initial sort requested in the constructor @@ -1084,16 +1085,32 @@ def _ensure_merge_view(self, state=None): def _show_merge_view(self, state=None): """Reveal the persistent Merge View for the active workspace.""" view = self._ensure_merge_view(state) + was_hidden = view.dock.isHidden() self.similarity_view.configure_cluster_drag_drop( 'similarity', accepted_roles=('merge',), drag_selected_rows=True ) view.dock.show() + if was_hidden and self._merge_dock_state is not None: + dock_state = self._merge_dock_state + if dock_state['floating']: + view.dock.setFloating(True) + view.dock.restoreGeometry(dock_state['geometry']) + elif not view.dock.isFloating(): + size = dock_state['size'] + self.gui.resizeDocks((view.dock,), (size.width(),), Qt.Horizontal) + self.gui.resizeDocks((view.dock,), (size.height(),), Qt.Vertical) return view def _hide_merge_view(self): """Hide Merge View without releasing its stable dock identity or callbacks.""" - if self.merge_view is not None: - self.merge_view.dock.hide() + if self.merge_view is not None and not self.merge_view.dock.isHidden(): + dock = self.merge_view.dock + self._merge_dock_state = { + 'floating': dock.isFloating(), + 'geometry': dock.saveGeometry(), + 'size': dock.size(), + } + dock.hide() self.similarity_view.configure_cluster_drag_drop(None) def _dispose_merge_view(self): diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index c946fc38..85317062 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -441,8 +441,10 @@ def test_supervisor_merge_view_opens_below_cluster_and_restores_position(qtbot, supervisor.merge_view.dock.setFloating(True) supervisor.merge_view.dock.move(70, 80) + supervisor.merge_view.dock.resize(240, 180) qtbot.wait(10) floating_position = supervisor.merge_view.dock.pos() + floating_size = supervisor.merge_view.dock.size() supervisor.toggle_merge_mode() supervisor.toggle_merge_mode() @@ -452,6 +454,24 @@ def test_supervisor_merge_view_opens_below_cluster_and_restores_position(qtbot, assert supervisor.merge_view.dock is merge_dock assert supervisor.merge_view.dock.isFloating() assert supervisor.merge_view.dock.pos() == floating_position + assert supervisor.merge_view.dock.size() == floating_size + + +def test_supervisor_merge_view_restores_docked_extent(qtbot, supervisor): + _select(supervisor, [30], [20]) + supervisor.toggle_merge_mode() + dock = supervisor.merge_view.dock + supervisor.gui.resizeDocks((dock,), (210,), Qt.Horizontal) + supervisor.gui.resizeDocks((dock,), (160,), Qt.Vertical) + qtbot.wait(10) + docked_size = dock.size() + + supervisor.toggle_merge_mode() + supervisor.toggle_merge_mode() + qtbot.wait(10) + + assert not dock.isFloating() + assert dock.size() == docked_size def test_supervisor_merge_candidate_interactions_follow_visible_role_order(supervisor): @@ -828,11 +848,15 @@ def on_select(sender, cluster_ids): assert 'PROPOSITION merge:' in supervisor.merge_view.dock.status replacement = supervisor.merge_propositions.catalog.propositions[2] + cluster_geometry = supervisor.cluster_view.dock.geometry() + proposition_geometry = supervisor.merge_propositions_view.dock.geometry() view._on_row_clicked(view._proxy_index_for_id(view._id_by_key[replacement.key])) assert supervisor.merge_view is merge_view assert supervisor.merge_view.dock is merge_dock assert not merge_dock.isHidden() assert events == [[30, 20], [11, 1]] + assert supervisor.cluster_view.dock.geometry() == cluster_geometry + assert supervisor.merge_propositions_view.dock.geometry() == proposition_geometry assert supervisor.selected_merge == [11, 1] assert supervisor.selection.state.merge.proposition_id == replacement.key From 27e3fae666818e05dc9ff786d5274778832eabd4 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 14:21:05 +0200 Subject: [PATCH 098/110] docs: document persistent merge dock lifecycle --- design/merge-propositions.md | 10 +++++----- design/merge-view-architecture.md | 7 ++++--- design/merge-view-dock-stability.md | 7 ++++--- design/merge-view-workflow.md | 7 ++++++- docs/api.md | 5 ++++- docs/changelog.md | 8 +++++--- docs/clustering.md | 12 +++++++----- docs/gui.md | 7 ++++--- docs/quickstart.md | 3 ++- 9 files changed, 41 insertions(+), 25 deletions(-) diff --git a/design/merge-propositions.md b/design/merge-propositions.md index 6e91a0b5..2de728de 100644 --- a/design/merge-propositions.md +++ b/design/merge-propositions.md @@ -117,11 +117,11 @@ background identifies the active review without recoloring its text. Foreground colors distinguish accepted, accepted-modified, rejected, stale, and invalid states; blue remains reserved for the merge reference cluster. -Clicking a pending, reviewable row immediately starts its review. It cancels and -replaces any active manual or proposition Merge workspace, snapshots the complete -current Normal workspace, and stages the ordered proposition IDs directly. It -must not first project those IDs into Cluster View, because cancellation must -restore the curator's pre-review state. Clicking an accepted, accepted-modified, +Clicking a pending, reviewable row immediately starts its review. It atomically +replaces any active manual or proposition Merge workspace in the same dock, +retains the original Normal-entry cancellation snapshot, and stages the ordered +proposition IDs directly. It must not first project those IDs into Cluster View, +because cancellation must restore the curator's pre-review state. Clicking an accepted, accepted-modified, rejected, stale, or invalid row first cancels any active workspace, then only highlights that row; it does not enter Merge mode. Invalid and stale propositions remain visible with their reason but cannot be reviewed. diff --git a/design/merge-view-architecture.md b/design/merge-view-architecture.md index 79a76c6d..240e9566 100644 --- a/design/merge-view-architecture.md +++ b/design/merge-view-architecture.md @@ -459,8 +459,9 @@ drag-and-drop, then add this reusable layer. ### 7.5 View closing and lifecycle Closing Merge View is a cancel intent. Cancellation must complete before the view -is removed or hidden. Re-entering Merge mode must be able to recreate or reveal -the view without retaining stale local state. +is hidden. Merge View and its dock persist for the dataset session, and re-entering +Merge mode reveals and freshly projects controller state into the same objects. +Only GUI shutdown disconnects and releases them. Application shutdown must not accidentally save the transient empty Cluster selection produced by Merge mode as the next Normal-mode selection. Either @@ -635,7 +636,7 @@ Cover: - close-to-cancel; - drag-and-drop and insertion order; - reference-row immobility; and -- view recreation and shutdown state. +- persistent view identity and shutdown disposal. ### 10.4 Safety regressions diff --git a/design/merge-view-dock-stability.md b/design/merge-view-dock-stability.md index 36de111c..492e8c59 100644 --- a/design/merge-view-dock-stability.md +++ b/design/merge-view-dock-stability.md @@ -1,6 +1,7 @@ # Merge View dock and workspace stability plan -Status: proposed for the unreleased phy 2.2 branch +Status: implemented and automatically validated on the unreleased phy 2.2 branch; +manual dataset smoke testing and release acceptance remain Companion documents: @@ -19,9 +20,9 @@ This plan changes presentation lifecycle only. The existing selection, proposition, merge, history, save, and cancellation integrity contracts remain authoritative. -## 2. Current source of disruption +## 2. Previous source of disruption -The current proposition transition takes a destructive GUI path: +Before this plan was implemented, proposition transitions took a destructive GUI path: 1. activating another proposition calls `_cancel_merge_mode()`; 2. cancellation calls `_close_merge_view()`; diff --git a/design/merge-view-workflow.md b/design/merge-view-workflow.md index 2e4197bb..13431783 100644 --- a/design/merge-view-workflow.md +++ b/design/merge-view-workflow.md @@ -148,7 +148,7 @@ GUI reports that another candidate is required. After a successful merge: - Merge mode ends; -- Merge View is cleared and closed; +- Merge View is cleared and hidden while its dataset-scoped dock is retained; - Cluster View is re-enabled; - the new merged cluster becomes the blue Cluster View selection; and - Similarity View is recomputed for the new cluster using the normal post-merge @@ -177,6 +177,11 @@ must also be unmistakable while active: Merge View is labelled **MERGE MODE**, Cluster View is dimmed or overlaid with an explanation, and the status area shows the pending merge count. +Merge View and its dock are created lazily once per dataset session. Cancelling, +closing, undoing, or re-entering Merge mode hides, reveals, and repopulates that +same dock, preserving its placement and size without restoring the whole-window +layout. + ## Undo and redo Before committing a Merge-mode merge, phy records the workspace state immediately diff --git a/docs/api.md b/docs/api.md index 84a57d5b..ece0c441 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1175,7 +1175,7 @@ close_view(view, gui) #### GUI.add_view -**`GUI.add_view(self, view, position=None, closable=True, floatable=True, floating=None)`** +**`GUI.add_view(self, view, position=None, closable=True, floatable=True, floating=None, persistent=False)`** Add a dock widget to the main window. @@ -1196,6 +1196,9 @@ Add a dock widget to the main window. * `floating : boolean` Whether the view should be added in floating mode or not. +* `persistent : boolean` + Whether closing the dock should hide it without removing the view. + --- #### GUI.closeEvent diff --git a/docs/changelog.md b/docs/changelog.md index a78016a2..442eccf8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -31,8 +31,9 @@ behavior they verify rather than listed separately. to enter or cancel Merge mode, transfer candidates with `Control`-right-click or drag-and-drop, and press `G` to merge every staged cluster plus the current Similarity View selection. Merge View opens below - Cluster View and remembers its in-session dock position; the dimmed Cluster - View remains scrollable. Scientific views follow Merge View order and then + Cluster View and keeps one stable in-session dock identity, position, and + size; proposition navigation updates that dock without moving neighboring + views. The dimmed Cluster View remains scrollable. Scientific views follow Merge View order and then selected Similarity rows in visible table order. Cancellation restores the entry state, and undo restores the full pre-merge workspace. - Review AIND/SpikeInterface format-version 2 merge propositions from @@ -76,7 +77,8 @@ behavior they verify rather than listed separately. dataset window closes, preventing retained Qt widgets and intermittent process crashes during shutdown. - Closing the Merge View now restores staged clusters to their original - Cluster and Similarity View rows, selections, and table positions. + Cluster and Similarity View rows, selections, and table positions. Reopening + reveals the same dock at its prior docked extent or floating geometry. - Show the active sort column and direction in Cluster and Similarity View headers. - Dragging Merge View rows now shows the cluster ID preview, insertion boundary, diff --git a/docs/clustering.md b/docs/clustering.md index c621aee5..c259007e 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -34,7 +34,9 @@ merge should contain only the staged rows. Press `G` to commit the staged clusters plus the selected Similarity candidates. Press `V` again, use **Cancel Merge Mode**, or close Merge View to cancel and restore the exact state from before entry. Undoing a committed Merge-mode merge restores the complete workspace as it appeared just -before `G`; Redo reapplies the merge and returns to the normal workflow. +before `G`; Redo reapplies the merge and returns to the normal workflow. The Merge View dock is +reused throughout the dataset session: proposition changes do not move it, and reopening restores +its previous docked extent or floating position and size without resetting neighboring docks. ### Reviewing merge propositions @@ -56,11 +58,11 @@ changes do not renumber it. Review persistence continues to use the internal stable key. Labels such as `P12.1` are reserved for future persisted propositions derived from `P12`; this release does not generate derived proposition rows. -Click a pending row to stage it in Merge View immediately; this cancels and -replaces any manual or proposition workspace already open. Clicking a completed, -stale, or invalid row cancels any active workspace and only highlights that row. +Click a pending row to stage it in Merge View immediately; this atomically +replaces any manual or proposition workspace already open in the same dock. +Clicking a completed, stale, or invalid row cancels any active workspace and only highlights that row. You can still add, remove, and reorder candidates in a pending review. `Alt+Down` -and `Alt+Up` cancel the current workspace and open the next or previous pending +and `Alt+Up` replace the current workspace with the next or previous pending proposition in the current visible table order, wrapping at either end. `Alt+Backspace` rejects the active proposition and advances; `Alt+Shift+Backspace` resets the highlighted completed review and reopens it when reviewable. diff --git a/docs/gui.md b/docs/gui.md index 139264ba..05dbc7ff 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -56,8 +56,9 @@ Control-right-clicking a Similarity View row promotes it into the primary select preserving the current comparison. See [Similarity and the wizard](similarity.md) for the complete workflow. -Press `V` to stage the current selections in Merge View. It opens below Cluster View and remembers -where you move it while toggling the mode. In this temporary mode, Cluster View is dimmed and +Press `V` to stage the current selections in Merge View. It opens below Cluster View and reuses the +same dock, placement, and size while toggling the mode or changing propositions. In this temporary +mode, Cluster View is dimmed and read-only but remains scrollable, every Merge View row is included in the pending merge, and Similarity View remains available for exploring additional candidates. Control-right-click or drag rows between Merge and Similarity views, or drag inside Merge View to reorder candidates. Press `G` @@ -66,7 +67,7 @@ to commit or `V` to cancel. See When a Template GUI dataset provides `curation.json` merge suggestions, the persistent **Merge Propositions** table is a button-free review queue. Clicking -a pending row immediately opens it in Merge View and replaces any active merge +a pending row immediately opens it in Merge View and atomically replaces any active merge workspace; clicking a nonactionable row only highlights it after cancelling the workspace. See [Reviewing merge propositions](clustering.md#reviewing-merge-propositions). diff --git a/docs/quickstart.md b/docs/quickstart.md index 6e7bd01a..d115afa1 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -117,7 +117,8 @@ clusters and press `G` to merge. phy gives the result a new cluster ID. Press For a longer comparison, press `V` first. Merge View keeps the candidates staged while you continue exploring Similarity View. Its status shows exactly how many clusters `G` will merge. -Press `V` again or close Merge View to cancel without changing the clustering. +Press `V` again or close Merge View to cancel without changing the clustering. Reopening uses the +same dock and restores its previous placement and size. If the Template GUI dataset includes an AIND/SpikeInterface format-version 2 `curation.json` with merge suggestions, use the persistent **Merge Propositions** From 391b8d28953819d6009e577ac15bc052b4d005fd Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 14:21:32 +0200 Subject: [PATCH 099/110] style: format merge dock lifecycle --- phy/cluster/supervisor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 982da073..9fafde43 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1071,9 +1071,7 @@ def _ensure_merge_view(self, state=None): ) connect(self._on_cluster_drop, event='cluster_drop', sender=self.merge_view) connect(self._on_cluster_drop, event='cluster_drop', sender=self.similarity_view) - self.gui.add_view( - self.merge_view, position='left', closable=True, persistent=True - ) + self.gui.add_view(self.merge_view, position='left', closable=True, persistent=True) self.gui.splitDockWidget(self.cluster_view.dock, self.merge_view.dock, Qt.Vertical) self.merge_view.dock.add_button( name='cancel_merge_mode', From 132b38bc1f72c85f3ec3cc31c8e85aad8874c578 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 14:34:25 +0200 Subject: [PATCH 100/110] fix: close proposition test controllers --- phy/apps/tests/test_base.py | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/phy/apps/tests/test_base.py b/phy/apps/tests/test_base.py index 437abf5a..4c33a26f 100644 --- a/phy/apps/tests/test_base.py +++ b/phy/apps/tests/test_base.py @@ -175,30 +175,38 @@ def test_controller_loads_and_reopens_merge_proposition_reviews(tempdir): } (tempdir / 'curation.json').write_text(json.dumps(source), encoding='utf8') controller = _mock_controller(tempdir, MyPropositionController) - proposition = controller.supervisor.merge_propositions.catalog.propositions[0] + try: + proposition = controller.supervisor.merge_propositions.catalog.propositions[0] - controller.supervisor.merge_propositions.reject(proposition.key) - controller.supervisor.save() + controller.supervisor.merge_propositions.reject(proposition.key) + controller.supervisor.save() - sidecar = json.loads((tempdir / 'curation_review.json').read_text(encoding='utf8')) - assert sidecar['source']['filename'] == 'curation.json' - assert len(sidecar['source']['sha256']) == 64 - assert sidecar['reviews'][proposition.key]['decision'] == 'rejected' + sidecar = json.loads((tempdir / 'curation_review.json').read_text(encoding='utf8')) + assert sidecar['source']['filename'] == 'curation.json' + assert len(sidecar['source']['sha256']) == 64 + assert sidecar['reviews'][proposition.key]['decision'] == 'rejected' + finally: + controller.close() reopened = _mock_controller(tempdir, MyPropositionController) - assert ( - reopened.supervisor.merge_propositions.catalog.status_for(proposition.key) - is PropositionStatus.REJECTED - ) + try: + assert ( + reopened.supervisor.merge_propositions.catalog.status_for(proposition.key) + is PropositionStatus.REJECTED + ) + finally: + reopened.close() def test_invalid_curation_json_does_not_prevent_ordinary_controller(tempdir, caplog): (tempdir / 'curation.json').write_text('{bad', encoding='utf8') controller = _mock_controller(tempdir, MyPropositionController) - - assert controller.supervisor.merge_propositions is None - assert 'Merge Propositions disabled' in caplog.text + try: + assert controller.supervisor.merge_propositions is None + assert 'Merge Propositions disabled' in caplog.text + finally: + controller.close() def test_allocate_spike_counts_redistributes_total_budget(): From 2488e3e54de6c932b73ee369acbcd36bd4612a58 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 14:42:00 +0200 Subject: [PATCH 101/110] fix: keep proposition queue layout stable --- docs/changelog.md | 2 ++ phy/cluster/_proposition_view.py | 16 ++++++++++ phy/cluster/supervisor.py | 4 ++- phy/cluster/tests/test_proposition_view.py | 37 ++++++++++++++++++++++ phy/cluster/tests/test_supervisor.py | 2 ++ 5 files changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 442eccf8..46782261 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -73,6 +73,8 @@ behavior they verify rather than listed separately. ### Fixed +- Keep the Merge Propositions table layout and scroll state stable while moving + between pending propositions instead of rebuilding the full queue. - Release GUI, Supervisor, table, dock, and curation event callbacks when a dataset window closes, preventing retained Qt widgets and intermittent process crashes during shutdown. diff --git a/phy/cluster/_proposition_view.py b/phy/cluster/_proposition_view.py index 91e8889e..54356b54 100644 --- a/phy/cluster/_proposition_view.py +++ b/phy/cluster/_proposition_view.py @@ -97,6 +97,7 @@ def _normalize_row(self, row, index): unit_ids = self._as_ordered_ids(row.get('unit_ids')) display_id = str(row.get('display_id') or f'P{index + 1}') status = str(row.get('status', 'pending')) + catalog_status = str(row.get('catalog_status', status)) new_unit_id = row.get('new_unit_id') reference = row.get('reference', unit_ids[0] if unit_ids else None) invalid_or_stale = status in {'invalid', 'stale'} @@ -118,6 +119,7 @@ def _normalize_row(self, row, index): 'display_id': display_id, 'unit_ids': unit_ids, 'status': status, + '_catalog_status': catalog_status, 'reference': reference, 'reason': row.get('reason') or '', 'new_unit_id': new_unit_id, @@ -198,6 +200,20 @@ def select_key(self, key): self._set_dock_status(key) return True + def set_active_key(self, key, previous_key=None): + """Mark one row active without resetting the table model or its layout.""" + if key not in self._id_by_key: + return False + patches = [] + previous_id = self._id_by_key.get(previous_key or self._current_key) + if previous_id is not None: + previous = self._model.row_by_id(previous_id) + patches.append({'id': previous_id, 'status': previous['_catalog_status']}) + row_id = self._id_by_key[key] + patches.append({'id': row_id, 'status': 'active'}) + self.change(patches) + return self.select_key(key) + def _select_actionable(self, direction): keys = self.actionable_keys() if not keys: diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 9fafde43..83c2f42b 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1354,6 +1354,7 @@ def _proposition_rows(self): 'display_id': f'P{entry.index + 1}', 'unit_ids': unit_ids, 'status': status, + 'catalog_status': catalog_status.value, 'reason': entry.invalid_reason or (catalog.reason_for(key) if entry.key is not None else None), 'new_unit_id': proposition.new_unit_id if proposition is not None else None, @@ -2259,7 +2260,8 @@ def _activate_merge_proposition(self, sender, key): self._show_merge_view() self._set_merge_mode_ui(True) self._apply_selection_change(change) - self._refresh_propositions() + self.merge_propositions_view.set_active_key(key, previous_key=active_key) + self._update_proposition_actions() return change.after def _review_merge_proposition(self, sender, key): diff --git a/phy/cluster/tests/test_proposition_view.py b/phy/cluster/tests/test_proposition_view.py index eb874868..dfee341b 100644 --- a/phy/cluster/tests/test_proposition_view.py +++ b/phy/cluster/tests/test_proposition_view.py @@ -49,3 +49,40 @@ def on_activate(sender, key): assert view.current_key == 'merge:2' unconnect(on_activate) view.close() + + +def test_merge_propositions_active_row_changes_in_place(qtbot): + view = MergePropositionsView( + data=[ + { + 'key': f'merge:{index}', + 'unit_ids': (index, index + 1), + 'status': 'rejected' if index == 1 else 'pending', + 'catalog_status': 'rejected' if index == 1 else 'pending', + } + for index in range(20) + ] + ) + _wait_until_table_ready(qtbot, view) + model = view._model + resets = [] + model.modelReset.connect(lambda: resets.append(True)) + view.table_view.verticalScrollBar().setValue(5) + scroll = view.table_view.verticalScrollBar().value() + + assert view.set_active_key('merge:10') + assert view._model is model + assert resets == [] + assert view._model.row_by_id(10)['status'] == 'active' + assert view.table_view.verticalScrollBar().value() >= scroll + + assert view.set_active_key('merge:11', previous_key='merge:10') + assert view._model is model + assert view._model.row_by_id(10)['status'] == 'pending' + assert view._model.row_by_id(11)['status'] == 'active' + + assert view.set_active_key('merge:1') + assert view._model.row_by_id(11)['status'] == 'pending' + assert view._model.row_by_id(1)['status'] == 'active' + assert view._model.row_by_id(1)['_catalog_status'] == 'rejected' + view.close() diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 85317062..e9e70ad5 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -897,6 +897,8 @@ def test_merge_proposition_navigation_shortcuts_and_text_focus( assert supervisor.merge_view.dock is merge_dock assert not merge_dock.isHidden() assert supervisor.selection.state.merge.proposition_id == second.key + assert supervisor.merge_propositions_view._model.row_by_id(0)['status'] == 'pending' + assert supervisor.merge_propositions_view._model.row_by_id(1)['status'] == 'active' supervisor.previous_merge_proposition() assert supervisor.merge_view is merge_view assert supervisor.merge_view.dock is merge_dock From 80511f98d8ac5279e378fb6095ae2998e446685d Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 15:21:39 +0200 Subject: [PATCH 102/110] fix: deselect clusters from correlogram view --- docs/api.md | 9 ++++++ docs/changelog.md | 4 +++ docs/shortcuts.md | 1 + docs/visualization.md | 4 +++ phy/apps/base.py | 27 ++++++++++++++++ phy/apps/tests/test_base.py | 35 +++++++++++++++++++++ phy/cluster/views/correlogram.py | 18 ++++++++++- phy/cluster/views/tests/test_correlogram.py | 32 +++++++++++++++++++ 8 files changed, 129 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index ece0c441..b924e06a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -7429,6 +7429,15 @@ selected clusters (template view, raster view). --- +#### CorrelogramView.on_mouse_release + + +**`CorrelogramView.on_mouse_release(self, e)`** + +Remove a cluster after a stationary Control-secondary click. + +--- + #### CorrelogramView.on_mouse_wheel diff --git a/docs/changelog.md b/docs/changelog.md index 46782261..d41945a9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -18,6 +18,10 @@ behavior they verify rather than listed separately. ### Added +- `Control`-right-clicking a diagonal autocorrelogram in the Correlogram View + removes that cluster from the active selection. On a cross-correlogram spanning + the Cluster and Similarity selections, it removes the Similarity cluster. In + Merge mode, staged clusters remain managed by the Merge View. - Split the lower-amplitude portion of one selected cluster directly from the Amplitude View: use `Alt`-right-drag to preview a threshold, then press `K` to commit an exact all-spike split. Individual waveform traces receive the diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 3554bd77..5fbe0f2a 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -104,6 +104,7 @@ CorrelogramView Keyboard shortcuts - change_bin_size alt+wheel - change_window_size ctrl+wheel +- deselect_cluster ctrl+right click Snippets - set_bin :cb diff --git a/docs/visualization.md b/docs/visualization.md index 1a5ed260..09581ec2 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -330,6 +330,9 @@ auto- and cross-correlogram calculation. These spikes are picked randomly. See [Spike sampling and performance](performance.md) before increasing them. You can dynamically change the window size and bin size with control+mouse wheel and alt+mouse wheel. +Control-right-click a diagonal autocorrelogram to remove that cluster from the +current selection. On a cross-correlogram between a Cluster View selection and a +Similarity View selection, the same shortcut removes the Similarity View cluster. Choose **View settings** in the view menu to edit the two spike-budget modes, bin size, window size, and refractory period together. The budget settings are global controller preferences; the bin, window, and refractory settings are @@ -349,6 +352,7 @@ Keyboard shortcuts for CorrelogramView Keyboard shortcuts - change_bin_size alt+wheel - change_window_size ctrl+wheel +- deselect_cluster ctrl+right click Snippets - set_bin :cb diff --git a/phy/apps/base.py b/phy/apps/base.py index 381dff86..160b57ea 100644 --- a/phy/apps/base.py +++ b/phy/apps/base.py @@ -2127,6 +2127,32 @@ def create_correlogram_view(self): sample_rate=self.model.sample_rate, ) + @connect(sender=view) + def on_request_correlogram_deselect(sender, cluster_id_a, cluster_id_b): + state = self.supervisor.selection.state + if cluster_id_a == cluster_id_b: + cluster_id = cluster_id_a + else: + selected_clusters = set(state.cluster_ids) + selected_similar = set(state.similar_ids) + cluster_id = next( + ( + cluster_id + for cluster_id, other_cluster_id in ( + (cluster_id_a, cluster_id_b), + (cluster_id_b, cluster_id_a), + ) + if cluster_id in selected_similar and other_cluster_id in selected_clusters + ), + None, + ) + if cluster_id in state.similar_ids: + self.supervisor.similarity_view.select_toggle(cluster_id) + elif not state.is_merge_mode and cluster_id in state.cluster_ids: + self.supervisor.cluster_view.select_toggle(cluster_id) + elif state.is_merge_mode and cluster_id in state.merge_ids: + logger.warning('Staged Merge clusters must be changed in the Merge View.') + @connect(sender=view) def on_view_attached(view_, gui): def validate(values): @@ -2204,6 +2230,7 @@ def edit_view_settings(): @connect(sender=view) def on_close_view(view_, gui): + unconnect(on_request_correlogram_deselect) unconnect(on_view_attached) return view diff --git a/phy/apps/tests/test_base.py b/phy/apps/tests/test_base.py index 4c33a26f..4f0145b6 100644 --- a/phy/apps/tests/test_base.py +++ b/phy/apps/tests/test_base.py @@ -167,6 +167,41 @@ def _mock_controller(tempdir, cls): ) +def test_correlogram_deselect_request_preserves_hidden_selection(qtbot, tempdir): + controller = _mock_controller(tempdir, MyController) + gui = controller.create_gui(do_prompt_save=False) + with qtbot.waitExposed(gui): + gui.show() + + try: + supervisor = controller.supervisor + supervisor.select(list(range(22))) + supervisor.block() + + view = gui.list_views(CorrelogramView)[0] + emit('request_correlogram_deselect', view, 0, 1) + supervisor.block() + assert supervisor.selected_clusters == list(range(22)) + + emit('request_correlogram_deselect', view, 0, 0) + supervisor.block() + + assert supervisor.selected_clusters == list(range(1, 22)) + + supervisor.similarity_view.select([22]) + supervisor.block() + assert supervisor.selected_similar == [22] + + emit('request_correlogram_deselect', view, 1, 22) + supervisor.block() + + assert supervisor.selected_clusters == list(range(1, 22)) + assert supervisor.selected_similar == [] + finally: + gui.close() + controller.close() + + def test_controller_loads_and_reopens_merge_proposition_reviews(tempdir): source = { 'format_version': '2', diff --git a/phy/cluster/views/correlogram.py b/phy/cluster/views/correlogram.py index ec138c0a..ea2fd2e5 100644 --- a/phy/cluster/views/correlogram.py +++ b/phy/cluster/views/correlogram.py @@ -9,7 +9,7 @@ import numpy as np from phylib.io.array import _clip -from phylib.utils import Bunch +from phylib.utils import Bunch, emit from phy.plot.transform import Scale from phy.plot.visuals import HistogramVisual, LineVisual, TextVisual @@ -62,6 +62,7 @@ class CorrelogramView(ScalingMixin, ManualClusteringView): default_shortcuts = { 'change_window_size': 'ctrl+wheel', 'change_bin_size': 'alt+wheel', + 'deselect_cluster': 'ctrl+right click', } default_snippets = { @@ -237,6 +238,21 @@ def toggle_labels(self, checked): self.text_visual.hide() self.canvas.update() + def on_mouse_release(self, e): + """Remove a cluster after a stationary Control-secondary click.""" + if 'Control' not in e.modifiers or e.button != 'Right' or not self.cluster_ids: + return + press_pos = self.canvas._mouse_press_position + if press_pos is None or np.linalg.norm(np.asarray(e.pos) - press_pos) > 5: + return + (i, j), _ = self.canvas.grid.box_map(e.pos) + emit( + 'request_correlogram_deselect', + self, + self.cluster_ids[i], + self.cluster_ids[j], + ) + def attach(self, gui): """Attach the view to the GUI.""" super().attach(gui) diff --git a/phy/cluster/views/tests/test_correlogram.py b/phy/cluster/views/tests/test_correlogram.py index a0cf90a6..561be8ca 100644 --- a/phy/cluster/views/tests/test_correlogram.py +++ b/phy/cluster/views/tests/test_correlogram.py @@ -6,6 +6,9 @@ import numpy as np from phylib.io.mock import artificial_correlograms +from phylib.utils import connect, unconnect + +from phy.plot.tests import mouse_click from ..correlogram import CorrelogramView from . import _stop_and_close @@ -36,6 +39,35 @@ def get_firing_rate(cluster_ids, bin_size): v.on_select(cluster_ids=[0, 2, 3]) v.on_select(cluster_ids=[0, 2]) + deselected = [] + + @connect(sender=v) + def on_request_correlogram_deselect(sender, cluster_id_a, cluster_id_b): + deselected.append((cluster_id_a, cluster_id_b)) + + v.on_select(cluster_ids=[0, 2, 3]) + width, height = v.canvas.get_size() + # A Control-secondary click on a diagonal autocorrelogram identifies one cluster. + mouse_click( + qtbot, + v.canvas, + (width / 2, height / 2), + button='Right', + modifiers=('Control',), + ) + # Plain clicks do nothing; modified cross-correlogram clicks report both clusters. + mouse_click(qtbot, v.canvas, (width / 6, height / 6), button='Right') + mouse_click( + qtbot, + v.canvas, + (width / 2, height / 6), + button='Right', + modifiers=('Control',), + ) + + assert deselected == [(2, 2), (0, 2)] + unconnect(on_request_correlogram_deselect) + v.toggle_normalization(True) v.toggle_labels(False) v.toggle_labels(True) From add8d7e57bdbfedefae6b8595ab271c137a67a82 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Mon, 3 Aug 2026 15:34:02 +0200 Subject: [PATCH 103/110] fix: align correlogram deselection with merge state --- docs/api.md | 9 ++++ docs/changelog.md | 5 +- docs/visualization.md | 8 ++- phy/apps/base.py | 4 +- phy/apps/tests/test_base.py | 60 ++++++++++++++++++++- phy/cluster/_selection.py | 33 ++++++++++++ phy/cluster/supervisor.py | 13 +++++ phy/cluster/tests/test_selection.py | 20 +++++++ phy/cluster/views/correlogram.py | 10 ++-- phy/cluster/views/tests/test_correlogram.py | 30 ++++++++--- 10 files changed, 174 insertions(+), 18 deletions(-) diff --git a/docs/api.md b/docs/api.md index b924e06a..e64ce5e1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10117,6 +10117,15 @@ Only used in the automated testing suite. +--- + +#### Supervisor.deselect_from_merge + + +**`Supervisor.deselect_from_merge(self, cluster_ids, callback=None)`** + +Remove staged clusters entirely from the active Merge selection. + --- #### Supervisor.filter diff --git a/docs/changelog.md b/docs/changelog.md index d41945a9..01a8db39 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -20,8 +20,9 @@ behavior they verify rather than listed separately. - `Control`-right-clicking a diagonal autocorrelogram in the Correlogram View removes that cluster from the active selection. On a cross-correlogram spanning - the Cluster and Similarity selections, it removes the Similarity cluster. In - Merge mode, staged clusters remain managed by the Merge View. + the primary and Similarity selections, it removes the Similarity cluster. This + also works during Merge mode and proposition review; removing the reference + promotes the next staged cluster to reference. - Split the lower-amplitude portion of one selected cluster directly from the Amplitude View: use `Alt`-right-drag to preview a threshold, then press `K` to commit an exact all-spike split. Individual waveform traces receive the diff --git a/docs/visualization.md b/docs/visualization.md index 09581ec2..3c4fe982 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -331,8 +331,12 @@ See [Spike sampling and performance](performance.md) before increasing them. You can dynamically change the window size and bin size with control+mouse wheel and alt+mouse wheel. Control-right-click a diagonal autocorrelogram to remove that cluster from the -current selection. On a cross-correlogram between a Cluster View selection and a -Similarity View selection, the same shortcut removes the Similarity View cluster. +current selection. On a cross-correlogram between a primary selection (from the +Cluster View or staged Merge selection) and a Similarity View selection, the same +shortcut removes the Similarity View cluster. +During Merge mode and proposition review, it can also remove a staged cluster; if +that cluster is the reference, the next staged cluster becomes the reference. The +last staged cluster cannot be removed without leaving Merge mode. Choose **View settings** in the view menu to edit the two spike-budget modes, bin size, window size, and refractory period together. The budget settings are global controller preferences; the bin, window, and refractory settings are diff --git a/phy/apps/base.py b/phy/apps/base.py index 160b57ea..3fd47fdd 100644 --- a/phy/apps/base.py +++ b/phy/apps/base.py @@ -2133,7 +2133,7 @@ def on_request_correlogram_deselect(sender, cluster_id_a, cluster_id_b): if cluster_id_a == cluster_id_b: cluster_id = cluster_id_a else: - selected_clusters = set(state.cluster_ids) + selected_clusters = set(state.merge_ids) selected_similar = set(state.similar_ids) cluster_id = next( ( @@ -2151,7 +2151,7 @@ def on_request_correlogram_deselect(sender, cluster_id_a, cluster_id_b): elif not state.is_merge_mode and cluster_id in state.cluster_ids: self.supervisor.cluster_view.select_toggle(cluster_id) elif state.is_merge_mode and cluster_id in state.merge_ids: - logger.warning('Staged Merge clusters must be changed in the Merge View.') + self.supervisor.deselect_from_merge(cluster_id) @connect(sender=view) def on_view_attached(view_, gui): diff --git a/phy/apps/tests/test_base.py b/phy/apps/tests/test_base.py index 4f0145b6..7a94a0e9 100644 --- a/phy/apps/tests/test_base.py +++ b/phy/apps/tests/test_base.py @@ -175,10 +175,28 @@ def test_correlogram_deselect_request_preserves_hidden_selection(qtbot, tempdir) try: supervisor = controller.supervisor + view = gui.list_views(CorrelogramView)[0] + + # Exercise the complete GUI path for the first diagonal cell, which is the + # current best cluster in the Cluster View. + supervisor.select([0, 1, 2]) + supervisor.block() + view.on_select(cluster_ids=[0, 1, 2]) + width, height = view.canvas.get_size() + first_center = 0.5 * (1 - 0.9 * (1 - 1 / 3)) + mouse_click( + qtbot, + view.canvas, + (first_center * width, first_center * height), + button='Right', + modifiers=('Control',), + ) + supervisor.block() + assert supervisor.selected_clusters == [1, 2] + supervisor.select(list(range(22))) supervisor.block() - view = gui.list_views(CorrelogramView)[0] emit('request_correlogram_deselect', view, 0, 1) supervisor.block() assert supervisor.selected_clusters == list(range(22)) @@ -202,6 +220,46 @@ def test_correlogram_deselect_request_preserves_hidden_selection(qtbot, tempdir) controller.close() +def test_correlogram_deselects_merge_proposition_reference(qtbot, tempdir): + source = { + 'format_version': '2', + 'unit_ids': list(range(MyModel.n_clusters)), + 'merges': [{'unit_ids': [0, 1, 2]}], + } + (tempdir / 'curation.json').write_text(json.dumps(source), encoding='utf8') + controller = _mock_controller(tempdir, MyPropositionController) + gui = controller.create_gui(do_prompt_save=False) + with qtbot.waitExposed(gui): + gui.show() + + try: + supervisor = controller.supervisor + proposition = supervisor.merge_propositions.catalog.propositions[0] + supervisor._review_merge_proposition(supervisor.merge_propositions_view, proposition.key) + assert supervisor.selected_merge == [0, 1, 2] + + view = gui.list_views(CorrelogramView)[0] + emit('request_correlogram_deselect', view, 0, 0) + supervisor.block() + + assert supervisor.selected_merge == [1, 2] + assert supervisor.selection.state.reference_id == 1 + assert supervisor.selection.state.merge.proposition_id == proposition.key + assert supervisor.merge_view._reference_id == 1 + + candidate = supervisor.similarity_view.get_ids()[0] + supervisor.similarity_view.select([candidate]) + supervisor.block() + emit('request_correlogram_deselect', view, 1, candidate) + supervisor.block() + + assert supervisor.selected_merge == [1, 2] + assert supervisor.selected_similar == [] + finally: + gui.close() + controller.close() + + def test_controller_loads_and_reopens_merge_proposition_reviews(tempdir): source = { 'format_version': '2', diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index fddf1e40..ee17f51f 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -434,6 +434,39 @@ def remove_from_merge(self, cluster_ids): ) ) + def deselect_from_merge(self, cluster_ids): + """Remove staged IDs entirely, promoting the next staged ID to reference.""" + self._require_merge_mode() + current = self._state + removed = _as_unique_ids(cluster_ids) + if not set(removed) <= set(current.merge_ids): + raise ValueError('Deselected IDs must belong to the merge session.') + merge_ids = tuple( + cluster_id for cluster_id in current.merge_ids if cluster_id not in removed + ) + if not merge_ids: + raise ValueError('The last staged merge cluster cannot be deselected.') + reference = merge_ids[0] + merge = MergeSession( + reference, + merge_ids, + current.merge.entry_snapshot, + proposition_id=current.merge.proposition_id, + ) + slots = list(current.color_slots) + if reference != current.reference_id: + reference_slot = slots.index(reference) + slots[0], slots[reference_slot] = slots[reference_slot], slots[0] + return self._apply( + CurationSelectionState( + mode=WorkflowMode.MERGE, + similar_ids=current.similar_ids, + reference_id=reference, + color_slots=tuple(slots), + merge=merge, + ) + ) + def reorder_merge(self, cluster_ids, insertion): self._require_merge_mode() current = self._state diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 83c2f42b..4d4f9e8d 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1313,6 +1313,7 @@ def _project_merge_view(self): state = self.selection.state if self.merge_view is None or not state.is_merge_mode: return + self.merge_view._reference_id = state.reference_id data = [self.get_cluster_info(cluster_id) for cluster_id in state.merge_ids] self.merge_view.set_merge_ids(state.merge_ids, data, state.color_indices) self.merge_view.dock.set_status(self._merge_status_text()) @@ -2404,6 +2405,18 @@ def remove_from_merge(self, cluster_ids, callback=None): self._apply_selection_change(change, callback=callback) return change.after + def deselect_from_merge(self, cluster_ids, callback=None): + """Remove staged clusters entirely from the active Merge selection.""" + if isinstance(cluster_ids, Integral): + cluster_ids = (int(cluster_ids),) + try: + change = self.selection.deselect_from_merge(cluster_ids) + except ValueError as e: + logger.warning('%s', e) + return + self._apply_selection_change(change, callback=callback) + return change.after + def reorder_merge(self, cluster_ids, insertion, callback=None): """Reorder staged candidates and their scientific presentation order.""" change = self.selection.reorder_merge(cluster_ids, insertion) diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index b8751ca9..a96e438f 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -402,6 +402,26 @@ def test_merge_candidate_guards_reference_and_duplicate_membership(): controller.remove_from_merge((9,)) +def test_merge_proposition_deselection_can_replace_reference(): + controller = CurationSelectionController(CurationSelectionState(cluster_ids=(9,))) + controller.enter_merge_proposition('proposal-1', (1, 2, 3)) + + change = controller.deselect_from_merge((1,)) + + assert change.after.merge_ids == (2, 3) + assert change.after.reference_id == 2 + assert change.after.presentation_order == (2, 3) + assert change.after.color_slots == (2, 1, 3) + assert change.after.merge.proposition_id == 'proposal-1' + + with raises(ValueError, match='merge session'): + controller.deselect_from_merge((9,)) + + controller.deselect_from_merge((3,)) + with raises(ValueError, match='last staged'): + controller.deselect_from_merge((2,)) + + def test_presentation_order_transition_preserves_roles_and_colors(): controller = CurationSelectionController( CurationSelectionState( diff --git a/phy/cluster/views/correlogram.py b/phy/cluster/views/correlogram.py index ea2fd2e5..51550f73 100644 --- a/phy/cluster/views/correlogram.py +++ b/phy/cluster/views/correlogram.py @@ -82,8 +82,10 @@ def __init__(self, correlograms=None, firing_rate=None, sample_rate=None, **kwar self.local_state_attrs += ('bin_size', 'window_size', 'refractory_period') self.canvas.set_layout(layout='grid') - # Outside margin to show labels. - self.canvas.gpu_transforms.add(Scale(0.9)) + # Outside margin to show labels. Mouse hit-testing must invert this transform + # before resolving a correlogram cell. + self._display_scale = Scale(0.9) + self.canvas.gpu_transforms.add(self._display_scale) assert sample_rate > 0 self.sample_rate = float(sample_rate) @@ -245,7 +247,9 @@ def on_mouse_release(self, e): press_pos = self.canvas._mouse_press_position if press_pos is None or np.linalg.norm(np.asarray(e.pos) - press_pos) > 5: return - (i, j), _ = self.canvas.grid.box_map(e.pos) + ndc = self.canvas.window_to_ndc(e.pos) + grid_ndc = self._display_scale.inverse().apply(ndc)[0] + i, j = self.canvas.grid.get_closest_box(grid_ndc) emit( 'request_correlogram_deselect', self, diff --git a/phy/cluster/views/tests/test_correlogram.py b/phy/cluster/views/tests/test_correlogram.py index 561be8ca..453e4af9 100644 --- a/phy/cluster/views/tests/test_correlogram.py +++ b/phy/cluster/views/tests/test_correlogram.py @@ -45,27 +45,41 @@ def get_firing_rate(cluster_ids, bin_size): def on_request_correlogram_deselect(sender, cluster_id_a, cluster_id_b): deselected.append((cluster_id_a, cluster_id_b)) - v.on_select(cluster_ids=[0, 2, 3]) + cluster_ids = list(range(v.max_n_clusters)) + v.on_select(cluster_ids=cluster_ids) width, height = v.canvas.get_size() - # A Control-secondary click on a diagonal autocorrelogram identifies one cluster. + # Exercise every visual diagonal center, including the first/best cluster. The + # entire matrix is scaled to leave room for labels, so unscaled hit-testing + # would map outer cells to an adjacent index when many clusters are displayed. + n = len(cluster_ids) + for k in range(n): + x_ndc = 0.9 * (-1 + (2 * k + 1) / n) + y_ndc = 0.9 * (+1 - (2 * k + 1) / n) + mouse_click( + qtbot, + v.canvas, + (0.5 * width * (x_ndc + 1), 0.5 * height * (1 - y_ndc)), + button='Right', + modifiers=('Control',), + ) + # Plain clicks do nothing; modified cross-correlogram clicks report both clusters. + first_center = 0.5 * (1 - 0.9 * (1 - 1 / n)) + second_center = 0.5 * (1 - 0.9 * (1 - 3 / n)) mouse_click( qtbot, v.canvas, - (width / 2, height / 2), + (first_center * width, first_center * height), button='Right', - modifiers=('Control',), ) - # Plain clicks do nothing; modified cross-correlogram clicks report both clusters. - mouse_click(qtbot, v.canvas, (width / 6, height / 6), button='Right') mouse_click( qtbot, v.canvas, - (width / 2, height / 6), + (second_center * width, first_center * height), button='Right', modifiers=('Control',), ) - assert deselected == [(2, 2), (0, 2)] + assert deselected == [(cluster_id, cluster_id) for cluster_id in cluster_ids] + [(0, 1)] unconnect(on_request_correlogram_deselect) v.toggle_normalization(True) From 4ba56d5d8d2f71fa32c09d8bf097d0fb0f06b47b Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Tue, 4 Aug 2026 18:39:14 +0200 Subject: [PATCH 104/110] fix: continue manual merge workflow after commit --- design/merge-view-architecture.md | 28 +++++++-- design/merge-view-dock-stability.md | 9 ++- design/merge-view-workflow.md | 53 ++++++++++++----- docs/changelog.md | 4 +- docs/clustering.md | 20 +++++-- docs/quickstart.md | 8 ++- phy/cluster/_selection.py | 27 +++++++++ phy/cluster/supervisor.py | 59 ++++++++++++++---- phy/cluster/tests/test_selection.py | 33 +++++++++++ phy/cluster/tests/test_supervisor.py | 89 ++++++++++++++++++++++++++-- 10 files changed, 283 insertions(+), 47 deletions(-) diff --git a/design/merge-view-architecture.md b/design/merge-view-architecture.md index 240e9566..a67740ca 100644 --- a/design/merge-view-architecture.md +++ b/design/merge-view-architecture.md @@ -152,9 +152,12 @@ The refactor should establish the following invariants: before and after states. 8. Cancellation restores the exact entry snapshot. 9. Undoing a Merge-mode merge restores the exact pre-commit workspace. -10. Workspace edits do not enter the curation undo stack. -11. Public selection and plugin APIs remain compatible. -12. Selection transitions operate on cluster IDs and do no work proportional to +10. A successful manual merge remains in Merge mode with its result as the sole + blue reference; its cancellation snapshot is the corresponding settled + Normal-mode result selection. +11. Workspace edits do not enter the curation undo stack. +12. Public selection and plugin APIs remain compatible. +13. Selection transitions operate on cluster IDs and do no work proportional to every spike. ## 4. Proposed domain model @@ -210,6 +213,8 @@ class MergeSession: reference_id: int ordered_ids: tuple[int, ...] entry_snapshot: NormalWorkflowSnapshot + proposition_id: str | None = None + is_post_merge: bool = False ``` `ordered_ids[0]` is always `reference_id`. The reference cannot be removed or @@ -221,6 +226,13 @@ not arbitrary application state. It includes selections, reference, presentation order, and any table filter, sort, scroll, or navigation state that entering or editing Merge mode changes. +`is_post_merge` distinguishes the automatically retained singleton workspace +from a manually entered, uncommitted workspace. This allows `Undo` to target the +commit directly without allowing a fresh temporary workspace to undo an older +curation action. Workspace edits preserve this marker; exiting and manually +re-entering Merge mode clears it. Proposition workspaces use their own provenance +and automatic-advancement contract and are never post-merge continuations. + ### 4.4 Selection change ```python @@ -402,11 +414,17 @@ For a Merge-mode merge: 1. capture the complete pre-commit Merge state; 2. execute the clustering merge; -3. capture the resulting Normal-mode state; +3. capture the resulting Normal-mode state and wrap it as the entry snapshot of + a singleton post-merge continuation workspace; 4. store both on the global action entry; 5. on undo, undo controllers and restore `selection_before` transactionally; and 6. on redo, redo controllers and restore `selection_after` transactionally. +The continuation keeps Merge View visible, makes the result the sole staged blue +reference, clears Similarity selection, and recomputes Similarity rows. Exiting +with `V` restores its settled Normal-mode result snapshot. Group and metadata +actions remain disabled until that exit. + The existing `request_undo_state` mechanism may be used as a compatibility step, but the final ownership of Merge workflow context belongs to the global curation action, not the `Clustering` model. @@ -529,6 +547,8 @@ The initial Merge-mode action policy is: - reject unsafe direct or plugin calls explicitly without partially mutating the workspace; - do not let an uncommitted Merge session undo an earlier curation action; +- keep a successful manual merge in a marked singleton continuation workspace + whose Undo action targets that commit directly; - after undoing a Merge-mode merge, allow redo to reapply it; and - truncate that redo branch normally if the restored workspace is edited and a different curation action is committed. diff --git a/design/merge-view-dock-stability.md b/design/merge-view-dock-stability.md index 492e8c59..47bb6414 100644 --- a/design/merge-view-dock-stability.md +++ b/design/merge-view-dock-stability.md @@ -104,7 +104,9 @@ Automatic advancement after a successful proposition merge is slightly different because the clustering has changed. It should construct the settled post-merge Normal state, use that as the next proposition's entry snapshot, and then project the next Merge workspace without hiding or recreating the dock. -Failed and manual merges retain their existing no-advancement behavior. +Failed merges retain their workspace unchanged. Successful manual merges do not +advance proposition review; they reuse the dock for a singleton continuation +workspace containing the merged result as its blue reference. Reject-and-advance and shortcut navigation use the same in-place replacement path. Selecting a nonactionable proposition still cancels to Normal mode and @@ -201,8 +203,9 @@ intermittent Qt shutdown crash. - `P1 -> P2` preserves `id(merge_view)` and `id(merge_view.dock)`. - Manual Merge to proposition review preserves those identities. - Shortcut navigation and reject-and-advance do not hide or recreate the dock. -- Successful auto-advance updates the existing view; failed and manual merges - do not advance. +- Successful auto-advance updates the existing view; successful manual merges + project the result into the same view without advancing, and failed merges + leave it unchanged. - All unrelated dock geometries remain unchanged across proposition switches. - Cancel/hide/reopen restores the Merge dock area and docked extent. - A floating Merge dock retains its exact position and size. diff --git a/design/merge-view-workflow.md b/design/merge-view-workflow.md index 13431783..6af91ab7 100644 --- a/design/merge-view-workflow.md +++ b/design/merge-view-workflow.md @@ -147,31 +147,48 @@ GUI reports that another candidate is required. After a successful merge: -- Merge mode ends; -- Merge View is cleared and hidden while its dataset-scoped dock is retained; -- Cluster View is re-enabled; -- the new merged cluster becomes the blue Cluster View selection; and -- Similarity View is recomputed for the new cluster using the normal post-merge - workflow. +- Merge mode remains active; +- Merge View replaces the committed inputs with the new merged cluster as its + sole staged row and blue reference; +- Similarity View clears its selection and is recomputed for the new cluster; +- the curator may stage or select more candidates and press `G` again; and +- the dock control changes from **Cancel Merge Mode** to **Exit Merge Mode**; + `V`, that control, or closing Merge View exits to Normal mode with the merged + cluster selected in Cluster View. + +Group and metadata changes remain unavailable while Merge mode is active. The +curator must exit with `V`, **Exit Merge Mode**, or the Merge View close control +before classifying the merged cluster as `good`, `mua`, `noise`, or another +quality. + +Successful proposition merges keep their separate review contract: they open +the next pending proposition when one exists, rather than pausing on the merged +result. If the merge fails, the complete Merge-mode state remains unchanged. -## Cancelling Merge mode +## Cancelling or exiting Merge mode -All of the following cancel Merge mode: +All of the following leave Merge mode: - pressing `V` while Merge mode is active; -- activating a prominent **Cancel Merge Mode** control; or +- activating the prominent **Cancel Merge Mode** control, renamed **Exit Merge + Mode** after a successful manual commit; or - closing Merge View. -Cancellation performs no clustering action. It restores the exact snapshot from -immediately before Merge mode was entered, regardless of additions, removals, or -reordering performed in Merge mode. In other words: +Cancellation performs no clustering action. Before the first commit, it restores +the exact snapshot from immediately before Merge mode was entered, regardless of +additions, removals, or reordering performed in Merge mode. In other words: ```text state A -> enter Merge mode -> edit workspace -> cancel -> state A ``` +After a successful manual merge, the continuation workspace receives a new +Normal-mode entry snapshot containing the merged cluster. Cancelling that +workspace therefore exits with the committed merged cluster selected; it never +tries to restore source clusters that no longer exist. + Closing Merge View must visibly communicate that it cancels the mode. Merge mode must also be unmistakable while active: Merge View is labelled **MERGE MODE**, Cluster View is dimmed or overlaid with an explanation, and the status area shows @@ -195,8 +212,12 @@ before `G`, including: Undoing that merge restores both the original clusters and the complete Merge workspace as it existed immediately before `G`. Redoing it reapplies the merge -and exits Merge mode again. This special restoration applies only to merges -initiated from Merge mode; ordinary merge undo behavior remains unchanged. +and restores the singleton post-merge continuation workspace. `Undo` is +available in that continuation workspace and targets the committed merge +directly. A manually entered workspace that has not committed a merge still +cannot undo an earlier curation action. This special restoration applies only +to merges initiated from Merge mode; ordinary merge undo behavior remains +unchanged. Workspace transfers and reordering are temporary UI operations and do not create entries in the clustering undo stack. @@ -210,10 +231,10 @@ entries in the clustering undo stack. | Merge | Ctrl+right-click removable Merge row | Transfer candidate to Similarity | | Merge | Ctrl+Space | Select the next Similarity candidates | | Merge | Backspace | Clear only the Similarity selection | -| Merge | `G` | Merge Merge contents plus selected Similarity candidates | +| Merge | `G` | Commit the selection and continue with its result as the blue Merge reference | | Merge | `V`, Cancel, or close Merge View | Restore the entry snapshot exactly | | After Merge-mode merge | Undo | Restore clusters and pre-commit Merge workspace | -| Restored after undo | Redo | Reapply merge and return to normal mode | +| Restored after undo | Redo | Reapply merge and restore the singleton continuation workspace | ## Extension diff --git a/docs/changelog.md b/docs/changelog.md index 01a8db39..e5851076 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -40,7 +40,9 @@ behavior they verify rather than listed separately. size; proposition navigation updates that dock without moving neighboring views. The dimmed Cluster View remains scrollable. Scientific views follow Merge View order and then selected Similarity rows in visible table order. Cancellation restores the entry state, and - undo restores the full pre-merge workspace. + undo restores the full pre-merge workspace. After a successful manual merge, + the result remains as the sole blue Merge View reference for inspection or a + further merge; press `V` to return to Normal mode before assigning quality. - Review AIND/SpikeInterface format-version 2 merge propositions from dataset-local `curation.json` in a persistent **Merge Propositions** view. Its compact rows have no action buttons and carry stable source-order display diff --git a/docs/clustering.md b/docs/clustering.md index c259007e..b843811a 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -31,12 +31,20 @@ order-dependent views, while each cluster keeps the same color across tables and for the entire Merge session. Press `Backspace` to clear only the Similarity View selection when the merge should contain only the staged rows. -Press `G` to commit the staged clusters plus the selected Similarity candidates. Press `V` again, -use **Cancel Merge Mode**, or close Merge View to cancel and restore the exact state from before -entry. Undoing a committed Merge-mode merge restores the complete workspace as it appeared just -before `G`; Redo reapplies the merge and returns to the normal workflow. The Merge View dock is -reused throughout the dataset session: proposition changes do not move it, and reopening restores -its previous docked extent or floating position and size without resetting neighboring docks. +Press `G` to commit the staged clusters plus the selected Similarity candidates. The resulting +unit remains in Merge View as the sole staged row and blue reference, with Similarity View +recomputed around it. Add more candidates to continue merging, or press `V` to return to Normal +mode with the result selected before assigning its quality. Group and metadata changes remain +unavailable in Merge mode. + +Before a merge is committed, pressing `V`, using **Cancel Merge Mode**, or closing Merge View +cancels and restores the exact state from before entry. After a commit, the dock control becomes +**Exit Merge Mode** and those controls exit with the latest merged result selected. Undoing a +committed Merge-mode merge directly restores the complete workspace as it appeared just before +`G`; Redo reapplies the merge and restores its singleton continuation workspace. The Merge View +dock is reused throughout the dataset session: +proposition changes do not move it, and reopening restores its previous docked extent or floating +position and size without resetting neighboring docks. ### Reviewing merge propositions diff --git a/docs/quickstart.md b/docs/quickstart.md index d115afa1..b18bd94a 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -117,8 +117,12 @@ clusters and press `G` to merge. phy gives the result a new cluster ID. Press For a longer comparison, press `V` first. Merge View keeps the candidates staged while you continue exploring Similarity View. Its status shows exactly how many clusters `G` will merge. -Press `V` again or close Merge View to cancel without changing the clustering. Reopening uses the -same dock and restores its previous placement and size. +After `G`, the merged result stays in Merge View as the blue reference so you can inspect it or +add candidates for another merge. Press `V` to return to Normal mode before assigning the result +to `good`, `mua`, or `noise`. Before the first commit, `V` or closing Merge View cancels without +changing the clustering; afterward it exits with the latest merged result selected. Reopening uses +the same dock and restores its previous placement and size. `Ctrl+Z` directly undoes the latest +commit and restores its pre-merge workspace. If the Template GUI dataset includes an AIND/SpikeInterface format-version 2 `curation.json` with merge suggestions, use the persistent **Merge Propositions** diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index ee17f51f..facfaf7f 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -43,6 +43,7 @@ class MergeSession: ordered_ids: tuple[int, ...] entry_snapshot: NormalWorkflowSnapshot proposition_id: str | None = None + is_post_merge: bool = False def __post_init__(self): ordered = _as_unique_ids(self.ordered_ids) @@ -50,6 +51,8 @@ def __post_init__(self): raise ValueError('The merge reference must be the first staged cluster.') if self.proposition_id is not None and not self.proposition_id: raise ValueError('The merge proposition ID cannot be empty.') + if self.proposition_id is not None and self.is_post_merge: + raise ValueError('A proposition workspace cannot be a post-merge continuation.') object.__setattr__(self, 'ordered_ids', ordered) @@ -350,6 +353,26 @@ def enter_merge_proposition(self, proposition_id, ordered_ids, workflow_context= ) ) + def continue_after_merge(self, cluster_id, workflow_context=None): + """Continue manual Merge mode with a committed result as the new reference.""" + self._require_merge_mode() + if self._state.merge.proposition_id is not None: + raise ValueError('Proposition merges advance through the proposition workflow.') + normal = CurationSelectionState(cluster_ids=(cluster_id,)) + merge = MergeSession( + cluster_id, + (cluster_id,), + NormalWorkflowSnapshot(normal, workflow_context), + is_post_merge=True, + ) + return self._apply( + CurationSelectionState( + mode=WorkflowMode.MERGE, + reference_id=cluster_id, + merge=merge, + ) + ) + def switch_merge_proposition(self, proposition_id, ordered_ids): """Replace the active Merge workspace while preserving its Normal entry snapshot.""" self._require_merge_mode() @@ -395,6 +418,7 @@ def add_to_merge(self, cluster_ids, insertion=None): tuple(ids), current.merge.entry_snapshot, proposition_id=current.merge.proposition_id, + is_post_merge=current.merge.is_post_merge, ) similar = tuple(cluster_id for cluster_id in current.similar_ids if cluster_id not in new) effective = _ordered_union(merge.ordered_ids, similar) @@ -421,6 +445,7 @@ def remove_from_merge(self, cluster_ids): tuple(i for i in current.merge_ids if i not in removed), current.merge.entry_snapshot, proposition_id=current.merge.proposition_id, + is_post_merge=current.merge.is_post_merge, ) similar = _ordered_union(current.similar_ids, removed) effective = _ordered_union(merge.ordered_ids, similar) @@ -452,6 +477,7 @@ def deselect_from_merge(self, cluster_ids): merge_ids, current.merge.entry_snapshot, proposition_id=current.merge.proposition_id, + is_post_merge=current.merge.is_post_merge, ) slots = list(current.color_slots) if reference != current.reference_id: @@ -484,6 +510,7 @@ def reorder_merge(self, cluster_ids, insertion): tuple(remain), current.merge.entry_snapshot, proposition_id=current.merge.proposition_id, + is_post_merge=current.merge.is_post_merge, ) return self._apply( CurationSelectionState( diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 4d4f9e8d..56f0db80 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -191,9 +191,17 @@ def enqueue_after(self, task, output): def _after_merge(self, task, output): """Tasks that should follow a merge.""" + selection_before = task.selection_before + if ( + selection_before is not None + and selection_before.is_merge_mode + and selection_before.merge.proposition_id is None + ): + self.supervisor._continue_after_merge(output) + return self.supervisor._select_after_merge( output, - task.selection_before, + selection_before, auto_select=self.auto_select_after_action, next_similar=task.next_similar_before, ) @@ -1316,6 +1324,11 @@ def _project_merge_view(self): self.merge_view._reference_id = state.reference_id data = [self.get_cluster_info(cluster_id) for cluster_id in state.merge_ids] self.merge_view.set_merge_ids(state.merge_ids, data, state.color_indices) + exit_button = self.merge_view.dock.get_widget('cancel_merge_mode') + if exit_button is not None: + exit_button.setText( + 'Exit Merge Mode' if state.merge.is_post_merge else 'Cancel Merge Mode' + ) self.merge_view.dock.set_status(self._merge_status_text()) def _merge_status_text(self): @@ -1469,11 +1482,13 @@ def _set_merge_mode_ui(self, active): if self.actions is not None: can_redo_merge = False can_undo_proposition = False + can_undo_post_merge = False if active: can_undo_proposition = ( self._active_merge_proposition_key() is not None and self._global_history.current_position > 0 ) + can_undo_post_merge = self._can_undo_post_merge_workspace() index = self._global_history.current_position + 1 history = self._global_history._history can_redo_merge = index < len(history) and self._is_merge_history_context( @@ -1483,7 +1498,7 @@ def _set_merge_mode_ui(self, active): enabled = ( not active or name == 'merge' - or (name == 'undo' and can_undo_proposition) + or (name == 'undo' and (can_undo_proposition or can_undo_post_merge)) or (name == 'redo' and can_redo_merge) ) (self.actions.enable if enabled else self.actions.disable)(name) @@ -1555,12 +1570,10 @@ def _restore_history_context(self, selection, workflow_context, direction): """Restore a curation snapshot after the associated data undo or redo.""" if selection.is_merge_mode: self._ensure_merge_view(selection) - self._set_merge_mode_ui(True) - elif not selection.is_merge_mode: - self._set_merge_mode_ui(False) change = self.selection.restore(selection) self._apply_selection_change(change, refresh_similarity=False, sync_presentation=False) if selection.is_merge_mode: + self._set_merge_mode_ui(True) context = ( workflow_context.get('tables') if self._is_merge_history_context(workflow_context) @@ -1569,6 +1582,7 @@ def _restore_history_context(self, selection, workflow_context, direction): self._restore_workflow_context(context) self._show_merge_view(selection) else: + self._set_merge_mode_ui(False) self._hide_merge_view() self._refresh_propositions() @@ -1597,6 +1611,11 @@ def _select_after_merge( change = self.selection.set_normal_selection((up.added[0],), similar_ids) self._apply_selection_change(change) + def _continue_after_merge(self, up): + """Keep a manual Merge workspace open with its committed result as reference.""" + change = self.selection.continue_after_merge(up.added[0], self._workflow_context()) + self._apply_selection_change(change) + def _select_after_split(self, up): """Select all clusters created by a split as one settled transition.""" change = self.selection.set_normal_selection(tuple(up.added)) @@ -1994,6 +2013,9 @@ def n_spikes(self, cluster_id): def merge(self, cluster_ids=None, to=None): """Merge the selected clusters.""" merge_mode = self.selection.state.is_merge_mode + task_logger_processing = bool( + getattr(getattr(self, 'task_logger', None), '_processing', False) + ) if merge_mode and cluster_ids is not None and set(cluster_ids) != set(self.selected): logger.warning('An explicit merge cannot differ from the active Merge workspace.') return @@ -2027,8 +2049,11 @@ def merge(self, cluster_ids=None, to=None): if table is not None: stack.enter_context(table.batch_update()) out = self.clustering.merge(cluster_ids, to=to) - if not getattr(getattr(self, 'task_logger', None), '_processing', False): - self._select_after_merge(out, selection_before) + if not task_logger_processing: + if merge_mode and proposition_id is None: + self._continue_after_merge(out) + else: + self._select_after_merge(out, selection_before) controllers = [self.clustering] if proposition_id is not None: self.merge_propositions.accept(proposition_id, tuple(cluster_ids), int(out.added[0])) @@ -2044,9 +2069,6 @@ def merge(self, cluster_ids=None, to=None): self._set_merge_mode_ui(False) self._hide_merge_view() self.merge_propositions_view.select_key(proposition_id) - elif merge_mode: - self._set_merge_mode_ui(False) - self._hide_merge_view() self._global_history.action( *controllers, description='merge', @@ -2209,6 +2231,17 @@ def _merge_workflow_history_context(self): return None return {'mode': 'merge', 'tables': self._workflow_context()} + def _can_undo_post_merge_workspace(self): + """Whether the active manual workspace continues the current merge action.""" + state = self.selection.state + return bool( + state.is_merge_mode + and state.merge is not None + and state.merge.is_post_merge + and self._global_history.current_position > 0 + and self._global_history.current_item.description == 'merge' + ) + def _pending_proposition_relative_to(self, key, direction, ordered_keys=None): """Find another pending proposition in visible order, wrapping once.""" view = self.merge_propositions_view @@ -2496,7 +2529,11 @@ def _update_save_feedback(self, saved=False): def undo(self): """Undo the last action.""" - if self.selection.state.is_merge_mode and self._active_merge_proposition_key() is None: + if ( + self.selection.state.is_merge_mode + and self._active_merge_proposition_key() is None + and not self._can_undo_post_merge_workspace() + ): logger.warning('Undo is unavailable while a Merge workspace is active.') return # Selection-only exploration does not create history entries. Preserve the exact diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index a96e438f..220392d0 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -305,6 +305,39 @@ def test_enter_merge_proposition_validates_identity_and_membership(): controller.enter_merge_proposition('p', (1, 1)) +def test_continue_after_merge_uses_result_as_singleton_reference_and_cancel_target(): + controller = CurationSelectionController( + CurationSelectionState(cluster_ids=(1,), similar_ids=(2,)) + ) + controller.enter_merge_mode() + context = {'similarity_filter': 'similarity > .5'} + + change = controller.continue_after_merge(3, context) + + assert change.after.is_merge_mode + assert change.after.merge_ids == (3,) + assert change.after.reference_id == 3 + assert change.after.color_slots == (3,) + assert change.after.merge.is_post_merge + assert change.after.merge.entry_snapshot.workflow_context is context + assert controller.cancel_merge_mode().after == CurationSelectionState(cluster_ids=(3,)) + + +def test_post_merge_identity_survives_workspace_edits_but_not_manual_reentry(): + controller = CurationSelectionController(CurationSelectionState(cluster_ids=(1, 2))) + controller.enter_merge_mode() + controller.continue_after_merge(3) + + controller.add_to_merge((4,)) + assert controller.state.merge.is_post_merge + controller.remove_from_merge((4,)) + assert controller.state.merge.is_post_merge + + controller.cancel_merge_mode() + controller.enter_merge_mode() + assert not controller.state.merge.is_post_merge + + def test_switch_merge_proposition_preserves_original_normal_entry_snapshot(): initial = CurationSelectionState(cluster_ids=(1,), similar_ids=(2,)) context = {'cluster_filter': 'group == good'} diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index e9e70ad5..1e6b8303 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -703,11 +703,19 @@ def test_merge_mode_merge_undo_redo_restores_workspace(supervisor): supervisor.block() merged_id = up.added[0] - assert not supervisor.selection.state.is_merge_mode + assert supervisor.selection.state.is_merge_mode + assert supervisor.selection.state.merge.is_post_merge + assert supervisor.selected_merge == [merged_id] + assert supervisor.selected_clusters == [] + assert supervisor.selected_similar == [] assert supervisor.selected == [merged_id] merge_view = supervisor.merge_view assert merge_view is not None - assert merge_view.dock.isHidden() + assert not merge_view.dock.isHidden() + assert merge_view.get_ids() == [merged_id] + assert merge_view._selected_color_index(merged_id) == 0 + assert merge_view.dock.get_widget('cancel_merge_mode').text() == 'Exit Merge Mode' + assert supervisor.actions.get('undo').isEnabled() assert set(up.deleted) == {30, 20, candidate} assignments_after = supervisor.clustering.spike_clusters.copy() events = [] @@ -725,6 +733,7 @@ def on_select(sender, cluster_ids): assert supervisor.selected_similar == [candidate] assert supervisor.merge_view is merge_view assert not merge_view.dock.isHidden() + assert merge_view.dock.get_widget('cancel_merge_mode').text() == 'Cancel Merge Mode' assert supervisor.actions.get('redo').isEnabled() assert events[-1] == list(merge_before.presentation_order) assert dict(supervisor.selection_color_indices) == dict(merge_before.color_indices) @@ -741,14 +750,86 @@ def on_select(sender, cluster_ids): supervisor.block() ae(supervisor.clustering.spike_clusters, assignments_after) - assert not supervisor.selection.state.is_merge_mode + assert supervisor.selection.state.is_merge_mode + assert supervisor.selection.state.merge.is_post_merge + assert supervisor.selected_merge == [merged_id] assert supervisor.selected == [merged_id] assert supervisor.merge_view is merge_view - assert merge_view.dock.isHidden() + assert not merge_view.dock.isHidden() + assert merge_view.dock.get_widget('cancel_merge_mode').text() == 'Exit Merge Mode' + assert supervisor.actions.get('undo').isEnabled() assert events[-1] == [merged_id] unconnect(on_select) +def test_post_merge_workspace_requires_exit_before_quality_assignment(supervisor): + _select(supervisor, [30], [20]) + supervisor.toggle_merge_mode() + merged_id = supervisor.merge().added[0] + supervisor.block() + group = supervisor.cluster_meta.get('group', merged_id) + + supervisor.move('good', 'all') + + assert supervisor.selection.state.is_merge_mode + assert supervisor.cluster_meta.get('group', merged_id) == group + + supervisor.toggle_merge_mode() + supervisor.move('good', 'all') + + assert not supervisor.selection.state.is_merge_mode + assert supervisor.selected == [merged_id] + assert supervisor.cluster_meta.get('group', merged_id) == 'good' + + +def test_post_merge_workspace_supports_chained_merge_and_direct_undo(supervisor): + _select(supervisor, [30], [20]) + supervisor.toggle_merge_mode() + first_id = supervisor.merge().added[0] + supervisor.block() + candidate = supervisor.similarity_view.get_ids()[0] + supervisor.similarity_view.select([candidate]) + supervisor.block() + before_second = supervisor.selection.snapshot() + + second = supervisor.merge() + supervisor.block() + + second_id = second.added[0] + assert set(second.deleted) == {first_id, candidate} + assert supervisor.selected_merge == [second_id] + assert supervisor.selection.state.merge.is_post_merge + + supervisor.undo() + supervisor.block() + + assert supervisor.selection.state == before_second + assert supervisor.selected_merge == [first_id] + assert supervisor.selected_similar == [candidate] + + +def test_post_merge_workspace_supports_action_dragged_chained_merge(supervisor): + _select(supervisor, [30], [20]) + supervisor.toggle_merge_mode() + supervisor.action_creator.edit_actions.get('merge').trigger() + supervisor.block() + first_id = supervisor.selected_merge[0] + assert supervisor.selection.state.is_merge_mode + candidate = supervisor.similarity_view.get_ids()[0] + + supervisor.merge_view.emit_cluster_drop(supervisor.similarity_view, (candidate,), 1) + + assert supervisor.selected_merge == [first_id, candidate] + assert supervisor.selected_similar == [] + + supervisor.action_creator.edit_actions.get('merge').trigger() + supervisor.block() + + assert first_id not in supervisor.clustering.cluster_ids + assert candidate not in supervisor.clustering.cluster_ids + assert len(supervisor.selected_merge) == 1 + + def test_uncommitted_merge_workspace_does_not_undo_prior_action(supervisor): _select(supervisor, [30], [20]) supervisor.merge() From d7ff0c5ee0762a04cea0b3451bfb4a3f7466a339 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sat, 8 Aug 2026 19:51:03 +0200 Subject: [PATCH 105/110] fix: address merge workflow tester feedback --- .github/workflows/ci.yml | 4 +- docs/changelog.md | 7 ++- docs/clustering.md | 15 +++--- docs/quickstart.md | 11 ++--- phy/apps/template/gui.py | 69 ++++++++++++++++++++++++++-- phy/apps/template/tests/test_gui.py | 37 +++++++++++++++ phy/cluster/_selection.py | 27 ----------- phy/cluster/supervisor.py | 39 ++++------------ phy/cluster/tests/test_selection.py | 33 ------------- phy/cluster/tests/test_supervisor.py | 48 ++++++++----------- 10 files changed, 149 insertions(+), 141 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac215c74..32c87dd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,10 +54,10 @@ jobs: - name: Check formatting with ruff run: uv run --frozen ruff format --check phy - # phy currently relies on SpikeSelector changes newer than phylib 2.7.0. + # phy currently relies on SpikeSelector and template-less waveform fixes newer than phylib 2.7.0. # Keep normal CI reproducible until those changes are included in a release. - name: Install tested phylib revision - run: uv pip install --python .venv "phylib @ git+https://github.com/cortex-lab/phylib.git@db10401589b47fd8d56f7c66531b0c22fc7f9e91" + run: uv pip install --python .venv "phylib @ git+https://github.com/cortex-lab/phylib.git@fc494f6ab9f03370c43e618d2ef9610c6781b0e6" - name: Test with pytest run: uv run --no-sync pytest --cov=phy --cov-report=xml phy diff --git a/docs/changelog.md b/docs/changelog.md index e5851076..005df9e3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -41,8 +41,8 @@ behavior they verify rather than listed separately. views. The dimmed Cluster View remains scrollable. Scientific views follow Merge View order and then selected Similarity rows in visible table order. Cancellation restores the entry state, and undo restores the full pre-merge workspace. After a successful manual merge, - the result remains as the sole blue Merge View reference for inspection or a - further merge; press `V` to return to Normal mode before assigning quality. + Merge mode closes and the result becomes the sole Cluster View selection for + quality assignment or explicit entry into another merge. - Review AIND/SpikeInterface format-version 2 merge propositions from dataset-local `curation.json` in a persistent **Merge Propositions** view. Its compact rows have no action buttons and carry stable source-order display @@ -80,6 +80,9 @@ behavior they verify rather than listed separately. ### Fixed +- Load and display stored spike-waveform subsets when waveform templates are absent, and derive + cluster-specific channel rankings from those waveforms so Waveform and Probe views do not fall + back to channel zero. - Keep the Merge Propositions table layout and scroll state stable while moving between pending propositions instead of rebuilding the full queue. - Release GUI, Supervisor, table, dock, and curation event callbacks when a diff --git a/docs/clustering.md b/docs/clustering.md index b843811a..f1adb5f8 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -32,17 +32,14 @@ for the entire Merge session. Press `Backspace` to clear only the Similarity Vie merge should contain only the staged rows. Press `G` to commit the staged clusters plus the selected Similarity candidates. The resulting -unit remains in Merge View as the sole staged row and blue reference, with Similarity View -recomputed around it. Add more candidates to continue merging, or press `V` to return to Normal -mode with the result selected before assigning its quality. Group and metadata changes remain -unavailable in Merge mode. +unit becomes the sole Cluster View selection, Merge mode closes, and Similarity View is recomputed +around the result. Assign its quality immediately, or press `V` again to stage it for another +merge. Group and metadata changes remain unavailable only while Merge mode is active. Before a merge is committed, pressing `V`, using **Cancel Merge Mode**, or closing Merge View -cancels and restores the exact state from before entry. After a commit, the dock control becomes -**Exit Merge Mode** and those controls exit with the latest merged result selected. Undoing a -committed Merge-mode merge directly restores the complete workspace as it appeared just before -`G`; Redo reapplies the merge and restores its singleton continuation workspace. The Merge View -dock is reused throughout the dataset session: +cancels and restores the exact state from before entry. Undoing a committed Merge-mode merge +directly restores the complete workspace as it appeared just before `G`; Redo reapplies the merge +and returns to Normal mode with the result selected. The Merge View dock is reused throughout the dataset session: proposition changes do not move it, and reopening restores its previous docked extent or floating position and size without resetting neighboring docks. diff --git a/docs/quickstart.md b/docs/quickstart.md index b18bd94a..7cb4d71a 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -117,12 +117,11 @@ clusters and press `G` to merge. phy gives the result a new cluster ID. Press For a longer comparison, press `V` first. Merge View keeps the candidates staged while you continue exploring Similarity View. Its status shows exactly how many clusters `G` will merge. -After `G`, the merged result stays in Merge View as the blue reference so you can inspect it or -add candidates for another merge. Press `V` to return to Normal mode before assigning the result -to `good`, `mua`, or `noise`. Before the first commit, `V` or closing Merge View cancels without -changing the clustering; afterward it exits with the latest merged result selected. Reopening uses -the same dock and restores its previous placement and size. `Ctrl+Z` directly undoes the latest -commit and restores its pre-merge workspace. +After `G`, Merge mode closes and the merged result becomes the sole Cluster View selection, ready +to assign to `good`, `mua`, or `noise`. Press `V` again if it needs another merge. Before the +commit, `V` or closing Merge View cancels without changing the clustering. Reopening uses the same +dock and restores its previous placement and size. `Ctrl+Z` directly undoes the latest commit and +restores its pre-merge workspace. If the Template GUI dataset includes an AIND/SpikeInterface format-version 2 `curation.json` with merge suggestions, use the persistent **Merge Propositions** diff --git a/phy/apps/template/gui.py b/phy/apps/template/gui.py index 5e6abfbe..bda606c4 100644 --- a/phy/apps/template/gui.py +++ b/phy/apps/template/gui.py @@ -11,7 +11,7 @@ import numpy as np from phylib import _add_log_file -from phylib.io.model import TemplateModel, load_model +from phylib.io.model import TemplateModel, get_closest_channels, load_model from phylib.io.traces import MtscompEphysReader from phylib.utils import Bunch, connect @@ -146,11 +146,68 @@ def _set_view_creator(self): # Public methods # ------------------------------------------------------------------------- + def _get_stored_waveform_channel_amplitudes(self, cluster_id): + """Estimate cluster channel amplitudes from an optional stored waveform subset.""" + stored = self.model.spike_waveforms + if stored is None: + return None + + rows_by_cluster = getattr(self, '_stored_waveform_rows_by_cluster', None) + if rows_by_cluster is None or cluster_id not in rows_by_cluster: + subset_clusters = self.supervisor.clustering.spike_clusters[stored.spike_ids] + order = np.argsort(subset_clusters, kind='stable') + cluster_ids, starts = np.unique(subset_clusters[order], return_index=True) + rows_by_cluster = { + int(current_cluster): rows + for current_cluster, rows in zip(cluster_ids, np.split(order, starts[1:])) + } + self._stored_waveform_rows_by_cluster = rows_by_cluster + + rows = rows_by_cluster.get(cluster_id) + if rows is None or len(rows) == 0: + return None + waveforms = stored.waveforms[rows] + channel_ids = stored.spike_channels[rows] + if waveforms.ndim != 3 or channel_ids.shape != waveforms.shape[::2]: + logger.warning('Stored spike waveforms have inconsistent waveform/channel shapes.') + return None + + local_amplitudes = np.ptp(waveforms, axis=1) + valid = ( + (channel_ids >= 0) + & (channel_ids < self.model.n_channels) + & np.isfinite(local_amplitudes) + ) + if not np.any(valid): + return None + sums = np.zeros(self.model.n_channels, dtype=np.float64) + counts = np.zeros(self.model.n_channels, dtype=np.int64) + np.add.at(sums, channel_ids[valid], local_amplitudes[valid]) + np.add.at(counts, channel_ids[valid], 1) + populated = np.flatnonzero(counts) + amplitudes = np.zeros(self.model.n_channels, dtype=np.float64) + amplitudes[populated] = sums[populated] / counts[populated] + + best_channel = int(populated[np.argmax(amplitudes[populated])]) + closest = get_closest_channels( + self.model.channel_positions, best_channel, self.model.n_closest_channels + ) + selected = np.intersect1d(populated, closest) + if self.model.channel_shanks is not None: + selected = selected[ + self.model.channel_shanks[selected] == self.model.channel_shanks[best_channel] + ] + selected = selected[np.argsort(amplitudes[selected])[::-1]] + return selected, amplitudes[selected] + def get_best_channels(self, cluster_id): """Return the best channels of a given cluster.""" template_id = self.get_template_for_cluster(cluster_id) template = self.model.get_template(template_id) - if not template: # pragma: no cover + if not template: + stored = self._get_stored_waveform_channel_amplitudes(cluster_id) + if stored is not None: + return stored[0] return [0] return template.channel_ids @@ -158,7 +215,13 @@ def get_channel_amplitudes(self, cluster_id): """Return the channel amplitudes of the best channels of a given cluster.""" template_id = self.get_template_for_cluster(cluster_id) template = self.model.get_template(template_id, amplitude_threshold=0.5) - if not template: # pragma: no cover + if not template: + stored = self._get_stored_waveform_channel_amplitudes(cluster_id) + if stored is not None: + channel_ids, amplitude = stored + m, M = amplitude.min(), amplitude.max() + d = (M - m) if m < M else 1.0 + return channel_ids, (amplitude - m) / d return [0], [0.0] m, M = template.amplitude.min(), template.amplitude.max() d = (M - m) if m < M else 1.0 diff --git a/phy/apps/template/tests/test_gui.py b/phy/apps/template/tests/test_gui.py index a338f543..05d2525e 100644 --- a/phy/apps/template/tests/test_gui.py +++ b/phy/apps/template/tests/test_gui.py @@ -58,6 +58,43 @@ def test_template_controller_close(tempdir): assert all(arr._mmap.closed for arr in mmaps) +def test_template_controller_without_templates_uses_stored_waveform_channels(qtbot, tempdir): + dataset = _make_dataset(tempdir, param='dense', has_spike_attributes=False) + model = load_model(dataset) + spike_clusters = model.spike_clusters.copy() + cluster_ids = np.unique(spike_clusters)[:2] + spike_ids = np.concatenate( + [np.flatnonzero(spike_clusters == cluster_id)[:2] for cluster_id in cluster_ids] + ) + model.close() + + n_samples = 20 + channel_ids = np.array(((2, 3), (2, 3), (7, 6), (7, 6)), dtype=np.int32) + waveforms = np.zeros((4, n_samples, 2), dtype=np.float32) + ramp = np.linspace(-1, 1, n_samples) + waveforms[:, :, 0] = 5 * ramp + waveforms[:, :, 1] = 2 * ramp + np.save(tempdir / '_phy_spikes_subset.waveforms.npy', waveforms) + np.save(tempdir / '_phy_spikes_subset.channels.npy', channel_ids) + np.save(tempdir / '_phy_spikes_subset.spikes.npy', spike_ids) + (tempdir / 'templates.npy').unlink() + + controller = _template_controller(tempdir, dataset.parent) + probe = controller.create_probe_view() + try: + assert controller.model.n_samples_waveforms == n_samples + assert list(controller.get_best_channels(cluster_ids[0])) == [2, 3] + assert list(controller.get_best_channels(cluster_ids[1])) == [7, 6] + assert controller._get_waveforms(cluster_ids[0]).data.shape == (2, n_samples, 2) + + first_positions, _ = probe._get_clu_positions([cluster_ids[0]]) + second_positions, _ = probe._get_clu_positions([cluster_ids[1]]) + assert not np.array_equal(first_positions, second_positions) + finally: + probe.canvas.close() + controller.close() + + class TemplateControllerTests(GlobalViewsTests, BaseControllerTests): """Base template controller tests.""" diff --git a/phy/cluster/_selection.py b/phy/cluster/_selection.py index facfaf7f..ee17f51f 100644 --- a/phy/cluster/_selection.py +++ b/phy/cluster/_selection.py @@ -43,7 +43,6 @@ class MergeSession: ordered_ids: tuple[int, ...] entry_snapshot: NormalWorkflowSnapshot proposition_id: str | None = None - is_post_merge: bool = False def __post_init__(self): ordered = _as_unique_ids(self.ordered_ids) @@ -51,8 +50,6 @@ def __post_init__(self): raise ValueError('The merge reference must be the first staged cluster.') if self.proposition_id is not None and not self.proposition_id: raise ValueError('The merge proposition ID cannot be empty.') - if self.proposition_id is not None and self.is_post_merge: - raise ValueError('A proposition workspace cannot be a post-merge continuation.') object.__setattr__(self, 'ordered_ids', ordered) @@ -353,26 +350,6 @@ def enter_merge_proposition(self, proposition_id, ordered_ids, workflow_context= ) ) - def continue_after_merge(self, cluster_id, workflow_context=None): - """Continue manual Merge mode with a committed result as the new reference.""" - self._require_merge_mode() - if self._state.merge.proposition_id is not None: - raise ValueError('Proposition merges advance through the proposition workflow.') - normal = CurationSelectionState(cluster_ids=(cluster_id,)) - merge = MergeSession( - cluster_id, - (cluster_id,), - NormalWorkflowSnapshot(normal, workflow_context), - is_post_merge=True, - ) - return self._apply( - CurationSelectionState( - mode=WorkflowMode.MERGE, - reference_id=cluster_id, - merge=merge, - ) - ) - def switch_merge_proposition(self, proposition_id, ordered_ids): """Replace the active Merge workspace while preserving its Normal entry snapshot.""" self._require_merge_mode() @@ -418,7 +395,6 @@ def add_to_merge(self, cluster_ids, insertion=None): tuple(ids), current.merge.entry_snapshot, proposition_id=current.merge.proposition_id, - is_post_merge=current.merge.is_post_merge, ) similar = tuple(cluster_id for cluster_id in current.similar_ids if cluster_id not in new) effective = _ordered_union(merge.ordered_ids, similar) @@ -445,7 +421,6 @@ def remove_from_merge(self, cluster_ids): tuple(i for i in current.merge_ids if i not in removed), current.merge.entry_snapshot, proposition_id=current.merge.proposition_id, - is_post_merge=current.merge.is_post_merge, ) similar = _ordered_union(current.similar_ids, removed) effective = _ordered_union(merge.ordered_ids, similar) @@ -477,7 +452,6 @@ def deselect_from_merge(self, cluster_ids): merge_ids, current.merge.entry_snapshot, proposition_id=current.merge.proposition_id, - is_post_merge=current.merge.is_post_merge, ) slots = list(current.color_slots) if reference != current.reference_id: @@ -510,7 +484,6 @@ def reorder_merge(self, cluster_ids, insertion): tuple(remain), current.merge.entry_snapshot, proposition_id=current.merge.proposition_id, - is_post_merge=current.merge.is_post_merge, ) return self._apply( CurationSelectionState( diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 56f0db80..aeef4b49 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -197,7 +197,7 @@ def _after_merge(self, task, output): and selection_before.is_merge_mode and selection_before.merge.proposition_id is None ): - self.supervisor._continue_after_merge(output) + self.supervisor._finish_manual_merge(output, selection_before) return self.supervisor._select_after_merge( output, @@ -1324,11 +1324,6 @@ def _project_merge_view(self): self.merge_view._reference_id = state.reference_id data = [self.get_cluster_info(cluster_id) for cluster_id in state.merge_ids] self.merge_view.set_merge_ids(state.merge_ids, data, state.color_indices) - exit_button = self.merge_view.dock.get_widget('cancel_merge_mode') - if exit_button is not None: - exit_button.setText( - 'Exit Merge Mode' if state.merge.is_post_merge else 'Cancel Merge Mode' - ) self.merge_view.dock.set_status(self._merge_status_text()) def _merge_status_text(self): @@ -1482,13 +1477,11 @@ def _set_merge_mode_ui(self, active): if self.actions is not None: can_redo_merge = False can_undo_proposition = False - can_undo_post_merge = False if active: can_undo_proposition = ( self._active_merge_proposition_key() is not None and self._global_history.current_position > 0 ) - can_undo_post_merge = self._can_undo_post_merge_workspace() index = self._global_history.current_position + 1 history = self._global_history._history can_redo_merge = index < len(history) and self._is_merge_history_context( @@ -1498,7 +1491,7 @@ def _set_merge_mode_ui(self, active): enabled = ( not active or name == 'merge' - or (name == 'undo' and (can_undo_proposition or can_undo_post_merge)) + or (name == 'undo' and can_undo_proposition) or (name == 'redo' and can_redo_merge) ) (self.actions.enable if enabled else self.actions.disable)(name) @@ -1611,10 +1604,11 @@ def _select_after_merge( change = self.selection.set_normal_selection((up.added[0],), similar_ids) self._apply_selection_change(change) - def _continue_after_merge(self, up): - """Keep a manual Merge workspace open with its committed result as reference.""" - change = self.selection.continue_after_merge(up.added[0], self._workflow_context()) - self._apply_selection_change(change) + def _finish_manual_merge(self, up, selection_before): + """Return a committed manual merge to Normal mode with its result selected.""" + self._select_after_merge(up, selection_before) + self._set_merge_mode_ui(False) + self._hide_merge_view() def _select_after_split(self, up): """Select all clusters created by a split as one settled transition.""" @@ -2051,7 +2045,7 @@ def merge(self, cluster_ids=None, to=None): out = self.clustering.merge(cluster_ids, to=to) if not task_logger_processing: if merge_mode and proposition_id is None: - self._continue_after_merge(out) + self._finish_manual_merge(out, selection_before) else: self._select_after_merge(out, selection_before) controllers = [self.clustering] @@ -2231,17 +2225,6 @@ def _merge_workflow_history_context(self): return None return {'mode': 'merge', 'tables': self._workflow_context()} - def _can_undo_post_merge_workspace(self): - """Whether the active manual workspace continues the current merge action.""" - state = self.selection.state - return bool( - state.is_merge_mode - and state.merge is not None - and state.merge.is_post_merge - and self._global_history.current_position > 0 - and self._global_history.current_item.description == 'merge' - ) - def _pending_proposition_relative_to(self, key, direction, ordered_keys=None): """Find another pending proposition in visible order, wrapping once.""" view = self.merge_propositions_view @@ -2529,11 +2512,7 @@ def _update_save_feedback(self, saved=False): def undo(self): """Undo the last action.""" - if ( - self.selection.state.is_merge_mode - and self._active_merge_proposition_key() is None - and not self._can_undo_post_merge_workspace() - ): + if self.selection.state.is_merge_mode and self._active_merge_proposition_key() is None: logger.warning('Undo is unavailable while a Merge workspace is active.') return # Selection-only exploration does not create history entries. Preserve the exact diff --git a/phy/cluster/tests/test_selection.py b/phy/cluster/tests/test_selection.py index 220392d0..a96e438f 100644 --- a/phy/cluster/tests/test_selection.py +++ b/phy/cluster/tests/test_selection.py @@ -305,39 +305,6 @@ def test_enter_merge_proposition_validates_identity_and_membership(): controller.enter_merge_proposition('p', (1, 1)) -def test_continue_after_merge_uses_result_as_singleton_reference_and_cancel_target(): - controller = CurationSelectionController( - CurationSelectionState(cluster_ids=(1,), similar_ids=(2,)) - ) - controller.enter_merge_mode() - context = {'similarity_filter': 'similarity > .5'} - - change = controller.continue_after_merge(3, context) - - assert change.after.is_merge_mode - assert change.after.merge_ids == (3,) - assert change.after.reference_id == 3 - assert change.after.color_slots == (3,) - assert change.after.merge.is_post_merge - assert change.after.merge.entry_snapshot.workflow_context is context - assert controller.cancel_merge_mode().after == CurationSelectionState(cluster_ids=(3,)) - - -def test_post_merge_identity_survives_workspace_edits_but_not_manual_reentry(): - controller = CurationSelectionController(CurationSelectionState(cluster_ids=(1, 2))) - controller.enter_merge_mode() - controller.continue_after_merge(3) - - controller.add_to_merge((4,)) - assert controller.state.merge.is_post_merge - controller.remove_from_merge((4,)) - assert controller.state.merge.is_post_merge - - controller.cancel_merge_mode() - controller.enter_merge_mode() - assert not controller.state.merge.is_post_merge - - def test_switch_merge_proposition_preserves_original_normal_entry_snapshot(): initial = CurationSelectionState(cluster_ids=(1,), similar_ids=(2,)) context = {'cluster_filter': 'group == good'} diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 1e6b8303..9a453f91 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -703,18 +703,14 @@ def test_merge_mode_merge_undo_redo_restores_workspace(supervisor): supervisor.block() merged_id = up.added[0] - assert supervisor.selection.state.is_merge_mode - assert supervisor.selection.state.merge.is_post_merge - assert supervisor.selected_merge == [merged_id] - assert supervisor.selected_clusters == [] + assert not supervisor.selection.state.is_merge_mode + assert supervisor.selected_merge == [] + assert supervisor.selected_clusters == [merged_id] assert supervisor.selected_similar == [] assert supervisor.selected == [merged_id] merge_view = supervisor.merge_view assert merge_view is not None - assert not merge_view.dock.isHidden() - assert merge_view.get_ids() == [merged_id] - assert merge_view._selected_color_index(merged_id) == 0 - assert merge_view.dock.get_widget('cancel_merge_mode').text() == 'Exit Merge Mode' + assert merge_view.dock.isHidden() assert supervisor.actions.get('undo').isEnabled() assert set(up.deleted) == {30, 20, candidate} assignments_after = supervisor.clustering.spike_clusters.copy() @@ -750,31 +746,22 @@ def on_select(sender, cluster_ids): supervisor.block() ae(supervisor.clustering.spike_clusters, assignments_after) - assert supervisor.selection.state.is_merge_mode - assert supervisor.selection.state.merge.is_post_merge - assert supervisor.selected_merge == [merged_id] + assert not supervisor.selection.state.is_merge_mode + assert supervisor.selected_merge == [] + assert supervisor.selected_clusters == [merged_id] assert supervisor.selected == [merged_id] assert supervisor.merge_view is merge_view - assert not merge_view.dock.isHidden() - assert merge_view.dock.get_widget('cancel_merge_mode').text() == 'Exit Merge Mode' + assert merge_view.dock.isHidden() assert supervisor.actions.get('undo').isEnabled() assert events[-1] == [merged_id] unconnect(on_select) -def test_post_merge_workspace_requires_exit_before_quality_assignment(supervisor): +def test_manual_merge_returns_to_cluster_view_for_quality_assignment(supervisor): _select(supervisor, [30], [20]) supervisor.toggle_merge_mode() merged_id = supervisor.merge().added[0] supervisor.block() - group = supervisor.cluster_meta.get('group', merged_id) - - supervisor.move('good', 'all') - - assert supervisor.selection.state.is_merge_mode - assert supervisor.cluster_meta.get('group', merged_id) == group - - supervisor.toggle_merge_mode() supervisor.move('good', 'all') assert not supervisor.selection.state.is_merge_mode @@ -782,12 +769,13 @@ def test_post_merge_workspace_requires_exit_before_quality_assignment(supervisor assert supervisor.cluster_meta.get('group', merged_id) == 'good' -def test_post_merge_workspace_supports_chained_merge_and_direct_undo(supervisor): +def test_manual_merge_supports_explicit_chained_merge_and_direct_undo(supervisor): _select(supervisor, [30], [20]) supervisor.toggle_merge_mode() first_id = supervisor.merge().added[0] supervisor.block() candidate = supervisor.similarity_view.get_ids()[0] + supervisor.toggle_merge_mode() supervisor.similarity_view.select([candidate]) supervisor.block() before_second = supervisor.selection.snapshot() @@ -797,8 +785,8 @@ def test_post_merge_workspace_supports_chained_merge_and_direct_undo(supervisor) second_id = second.added[0] assert set(second.deleted) == {first_id, candidate} - assert supervisor.selected_merge == [second_id] - assert supervisor.selection.state.merge.is_post_merge + assert supervisor.selected_clusters == [second_id] + assert supervisor.selected_merge == [] supervisor.undo() supervisor.block() @@ -808,13 +796,14 @@ def test_post_merge_workspace_supports_chained_merge_and_direct_undo(supervisor) assert supervisor.selected_similar == [candidate] -def test_post_merge_workspace_supports_action_dragged_chained_merge(supervisor): +def test_manual_merge_supports_action_dragged_explicit_chained_merge(supervisor): _select(supervisor, [30], [20]) supervisor.toggle_merge_mode() supervisor.action_creator.edit_actions.get('merge').trigger() supervisor.block() - first_id = supervisor.selected_merge[0] - assert supervisor.selection.state.is_merge_mode + first_id = supervisor.selected_clusters[0] + assert not supervisor.selection.state.is_merge_mode + supervisor.toggle_merge_mode() candidate = supervisor.similarity_view.get_ids()[0] supervisor.merge_view.emit_cluster_drop(supervisor.similarity_view, (candidate,), 1) @@ -827,7 +816,8 @@ def test_post_merge_workspace_supports_action_dragged_chained_merge(supervisor): assert first_id not in supervisor.clustering.cluster_ids assert candidate not in supervisor.clustering.cluster_ids - assert len(supervisor.selected_merge) == 1 + assert len(supervisor.selected_clusters) == 1 + assert supervisor.selected_merge == [] def test_uncommitted_merge_workspace_does_not_undo_prior_action(supervisor): From 0a47882da7dd54c9362054747c4b748acb86f5e6 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sat, 8 Aug 2026 22:48:30 +0200 Subject: [PATCH 106/110] docs: record merge integration handoff --- design/README.md | 3 + design/merge-view-integration-handoff.md | 171 +++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 design/merge-view-integration-handoff.md diff --git a/design/README.md b/design/README.md index a9cbf3fb..05f9a171 100644 --- a/design/README.md +++ b/design/README.md @@ -27,6 +27,9 @@ silently alter the workflow contract. - Automated release validation is complete (`make test-full`, lint, formatting, strict documentation build, and package build). Remaining work is maintainer acceptance and manual dataset smoke testing before release. +- The dated [integration handoff](merge-view-integration-handoff.md) records the + current PR dependencies, manual-feedback gate, merge order, conflict policy, + and final validation steps. GitHub remains authoritative for live PR state. Agents continuing this work should first read the repository `AGENTS.md`, then both Merge View documents completely. Merge, selection, undo/redo, saved cluster diff --git a/design/merge-view-integration-handoff.md b/design/merge-view-integration-handoff.md new file mode 100644 index 00000000..d44273b3 --- /dev/null +++ b/design/merge-view-integration-handoff.md @@ -0,0 +1,171 @@ +# phy 2.2 merge-workflow integration handoff + +Status snapshot: 2026-08-08 + +This document records the remaining integration work around phy PR #1404. It +is a point-in-time handoff, not a substitute for GitHub. Before acting, read +`AGENTS.md` and `.github/issue-audit/2026-07.yaml`, then verify every PR state, +review, comment, head SHA, and check result on GitHub. + +## 1. Branch and PR topology + +Keep the existing two implementation branches. Do not create replacement or +stacked branches for this work. + +- phy: `feature/merge-view-workflow` + - PR: https://github.com/cortex-lab/phy/pull/1404 + - snapshot head: `d7ff0c5ee0762a04cea0b3451bfb4a3f7466a339` + - snapshot state: draft, cleanly mergeable, all 12 CI checks passing +- phylib: `agent/fix-template-less-curation-reload` + - PR: https://github.com/cortex-lab/phylib/pull/63 + - snapshot head: `fc494f6ab9f03370c43e618d2ef9610c6781b0e6` + - snapshot state: draft and cleanly mergeable; phylib has no GitHub Actions + workflow, so this PR has no automated checks + +PR #1404 deliberately pins its GitHub Actions environment to phylib commit +`fc494f6ab9f03370c43e618d2ef9610c6781b0e6`. Keep that exact pin until a phylib +release contains the fix. + +## 2. Current acceptance gate for PR #1404 + +PR #1404 is being manually retested by `@goatsofnaxos`. The current build fixes: + +- template-less saved waveform subsets failing after save/reopen; +- repeated waveform warnings and Probe View falling back to channel zero; +- manual merges remaining in Merge mode instead of returning the merged unit + as the sole Cluster View selection. + +The retest request is: +https://github.com/cortex-lab/phy/pull/1404#issuecomment-5227384869 + +At this snapshot, that request is the latest comment and has no tester reply. +While waiting, keep `feature/merge-view-workflow` frozen at the tested head. Do +not merge `master` into it, rewrite it, or force-push it. Silence is not release +acceptance. If no response arrives after several business days, post one concise +follow-up rather than assuming success. + +## 3. Correct merge order before final integration + +Review and merge these PRs one at a time in this order: + +1. phylib #63 — template-less stored waveform sample count + - https://github.com/cortex-lab/phylib/pull/63 + - Run or verify `phylib/io/tests/test_model.py` (41 tests at the snapshot). + - This repository has no CI, so human review and local evidence are the gate. +2. phy #1408 — correct `pc_features.npy` documentation + - https://github.com/cortex-lab/phy/pull/1408 + - Documentation-only, with strict documentation CI passing. +3. phy #1406 — numeric sorting for text-valued table columns + - https://github.com/cortex-lab/phy/pull/1406 + - Focused widget regressions and the full platform matrix pass. +4. phy #1407 — rescale Waveform View when waveform type changes + - https://github.com/cortex-lab/phy/pull/1407 + - This belongs to the audit's waveform-correctness family. Preserve its + focused regression covering every waveform-type switching entry point. +5. phy #1409 — grouped dependency lock refresh + - https://github.com/cortex-lab/phy/pull/1409 + - Merge this last so `uv.lock` represents the final dependency state. + +At the snapshot, phy #1406 through #1409 are non-draft, cleanly mergeable, have +the full green CI matrix, and have no reviews or comments. Recheck that this is +still true before approving or merging. Do not merge merely because CI is green; +review the current patch first. + +The smaller phy PRs overlap #1404 as follows: + +- #1406: `phy/gui/widgets.py`, its tests, and `docs/changelog.md`; +- #1407: `phy/cluster/views/waveform.py`, its tests, and the changelog; +- #1408: the changelog; +- #1409: `uv.lock`. + +Merging them to `master` while #1404 is frozen is expected. Integrate `master` +into #1404 only once, after the manual-feedback gate below is satisfied. + +## 4. Decision after tester feedback + +If the tester confirms the fixes: + +1. Record or acknowledge the acceptance on PR #1404. +2. Confirm phylib #63 and phy #1406 through #1409 are merged. +3. Merge current `origin/master` once into `feature/merge-view-workflow`. +4. Resolve overlap, validate, push, and wait for the complete PR CI matrix. +5. Request final code review and mark #1404 ready only when all gates pass. + +If the tester reports another problem: + +1. Do not integrate `master` yet; keep debugging the exact tested revisions. +2. Reproduce the report and add focused regression coverage, especially for + waveform/channel mapping, saved curation, merge/undo, or cross-view state. +3. Put phylib changes only on the existing phylib branch and phy changes only + on the existing phy branch. +4. Validate and push phylib first, update phy's exact phylib CI pin if its SHA + changes, then push phy and request another retest. +5. Integrate `master` only after the tester accepts the corrected build. + +## 5. Integrating master into PR #1404 + +Use a normal merge, not a rebase or force-push. The branch is shared for manual +testing, and preserving its published history keeps the tested revisions +traceable. + +Before merging, ensure both the local worktree and remote tracking state are +clean. Then fetch and merge current `origin/master` into +`feature/merge-view-workflow`. Resolve conflicts deliberately: + +- retain #1406's numeric-text sorting and focused widget tests; +- retain #1407's waveform-type bounds invalidation and regression test; +- retain #1408's corrected PC-feature documentation; +- combine all unreleased changelog entries without duplicating them; +- regenerate `uv.lock` from the resolved `pyproject.toml` with `uv`, then inspect + the dependency diff rather than choosing either conflict side wholesale; +- retain the exact phylib CI pin until a phylib release supersedes it. + +Do not use destructive history commands such as `git reset --hard`, and do not +discard unrelated or user-authored worktree changes. + +## 6. Required final validation + +After resolving the integration, follow `AGENTS.md` and run at minimum: + +```bash +make lint +make format-check +make doc-check +make test-full +uv build +``` + +Also rerun the focused template-less waveform regression and the selection and +Supervisor suites against the tested phylib revision. Confirm that the full +GitHub Actions matrix passes on Linux, macOS, and Windows for Python 3.10–3.12, +plus docs, spelling, and build jobs. If CI fails, inspect Actions logs, implement +the narrowest fix, validate locally, commit and push, and repeat until green. + +Manual release acceptance must cover a copy of a real dataset: + +- save, close, and reopen a template-less curated dataset; +- select multiple units and confirm Waveform and Probe views use distinct, + cluster-specific channels without repeated warnings; +- complete a manual merge and confirm Normal mode returns with only the result + selected, quality assignment works immediately, and explicit re-entry permits + another merge; +- undo and redo the merge and verify the exact workspace transitions; +- verify proposition merges still advance automatically; +- save and reopen once more to check curation integrity. + +## 7. Ready-to-merge and post-merge gates + +PR #1404 may be marked ready only when all of the following are true: + +- explicit manual tester acceptance is recorded; +- phylib #63 is merged; +- phy #1406 through #1409 are merged and integrated; +- local required validation and the complete PR CI matrix pass; +- the real-dataset smoke test passes; +- a final reviewer has reviewed the integrated patch. + +Prefer squash-merging PR #1404 because its branch contains a long development +history. After merge, verify `master` CI. When a phylib release containing +`fc494f6` (or its successor) is available, replace the temporary commit pin with +the released dependency, regenerate the lockfile, run packaging and CI checks, +and publish that cleanup separately. From e9f957a831ce8b2eb0849f1e33be5190e5431b65 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 9 Aug 2026 10:26:35 +0200 Subject: [PATCH 107/110] docs: update integration handoff --- design/merge-view-integration-handoff.md | 83 ++++++++++++++++++++---- 1 file changed, 71 insertions(+), 12 deletions(-) diff --git a/design/merge-view-integration-handoff.md b/design/merge-view-integration-handoff.md index d44273b3..567bbe23 100644 --- a/design/merge-view-integration-handoff.md +++ b/design/merge-view-integration-handoff.md @@ -1,6 +1,6 @@ # phy 2.2 merge-workflow integration handoff -Status snapshot: 2026-08-08 +Status snapshot: 2026-08-09 This document records the remaining integration work around phy PR #1404. It is a point-in-time handoff, not a substitute for GitHub. Before acting, read @@ -14,13 +14,15 @@ stacked branches for this work. - phy: `feature/merge-view-workflow` - PR: https://github.com/cortex-lab/phy/pull/1404 - - snapshot head: `d7ff0c5ee0762a04cea0b3451bfb4a3f7466a339` - - snapshot state: draft, cleanly mergeable, all 12 CI checks passing -- phylib: `agent/fix-template-less-curation-reload` + - snapshot head before this handoff update: + `0a47882d6cd6ca8190b77df29d489963da077cc3` + - snapshot state: draft, manual feedback pending; all 12 checks passed on + that head +- phylib: former branch `agent/fix-template-less-curation-reload` - PR: https://github.com/cortex-lab/phylib/pull/63 - - snapshot head: `fc494f6ab9f03370c43e618d2ef9610c6781b0e6` - - snapshot state: draft and cleanly mergeable; phylib has no GitHub Actions - workflow, so this PR has no automated checks + - merged head: `fc494f6ab9f03370c43e618d2ef9610c6781b0e6` + - merged into phylib `master` as + `d9beeae0f8500f9e8a879003f5226a4b212a4b93`; the remote branch was deleted PR #1404 deliberately pins its GitHub Actions environment to phylib commit `fc494f6ab9f03370c43e618d2ef9610c6781b0e6`. Keep that exact pin until a phylib @@ -46,30 +48,87 @@ follow-up rather than assuming success. ## 3. Correct merge order before final integration +### First executable batch + +This batch is safe for a maintainer with merge and commit rights to execute +without waiting for independent review. Execute it serially so each later PR is +validated against the master branch produced by the previous step: + +1. Run the complete local phylib test suite on phylib #63, fix any regression + on its existing branch, wait for a clean result, and squash-merge #63. +2. From the updated phylib `master`, create `agent/add-ci`, add a focused GitHub + Actions workflow for tests and package building, open a PR, and iterate on + that branch until its own Actions checks pass. Then squash-merge the CI PR. + Do not make legacy repository-wide lint failures a new blocking gate in this + first workflow. +3. Recheck and squash-merge phy #1408. +4. Recheck phy #1406 against the new `master`; if GitHub reports a conflict or + stale required checks, update its existing branch, preserve its focused + regression, and wait for green CI before squash-merging it. +5. Apply the same gate to phy #1407 and squash-merge it last in this batch. + +After every merge, verify the merged state on GitHub before starting the next +step. If a check fails, inspect its logs, make the narrowest fix on the PR's +existing branch, run the relevant local check, push, and repeat until green. Do +not update or merge `master` into phy #1404 during this batch: that branch stays +frozen pending the recorded manual tester response. When the batch ends, update +this handoff with the merged PR numbers, resulting master SHAs, checks run, and +the first unexecuted step. + +### First batch result (completed 2026-08-09) + +The batch above is complete: + +- phylib #63 was squash-merged as + `d9beeae0f8500f9e8a879003f5226a4b212a4b93` after all 273 local tests passed. +- phylib #64 added CI and repaired the source-distribution manifest. It was + squash-merged as `d99464bddfcd9f0bdc1005dbe03f4510d67919cd` after its PR + and post-merge `master` runs passed. Linux Python 3.10–3.12 and macOS Python + 3.12 run all 273 tests; Windows Python 3.12 runs the stable 60-test electrode, + statistics, and utilities baseline; the build job verifies and uploads both + source and wheel artifacts. The full Windows I/O suite remains excluded + because it has pre-existing open-memory-map teardown failures. +- phy #1408 was squash-merged as + `bcf01c10ec9309e241656028554962fa34d8a0e9`. +- phy #1406 was squash-merged as + `1e56305eb50a5321079fb11740267c3cd29dfdfc`. +- phy #1407 was updated from its existing contributor branch after the earlier + changelog merges, then squash-merged as + `92f080080047af2013162027e253ba22cbfd22a8`. Its focused waveform tests, lint, + formatting, strict documentation checks, and refreshed 12-check PR matrix + all passed. + +The first unexecuted merge step is phy #1409. Do not start the #1404 integration +until #1409 is merged and the manual tester feedback gate in section 2 is met. + +### Full pre-integration order + Review and merge these PRs one at a time in this order: 1. phylib #63 — template-less stored waveform sample count - https://github.com/cortex-lab/phylib/pull/63 - Run or verify `phylib/io/tests/test_model.py` (41 tests at the snapshot). - - This repository has no CI, so human review and local evidence are the gate. + - Completed as `d9beeae0f8500f9e8a879003f5226a4b212a4b93`. 2. phy #1408 — correct `pc_features.npy` documentation - https://github.com/cortex-lab/phy/pull/1408 - Documentation-only, with strict documentation CI passing. + - Completed as `bcf01c10ec9309e241656028554962fa34d8a0e9`. 3. phy #1406 — numeric sorting for text-valued table columns - https://github.com/cortex-lab/phy/pull/1406 - Focused widget regressions and the full platform matrix pass. + - Completed as `1e56305eb50a5321079fb11740267c3cd29dfdfc`. 4. phy #1407 — rescale Waveform View when waveform type changes - https://github.com/cortex-lab/phy/pull/1407 - This belongs to the audit's waveform-correctness family. Preserve its focused regression covering every waveform-type switching entry point. + - Completed as `92f080080047af2013162027e253ba22cbfd22a8`. 5. phy #1409 — grouped dependency lock refresh - https://github.com/cortex-lab/phy/pull/1409 - Merge this last so `uv.lock` represents the final dependency state. -At the snapshot, phy #1406 through #1409 are non-draft, cleanly mergeable, have -the full green CI matrix, and have no reviews or comments. Recheck that this is -still true before approving or merging. Do not merge merely because CI is green; -review the current patch first. +At this snapshot only #1409 remains in this sequence. Recheck its current patch, +head, mergeability, reviews, comments, and complete CI matrix before merging it. +Do not merge merely because CI is green. The smaller phy PRs overlap #1404 as follows: From e8ff69233f5028813b21d5c94b768f99783d94bf Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 9 Aug 2026 20:24:18 +0200 Subject: [PATCH 108/110] docs: record later repository activity --- design/merge-view-integration-handoff.md | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/design/merge-view-integration-handoff.md b/design/merge-view-integration-handoff.md index 567bbe23..f76ad3b4 100644 --- a/design/merge-view-integration-handoff.md +++ b/design/merge-view-integration-handoff.md @@ -101,6 +101,37 @@ The batch above is complete: The first unexecuted merge step is phy #1409. Do not start the #1404 integration until #1409 is merged and the manual tester feedback gate in section 2 is met. +### Later activity refresh (2026-08-09 20:23 CEST) + +A second GitHub audit after the batch found no new phy PR, review, inline +comment, or tester response. Phy #1409 remains open, non-draft, cleanly +mergeable, and green at `046e05c9db33c9af565582829ea5fab4f55d7d8a`. +The final phy `master` CI run at +`92f080080047af2013162027e253ba22cbfd22a8` also passed. + +Three existing phylib PRs received new heads later in the day. Their CI runs +have conclusion `action_required`, meaning a maintainer must approve the fork +workflows; this is not a test failure: + +- #61, blank `dat_path`: `45be9a2c03824b8a1ca748a447dafaf049e91d7c`, + Actions run `31311081566`; +- #62, atomic text writers: `0703f8474a2c1029a678f13cdc6b3938d1e4b952`, + Actions run `31311093728`; +- #60, atomic `spike_clusters.npy`: + `6cf9e9828cdced4455635ad091d786a9206cccee`, Actions run `31309808016`. + +The revised phylib order is #61, then #62, then #60. Approve and verify #61's +CI before merging it. Before merging #62, replace its process-global +`os.umask(0)` read with a race-free way to create a sibling temporary file with +normal new-file permissions, and preserve existing-file permissions. After #62 +is corrected and merged, update #60 onto that master and implement its binary +array write through the shared atomic writer. The current #60 creates its +temporary file with mode 0600 and then replaces `spike_clusters.npy`, which can +silently remove group access on shared lab storage. Resolve the #61/#60 model, +test, and changelog overlaps once, on #60 after both earlier PRs have landed. +Treat #60 and #62 as curation-integrity changes and require their failure-path, +permission, cleanup, and full-suite regressions plus green CI before merge. + ### Full pre-integration order Review and merge these PRs one at a time in this order: From 036299dcff229e27db17a774cc2912b9a7f0c218 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 9 Aug 2026 20:24:50 +0200 Subject: [PATCH 109/110] docs: record integration conflict state --- design/merge-view-integration-handoff.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/design/merge-view-integration-handoff.md b/design/merge-view-integration-handoff.md index f76ad3b4..b04bc2ec 100644 --- a/design/merge-view-integration-handoff.md +++ b/design/merge-view-integration-handoff.md @@ -108,6 +108,10 @@ comment, or tester response. Phy #1409 remains open, non-draft, cleanly mergeable, and green at `046e05c9db33c9af565582829ea5fab4f55d7d8a`. The final phy `master` CI run at `92f080080047af2013162027e253ba22cbfd22a8` also passed. +As expected after those merges, draft #1404 now reports a conflict with +`master`; GitHub did not create a new check suite for the documentation-only +handoff commits because it cannot construct the PR merge ref. Leave that +conflict unresolved until the tester-feedback and #1409 gates are both met. Three existing phylib PRs received new heads later in the day. Their CI runs have conclusion `action_required`, meaning a maintainer must approve the fork From 9391e9d8c3aad84b18f23b98b7548ae060e35f45 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Sun, 9 Aug 2026 20:43:21 +0200 Subject: [PATCH 110/110] Record completed prerequisite merge sequence --- design/merge-view-integration-handoff.md | 59 ++++++++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/design/merge-view-integration-handoff.md b/design/merge-view-integration-handoff.md index b04bc2ec..3afde089 100644 --- a/design/merge-view-integration-handoff.md +++ b/design/merge-view-integration-handoff.md @@ -14,10 +14,12 @@ stacked branches for this work. - phy: `feature/merge-view-workflow` - PR: https://github.com/cortex-lab/phy/pull/1404 - - snapshot head before this handoff update: - `0a47882d6cd6ca8190b77df29d489963da077cc3` - - snapshot state: draft, manual feedback pending; all 12 checks passed on - that head + - current published head before this handoff update: + `036299dcff229e27db17a774cc2912b9a7f0c218` + - last manually tested implementation head, before documentation-only + handoff commits: `0a47882d6cd6ca8190b77df29d489963da077cc3` + - snapshot state: draft and conflicting with current `master`, with manual + feedback pending; all 12 checks passed on the tested implementation head - phylib: former branch `agent/fix-template-less-curation-reload` - PR: https://github.com/cortex-lab/phylib/pull/63 - merged head: `fc494f6ab9f03370c43e618d2ef9610c6781b0e6` @@ -136,6 +138,38 @@ test, and changelog overlaps once, on #60 after both earlier PRs have landed. Treat #60 and #62 as curation-integrity changes and require their failure-path, permission, cleanup, and full-suite regressions plus green CI before merge. +### Later activity result (completed 2026-08-09) + +The refreshed executable batch is complete: + +- phy #1409 was verified as a lockfile-only six-package minor/patch update. + `uv sync --frozen --dev`, `make lint`, `make format-check`, and `uv build` + passed locally. It was squash-merged as + `6c582a5043d4f79723e446bb80dc62b63b7e9dc4`; its complete post-merge + platform matrix, docs, spelling, dependency-graph, and build checks passed. +- phylib #61's fork workflow was approved. Its focused model tests and every + Linux, macOS, Windows-smoke, and package-build CI job passed. It was + squash-merged as `97118cad110006bf58a5a948c9de47666ff97065`. +- phylib #62 was merged with current `master` and corrected before approval. + Its sibling temporary files now use exclusive open semantics, letting the OS + apply the process umask without temporarily changing that process-global + setting. Existing destination permissions are preserved; exception cleanup, + binary mode, and cross-platform permission behavior have regression tests. + Changed-file flake8 and all 279 local tests passed, followed by every CI job. + It was squash-merged as `0f78527a8051d40478c4d72910969b0fb9b16d7a`. +- phylib #60 was then merged with that `master` and changed to reuse #62's + shared atomic writer for `spike_clusters.npy`. Its model-level regression + verifies that a simulated partial `np.save` leaves prior assignments byte + identical, removes the temporary file, and preserves group-readable mode on + Unix. All 280 local tests passed; changed files introduced no flake8 findings + beyond the six existing `model.py` findings; every CI job passed. It was + squash-merged as `0ccd6908ecb689a1762ca8d1322433bf0fba801b`. + +There are now no open phylib PRs in this integration sequence and no unmerged +phy prerequisite PRs. The only current gate is the explicit real-dataset reply +requested from `@goatsofnaxos` on phy #1404. There was still no reply, review, +or newer PR comment at this update. Do not interpret silence as acceptance. + ### Full pre-integration order Review and merge these PRs one at a time in this order: @@ -159,11 +193,13 @@ Review and merge these PRs one at a time in this order: - Completed as `92f080080047af2013162027e253ba22cbfd22a8`. 5. phy #1409 — grouped dependency lock refresh - https://github.com/cortex-lab/phy/pull/1409 - - Merge this last so `uv.lock` represents the final dependency state. + - Completed as `6c582a5043d4f79723e446bb80dc62b63b7e9dc4` after local + lock-environment, lint, format, and build validation plus green PR and + post-merge CI. -At this snapshot only #1409 remains in this sequence. Recheck its current patch, -head, mergeability, reviews, comments, and complete CI matrix before merging it. -Do not merge merely because CI is green. +This sequence is complete. Do not begin the #1404 integration merely because +all machine checks and prerequisite merges are complete; the manual feedback +gate still applies. The smaller phy PRs overlap #1404 as follows: @@ -183,7 +219,9 @@ If the tester confirms the fixes: 2. Confirm phylib #63 and phy #1406 through #1409 are merged. 3. Merge current `origin/master` once into `feature/merge-view-workflow`. 4. Resolve overlap, validate, push, and wait for the complete PR CI matrix. -5. Request final code review and mark #1404 ready only when all gates pass. +5. Perform the final maintainer review and mark #1404 ready only when all gates + pass. Cyrille may provide that review and merge without an additional + independent reviewer. If the tester reports another problem: @@ -256,7 +294,8 @@ PR #1404 may be marked ready only when all of the following are true: - phy #1406 through #1409 are merged and integrated; - local required validation and the complete PR CI matrix pass; - the real-dataset smoke test passes; -- a final reviewer has reviewed the integrated patch. +- Cyrille has completed the final maintainer review of the integrated patch; + no additional independent reviewer is required. Prefer squash-merging PR #1404 because its branch contains a long development history. After merge, verify `master` CI. When a phylib release containing