# handoff-260218-1.md

## Problem framing

The current Tx-box-span UI (`TxSpanUI`) lets the user adjust the span `[t0, t1]` via Matplotlib `SpanSelector` (horizontal). The Tx overlay rectangle and diagonal are re-derived from the span, with the top-left anchor at `(t0, y(t0))` in the current display mode (`db` or `amp`) inside `_refresh_overlay()`.

In practice, for noisy / coupled envelopes, the best “visual slope alignment” is sometimes achieved by translating the Tx box (rectangle + diagonal) in the plane. Today, the user approximates this by zooming and micro-adjusting `t0` to “land” the anchor on a more representative ridge/valley. This is workable but annoying.

We want a direct interaction:
- **Drag the drawn Tx rectangle freely (Δx, Δy)**.
- The rectangle **must not change size** during this operation:
  - width (span) fixed
  - height fixed
- On release, **snap + commit** the underlying span `[t0, t1]` so that the *new* box/diagonal is consistent with the envelope and the current Tx definition.

## Scope

Add a new interaction to `TxSpanUI`:
- mouse-driven planar drag of the overlay rectangle (and its diagonal)
- on release: snap `(x,y)` → commit to a new span (updates `t0,t1` and SpanSelector extents)

Do not change the underlying Tx mathematics:
- Tx overlay continues to be defined as today, anchored at `(t0, y(t0))` and `Tx_db`, recomputed by `_refresh_overlay()`.
- This feature is a more ergonomic way to choose `[t0,t1]`, not a new measurement model.

## UX / UI requirements

### UX-1: Free planar drag + fixed size
- User can click inside the Tx rectangle and drag it freely.
- During drag:
  - Rectangle width remains constant (current `t1 - t0`)
  - Rectangle height remains constant (current height as drawn for the current display mode)
  - The diagonal moves with the rectangle (i.e., same endpoints offset)

### UX-2: Snap+commit on mouse release
- On mouse release:
  - derive a target anchor `(t_target, y_target)` from the dragged rectangle’s **top-left corner** in display coordinates.
  - snap the underlying span to a new `[t0', t1']` (same span width) such that:
    - `t0'` is near `t_target`
    - `y_display(t0')` is as close as possible to `y_target`
  - commit:
    - set `self.t0 = t0'`, `self.t1 = t0' + span_width`
    - update the SpanSelector extents to match
    - call `_refresh_overlay()` (which re-derives the rectangle/diagonal from the committed span)

### UX-3: Cancel & reset
- `Esc` cancels an in-progress drag (revert overlay to last committed state).
- `r` resets span to the initial full range (existing behavior may not exist; add if absent) OR at minimum resets overlay to last committed state and clears any transient drag state.

### UX-4: Guardrails
- Snap search must be bounded to avoid “teleporting” too far:
  - only consider candidates within a configurable time window around `t_target` (e.g. ±0.25 s) OR within ±N samples (e.g. ±2000), whichever is smaller.
- Clamp the resulting `t0'` so `t1'` stays within `t` domain.

### UX-5: Minimal interference with pan/zoom
- Drag should only engage when clicking inside the overlay rectangle.
- If no overlay rect exists or click misses it, normal Matplotlib interactions apply.

## Snap definition (locked-in)

Definitions:
- `span_width = (t1 - t0)` from the last committed measurement.
- `y_display(t)` is the currently displayed envelope array in the active display mode (from `_current_display_y()`).
- The dragged rectangle top-left in display coordinates is:
  - `t_drag_left = rect.get_x()`
  - `y_drag_bottom = rect.get_y()`
  - `rect_height = rect.get_height()`
  - `y_target = y_drag_bottom + rect_height`
  - `t_target = t_drag_left`

Snap objective:
- Choose `t0'` that minimizes `|y_display(t0') - y_target|` among candidate indices within the snap window around `t_target`.
- Tie-breakers (in order):
  1) smaller `|t0' - t_target|`
  2) earlier time (smaller `t0'`) to keep behavior deterministic

Commit:
- `t1' = t0' + span_width`
- If `t1'` exceeds `t[-1]`, shift both left so `t1' = t[-1]` and preserve width.
- If `t0'` goes below `t[0]`, shift right so `t0' = t[0]`.

Note: this preserves the current Tx definition because `_refresh_overlay()` will recompute:
- `y0 = interp(y_display at committed t0')`
- `y1 = y0 - Tx_db` (db) or `y1 = y0 * ratio` (amp)
- rectangle height / diagonal consistent with the committed span.

## Implementation plan

### I-260218-1.1: Add drag state to TxSpanUI
Files:
- `src/envelope_decay_fit/tx_span_ui.py` (or wherever `TxSpanUI` lives in this dump)

Add fields to `TxSpanUI`:
- `_drag_active: bool = False`
- `_drag_start_event: tuple[float,float] | None`  (event.xdata, event.ydata)
- `_drag_start_rect: tuple[float,float] | None`   (rect_x, rect_y)
- `_drag_span_width: float | None`
- `_drag_rect_height: float | None`
- `_drag_last_committed: tuple[float,float] | None` (t0,t1) to revert on cancel

### I-260218-1.2: Register mouse callbacks
In `run()` after setting up overlay / span selector:
- `mpl_connect("button_press_event", self._on_mouse_press)`
- `mpl_connect("motion_notify_event", self._on_mouse_move)`
- `mpl_connect("button_release_event", self._on_mouse_release)`

