diff --git a/src/napari_deeplabcut/_tests/core/test_dataframes.py b/src/napari_deeplabcut/_tests/core/test_dataframes.py index 5718ea37..8986d6f9 100644 --- a/src/napari_deeplabcut/_tests/core/test_dataframes.py +++ b/src/napari_deeplabcut/_tests/core/test_dataframes.py @@ -742,7 +742,7 @@ def test_merge_save_df_merge_refuses_no_row_overlap_after_harmonization(): columns=cols, ) - with pytest.raises(ValueError, match="no row-index overlap after harmonization"): + with pytest.raises(ValueError, match="Cannot merge annotations"): merge_save_df(df_old, df_new, nan_clears_existing=True) diff --git a/src/napari_deeplabcut/_writer.py b/src/napari_deeplabcut/_writer.py index 4981d493..60c305b1 100644 --- a/src/napari_deeplabcut/_writer.py +++ b/src/napari_deeplabcut/_writer.py @@ -26,8 +26,11 @@ def write_hdf_napari_dlc(path: str, data, attributes: dict) -> list[str]: ) logger.debug("write_hdf_napari_dlc ENTER path=%r", path) - - written = write_hdf(path, data, attributes) + try: + written = write_hdf(path, data, attributes) + except Exception: + logger.exception("write_hdf_napari_dlc failed") + raise logger.debug("write_hdf_napari_dlc RETURN written=%r", written) logger.debug( diff --git a/src/napari_deeplabcut/config/keybinds.py b/src/napari_deeplabcut/config/keybinds.py index ca75b5c1..f05f81c1 100644 --- a/src/napari_deeplabcut/config/keybinds.py +++ b/src/napari_deeplabcut/config/keybinds.py @@ -14,7 +14,7 @@ increment_dims_right, ) from napari.layers import Points -from qtpy.QtCore import QTimer +from qtpy.QtCore import QElapsedTimer, QTimer from .settings import TRACKING_SHORTCUTS_ENABLED @@ -58,9 +58,41 @@ class ShortcutAction(Enum): # ---------------------------------------- # Functions with associated keybind callbacks # ---------------------------------------- +_FRAME_REPEAT_INITIAL_DELAY_MS = 400 + +_FRAME_REPEAT_STAGES = ( + # Milliseconds since repeating began, interval in milliseconds. + (0, 180), + (800, 100), + (1_500, 40), +) + + +@dataclass +class _FrameRepeatState: + delay_timer: QTimer + repeat_timer: QTimer + elapsed: QElapsedTimer + + +_frame_repeat_states: dict[ + tuple[int, str], + _FrameRepeatState, +] = {} -_FRAME_REPEAT_INTERVAL_MS = 60 -_frame_repeat_timers: dict[tuple[int, str], QTimer] = {} + +def _repeat_interval_for_elapsed( + elapsed_ms: int, +) -> int: + interval = _FRAME_REPEAT_STAGES[0][1] + + for threshold_ms, stage_interval in _FRAME_REPEAT_STAGES: + if elapsed_ms < threshold_ms: + break + + interval = stage_interval + + return interval def _viewer_from_callback_arg(ctx: BindingContext, obj): @@ -77,41 +109,85 @@ def _viewer_from_callback_arg(ctx: BindingContext, obj): return None -def _make_repeating_viewer_callback(ctx: BindingContext, action, repeat_id: str): +def _make_repeating_viewer_callback( + ctx: BindingContext, + action, + repeat_id: str, +): """ - Call a napari viewer action once, then continue calling it while the key is held. - - This reuses napari's own increment_dims_* functions, but adds hold-to-repeat - for A/D, which napari otherwise filters as non-navigation autorepeat keys. + Move once immediately, then repeat with progressive acceleration while + the key remains held. """ def callback(obj): - viewer = _viewer_from_callback_arg(ctx, obj) + viewer = _viewer_from_callback_arg( + ctx, + obj, + ) if viewer is None: return - timer_key = (id(viewer), repeat_id) + timer_key = ( + id(viewer), + repeat_id, + ) - # Avoid duplicate timers if repeat key-press events sneak through. - if timer_key in _frame_repeat_timers: + # Ignore duplicate press events while this key is already held. + if timer_key in _frame_repeat_states: return - # Move once immediately. + # A tap always moves exactly one frame. action(viewer) - timer = QTimer() - timer.setInterval(_FRAME_REPEAT_INTERVAL_MS) - timer.timeout.connect(lambda: action(viewer)) + delay_timer = QTimer() + delay_timer.setSingleShot(True) - _frame_repeat_timers[timer_key] = timer - timer.start() + repeat_timer = QTimer() + elapsed = QElapsedTimer() + + state = _FrameRepeatState( + delay_timer=delay_timer, + repeat_timer=repeat_timer, + elapsed=elapsed, + ) + _frame_repeat_states[timer_key] = state + + def repeat_once(): + action(viewer) + + next_interval = _repeat_interval_for_elapsed(elapsed.elapsed()) + + if repeat_timer.interval() != next_interval: + repeat_timer.setInterval(next_interval) + + def begin_repeating(): + elapsed.start() + + initial_interval = _repeat_interval_for_elapsed(0) + repeat_timer.setInterval(initial_interval) + + # Move when the initial hold delay expires, then continue. + repeat_once() + repeat_timer.start() + + delay_timer.timeout.connect(begin_repeating) + repeat_timer.timeout.connect(repeat_once) + + delay_timer.start(_FRAME_REPEAT_INITIAL_DELAY_MS) try: yield finally: - timer.stop() - timer.deleteLater() - _frame_repeat_timers.pop(timer_key, None) + delay_timer.stop() + repeat_timer.stop() + + delay_timer.deleteLater() + repeat_timer.deleteLater() + + _frame_repeat_states.pop( + timer_key, + None, + ) return callback diff --git a/src/napari_deeplabcut/core/dataframes.py b/src/napari_deeplabcut/core/dataframes.py index 482f897a..a09a1c19 100644 --- a/src/napari_deeplabcut/core/dataframes.py +++ b/src/napari_deeplabcut/core/dataframes.py @@ -560,8 +560,12 @@ def merge_save_df( overlap = df_old2.index.intersection(df_new2.index) if overlap.empty: raise ValueError( - "Cannot merge save dataframe: no row-index overlap after harmonization. " - "Existing labels would be preserved instead of overwritten/deleted." + "Cannot merge annotations because no annotation rows overlap between the " + "destination file and the incoming data after harmonization. " + "This usually means the annotations refer to different image paths/names " + "(e.g., a labeled-data folder was moved/renamed, or you are saving to a different dataset). " + f"Existing row example: {df_old2.index[0]!r}. " + f"Incoming row example: {df_new2.index[0]!r}." ) idx = df_old2.index.union(df_new2.index)