### I-260218-1.3: Implement hit-testing and start drag
`_on_mouse_press(event)`:
- ignore if event.inaxes != self.ax or event.xdata/ydata is None
- require `self.overlay_rect` exists
- use `self.overlay_rect.contains(event)` to detect click inside rect
- if hit:
  - set `_drag_active=True`
  - store `_drag_start_event=(event.xdata,event.ydata)`
  - store `_drag_start_rect=(rect.get_x(), rect.get_y())`
  - store `_drag_span_width = rect.get_width()` (== current span width)
  - store `_drag_rect_height = rect.get_height()`
  - store `_drag_last_committed=(self.t0,self.t1)` (must be non-None)
  - optionally set a cursor / status message (not required)

### I-260218-1.4: Update overlay during drag (fixed size)
`_on_mouse_move(event)`:
- if not `_drag_active`: return
- ignore if event.xdata/ydata is None
- compute dx = event.xdata - start_x, dy = event.ydata - start_y
- set rect position:
  - rect.set_x(start_rect_x + dx)
  - rect.set_y(start_rect_y + dy)
- update overlay diagonal consistently *without changing size*:
  - For db mode: diagonal endpoints are:
    - (rect_x, rect_y + rect_height) to (rect_x + rect_width, rect_y)
  - For amp mode: since current overlay line is not a straight line in amplitude mode
    (it’s generated from an exponential curve), during drag we still keep the line/curve
    shape but translated by (dx, dy) in display coordinates.
    - simplest: store the original overlay line data at drag start, then add dx/dy.
    - acceptable: during drag in amp mode, draw a straight diagonal for feedback and
      on release recompute exact curve after commit. (Choose one; see DoD.)
- `canvas.draw_idle()`

### I-260218-1.5: On release, snap+commit span and refresh
`_on_mouse_release(event)`:
- if not `_drag_active`: return
- set `_drag_active=False`
- read current dragged rect position:
  - t_target = rect.get_x()
  - y_target = rect.get_y() + rect.get_height()
- compute `t0'` by snapping:
  - create candidate index window around `t_target`:
    - find nearest index `i0 = argmin |t - t_target|`
    - define `i_lo = max(0, i0 - N)`, `i_hi = min(len(t)-1, i0 + N)` with default N=2000
      OR compute by time window (±0.25 s) by searching indices; whichever is simpler.
  - use `y_display = _current_display_y()` and finite mask
  - among candidates with finite y, pick i minimizing abs(y_display[i] - y_target)
    with tie-breakers as defined in Snap definition.
  - set `t0' = t[i_best]`
- set `t1' = t0' + span_width`, then clamp shift within bounds.
- commit:
  - `self.t0 = float(t0')`
  - `self.t1 = float(t1')`
  - update SpanSelector extents:
    - if supported: `self.span_selector.extents = (self.t0, self.t1)`
      (Matplotlib supports `SpanSelector.extents` when `interactive=True`)
  - call `_refresh_overlay()`

### I-260218-1.6: Keyboard handling for cancel/reset
Extend `_on_key()`:
- if key == "escape" and `_drag_active`:
  - revert overlay to last committed by calling `_refresh_overlay()` using stored `(t0,t1)`
    OR restore `self.t0,self.t1` from `_drag_last_committed` and `_refresh_overlay()`
- if key == "r":
  - reset `self.t0,self.t1` to initial span (e.g. full range) and update extents + refresh
  - (If you prefer not to change semantics, make `r` “reset overlay offset / cancel drag”)

## Tests

### T-260218-1.1: Unit test snap selection determinism
Add tests for a pure function extracted from the UI code, e.g.:
- `_snap_t0_to_y_target(t, y_display, t_target, y_target, window)` → `t0'`
Test cases:
- single clear nearest point
- tie on abs(y-y_target) resolves by nearest t, then earlier t
- ignores non-finite y
- clamps window bounds

### T-260218-1.2: Integration-ish UI logic test (non-GUI)
Without launching Matplotlib, test that commit math preserves span width and bounds:
- start with synthetic t and y
- set span_width
- choose t_target near end; ensure clamp shifts span inside domain

(Keep GUI event simulation optional; core snap/commit logic must be testable headlessly.)

## Definition of Done

### DoD-260218-1.1
In TxSpanUI, user can click inside the Tx rectangle and drag it freely in the axes.
- Rectangle width and height remain constant during drag.
- Drag updates overlay rectangle and overlay diagonal/line visually.

### DoD-260218-1.2
On mouse release:
- UI snaps and commits `[t0,t1]` (same width as before) using the locked-in snap rule.
- SpanSelector extents update to the committed span.
- `_refresh_overlay()` redraws the overlay in its “true” anchored form.

### DoD-260218-1.3
Escape cancels an in-progress drag and reverts to last committed state.

### DoD-260218-1.4
Core snapping logic is covered by deterministic unit tests.

## Notes / tradeoffs (amp mode)

Current amp mode overlay line is a curved exponential (generated with many points).
During drag:
- Preferred: translate the curve data (store at drag start; add dx, dy).
- Acceptable fallback: show a straight diagonal while dragging and restore the exact
  curve after commit on mouse release.

Choose one and document it in code comments.

