diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index add93258..fe5ac617 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -3185,6 +3185,15 @@ function render({ model, el, onResize }) { const [rx,ry]=_imgToCanvas2d(w.x,w.y,st,imgW,imgH); const rw=w.w*scale, rh=w.h*scale; ovCtx.strokeRect(rx,ry,rw,rh); + // Canvas-space geometry readback for Playwright (same role as + // window._aplTiming). A test cannot derive a handle's page position from + // figure padding: the image->canvas mapping depends on the zoom/extent + // state, and the grab radius is only HR px — so a hard-coded corner + // silently grabs the BODY instead, and a resize test then asserts + // nothing. Publishing the drawn corners lets a test aim at a real handle. + if(!window._aplWidgetGeom) window._aplWidgetGeom={}; + if(!window._aplWidgetGeom[p.id]) window._aplWidgetGeom[p.id]={}; + window._aplWidgetGeom[p.id][w.id]={type:'rectangle',rx,ry,rw,rh}; if(_handles){ _drawHandle2d(ovCtx,rx,ry,w.color);_drawHandle2d(ovCtx,rx+rw,ry,w.color); _drawHandle2d(ovCtx,rx,ry+rh,w.color);_drawHandle2d(ovCtx,rx+rw,ry+rh,w.color); @@ -7214,22 +7223,28 @@ fn fs(in : VsOut) -> @location(0) vec4 { w.r_inner = newR; } } else if (w.type === 'rectangle') { + // max_w / max_h cap the rectangle DURING the drag, so it physically stops + // growing rather than being silently clamped afterwards. Each branch pins + // the size and leaves the OPPOSITE corner where it is, so the anchor never + // shifts and the rectangle cannot jump out from under the cursor. + const capW = (v) => (w.max_w == null ? v : Math.min(v, w.max_w)); + const capH = (v) => (w.max_h == null ? v : Math.min(v, w.max_h)); if (d.mode === 'move') { w.x = s.x + dix; w.y = s.y + diy; } else if (d.mode === 'resize_br') { - w.w = Math.max(1, s.w + dix); w.h = Math.max(1, s.h + diy); + w.w = capW(Math.max(1, s.w + dix)); w.h = capH(Math.max(1, s.h + diy)); } else if (d.mode === 'resize_bl') { - const newW = Math.max(1, s.w - dix); + const newW = capW(Math.max(1, s.w - dix)); w.x = s.x + (s.w - newW); w.w = newW; - w.h = Math.max(1, s.h + diy); + w.h = capH(Math.max(1, s.h + diy)); } else if (d.mode === 'resize_tr') { - w.w = Math.max(1, s.w + dix); - const newH = Math.max(1, s.h - diy); + w.w = capW(Math.max(1, s.w + dix)); + const newH = capH(Math.max(1, s.h - diy)); w.y = s.y + (s.h - newH); w.h = newH; } else if (d.mode === 'resize_tl') { - const newW = Math.max(1, s.w - dix); + const newW = capW(Math.max(1, s.w - dix)); w.x = s.x + (s.w - newW); w.w = newW; - const newH = Math.max(1, s.h - diy); + const newH = capH(Math.max(1, s.h - diy)); w.y = s.y + (s.h - newH); w.h = newH; } } else if (w.type === 'crosshair') { @@ -7307,9 +7322,16 @@ fn fs(in : VsOut) -> @location(0) vec4 { if(Math.hypot(mx-px1b,my-pyHalf)<=HR+5) return{idx:i,mode:'edge1',wtype:'range',startMX:mx,snapW:{...w}}; } else { - if(Math.abs(mx-px0)<=HR+5) return{idx:i,mode:'edge0',wtype:'range',startMX:mx,snapW:{...w}}; - if(Math.abs(mx-px1b)<=HR+5) return{idx:i,mode:'edge1',wtype:'range',startMX:mx,snapW:{...w}}; + // Band style. The edge grab zones are ±(HR+5)=12 px each, so on a + // NARROW band (< ~24 px on screen — routine when the span is capped, or + // just zoomed out) the two zones meet and swallow the body: the user + // aims at the middle to translate the band and gets an edge resize + // instead. Give each edge at most a THIRD of the band, so the middle + // third always belongs to 'move' no matter how narrow the band gets. const left=Math.min(px0,px1b),right=Math.max(px0,px1b); + const grab=Math.min(HR+5,(right-left)/3); + if(Math.abs(mx-px0)<=grab) return{idx:i,mode:'edge0',wtype:'range',startMX:mx,snapW:{...w}}; + if(Math.abs(mx-px1b)<=grab) return{idx:i,mode:'edge1',wtype:'range',startMX:mx,snapW:{...w}}; if(mx>=left&&mx<=right&&my>=r.y&&my<=r.y+r.h) return{idx:i,mode:'move',wtype:'range',startMX:mx,snapW:{...w}}; } } @@ -7329,8 +7351,21 @@ fn fs(in : VsOut) -> @location(0) vec4 { if(w.type==='vline'){w.x=xUnit;} else if(w.type==='hline'){w.y=st.data_max-((py-r.y)/(r.h||1))*(st.data_max-st.data_min);} else if(w.type==='range'){ - if(d.mode==='edge0') w.x0=xUnit; - else if(d.mode==='edge1') w.x1=xUnit; + // max_extent caps the span DURING the drag. The edge NOT being dragged is + // the anchor and never moves, so the span stops growing at the cap without + // the range jumping. (Clamping afterwards by rewriting whichever endpoint + // happens to be numerically smaller moves the edge the user is not holding + // — that reads as phantom movement.) Dragging the whole band is unaffected: + // a translation can't change the width. + const cap = (w.max_extent == null ? null : Math.abs(w.max_extent)); + if(d.mode==='edge0'){ + w.x0 = (cap!=null && Math.abs(w.x1-xUnit) > cap) + ? w.x1 + (xUnit < w.x1 ? -cap : cap) : xUnit; + } + else if(d.mode==='edge1'){ + w.x1 = (cap!=null && Math.abs(xUnit-w.x0) > cap) + ? w.x0 + (xUnit < w.x0 ? -cap : cap) : xUnit; + } else { const snapPx=_fracToPx1d(xArr.length>=2?_axisValToFrac(xArr,s.x0):0,x0,x1,r); const dxUnit=xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(snapPx+(mx-d.startMX),x0,x1,r))-s.x0:(mx-d.startMX)/(r.w||1); diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 220c0efa..e25d25cd 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -756,6 +756,7 @@ def add_range_widget(self, x0: float, x1: float, style: str = "band", y: float = 0.0, linewidth: float = 2, + max_extent: float | None = None, _push: bool = True) -> _RangeWidget: """Add a draggable range overlay to this panel. @@ -775,6 +776,10 @@ def add_range_widget(self, x0: float, x1: float, ``style='fwhm'``. Ignored when ``style='band'``. Default 0. linewidth : float, optional Line stroke width in px. Default 2. + max_extent : float, optional + Maximum span width in data units. The span physically stops growing + at this width while dragging (the dragged edge pins, the opposite + edge stays put). ``None`` (default) leaves it unbounded. _push : bool, optional Push state to JS immediately. Set to ``False`` when adding several widgets at once; call :meth:`_push` manually afterward. @@ -787,7 +792,7 @@ def add_range_widget(self, x0: float, x1: float, """ widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), color=color, style=style, y=float(y), - linewidth=linewidth) + linewidth=linewidth, max_extent=max_extent) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index a2aa984f..d9c092df 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -1610,16 +1610,38 @@ def add_circle_widget(self, cx: float | None = None, cy: float | None = None, def add_rectangle_widget(self, x: float | None = None, y: float | None = None, w: float | None = None, h: float | None = None, color: str = "#00e5ff", linewidth: float = 2, - show_handles: bool = True) -> RectangleWidget: - """Add a draggable rectangle overlay.""" + show_handles: bool = True, + max_extent=None) -> RectangleWidget: + """Add a draggable rectangle overlay. + + ``max_extent`` caps the rectangle's size in the widget's coordinates — + a scalar caps both axes, a ``(max_w, max_h)`` pair caps them separately. + The rectangle then physically stops growing at the cap while dragging + (the dragged corner pins, the opposite corner stays put). + """ iw, ih = self._state["image_width"], self._state["image_height"] + # The DEFAULT size honours max_extent. Without this a capped rectangle is + # born at half the image and then snaps down to the cap on first use — + # the ROI visibly appears huge and collapses, which looks like a bug. + def _cap(axis_default, cap): + return axis_default if cap is None else min(axis_default, float(cap)) + + if max_extent is None: + cap_w = cap_h = None + elif isinstance(max_extent, (tuple, list)): + cap_w, cap_h = max_extent[0], max_extent[1] + else: + cap_w = cap_h = max_extent + def_w = _cap(iw * 0.5, cap_w) + def_h = _cap(ih * 0.5, cap_h) widget = RectangleWidget(lambda: None, x=float(x) if x is not None else iw * 0.25, y=float(y) if y is not None else ih * 0.25, - w=float(w) if w is not None else iw * 0.5, - h=float(h) if h is not None else ih * 0.5, + w=float(w) if w is not None else def_w, + h=float(h) if h is not None else def_h, color=color, linewidth=linewidth, - show_handles=show_handles) + show_handles=show_handles, + max_extent=max_extent) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() diff --git a/anyplotlib/tests/test_interactive/test_widget_max_extent.py b/anyplotlib/tests/test_interactive/test_widget_max_extent.py new file mode 100644 index 00000000..53e25112 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_widget_max_extent.py @@ -0,0 +1,371 @@ +""" +tests/test_interactive/test_widget_max_extent.py +================================================= + +``max_extent`` — a size cap enforced while the widget is being dragged. + +Why it exists: when a widget's size drives real downstream work (an integrating +selector whose span is a number of frames to read), an unbounded drag is a +performance cliff. Clamping *after* the fact is worse than useless — it moves an +edge the user isn't holding, which reads as the selection jumping around under +the cursor. So the cap belongs in the drag itself: the dragged edge pins, the +opposite edge stays put, and the widget visibly stops growing. + +Also covered: the band hit-test. Each edge of a range widget claimed a fixed +±12 px grab zone, so a band narrower than ~24 px on screen (routine when its +span is capped, or simply when zoomed out) had NO grabbable middle — aiming at +the body to translate it caught an edge and resized instead. Each edge now takes +at most a third of the band. + +Coordinate system for the Playwright tests mirrors figure_esm.js: + PAD_L=58 PAD_R=12 PAD_T=12 PAD_B=42 GRID_PAD=8 +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.widgets import RangeWidget, RectangleWidget +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + GRID_PAD, PAD_L, PAD_R, PAD_T, PAD_B, +) + +FIG_W, FIG_H = 400, 300 + + +# ═══════════════════════════════════════════════════════════════════════════ +# 1. Python API +# ═══════════════════════════════════════════════════════════════════════════ + +class TestMaxExtentAttributes: + def test_range_defaults_to_unbounded(self): + w = RangeWidget(lambda: None, x0=0, x1=10) + assert w.max_extent is None + + def test_range_stores_max_extent(self): + w = RangeWidget(lambda: None, x0=0, x1=10, max_extent=16) + assert w.max_extent == 16.0 + + def test_range_max_extent_reaches_the_state_dict(self): + """JS reads the widget dict, so the cap has to survive to_dict().""" + w = RangeWidget(lambda: None, x0=0, x1=10, max_extent=16) + assert w.to_dict()["max_extent"] == 16.0 + + def test_rectangle_defaults_to_unbounded(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + assert w.max_w is None and w.max_h is None + + def test_rectangle_scalar_caps_both_axes(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10, max_extent=16) + assert w.max_w == 16.0 and w.max_h == 16.0 + + def test_rectangle_pair_caps_axes_separately(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10, + max_extent=(8, 32)) + assert w.max_w == 8.0 and w.max_h == 32.0 + + def test_rectangle_max_extent_reaches_the_state_dict(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10, max_extent=16) + d = w.to_dict() + assert d["max_w"] == 16.0 and d["max_h"] == 16.0 + + +class TestMaxExtentFactories: + def test_add_range_widget_passes_max_extent(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_range_widget(x0=10, x1=20, max_extent=16) + assert isinstance(w, RangeWidget) and w.max_extent == 16.0 + + def test_add_range_widget_without_max_extent(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + assert v.add_range_widget(x0=10, x1=20).max_extent is None + + def test_add_rectangle_widget_passes_max_extent(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + w = v.add_rectangle_widget(x=0, y=0, w=4, h=4, max_extent=(8, 12)) + assert w.max_w == 8.0 and w.max_h == 12.0 + + +class TestDefaultSizeHonoursTheCap: + """A capped rectangle must be BORN at the cap. + + The default size is half the image, so a capped ROI used to appear huge and + then snap down to the cap on first use — which reads as a bug ("the ROI is + always huge and then it clamps down").""" + + def test_default_size_is_capped(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((256, 256), dtype=np.float32)) + w = v.add_rectangle_widget(max_extent=16.0) + assert w.w == 16.0 and w.h == 16.0 + + def test_default_size_respects_a_per_axis_cap(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((256, 256), dtype=np.float32)) + w = v.add_rectangle_widget(max_extent=(8, 32)) + assert w.w == 8.0 and w.h == 32.0 + + def test_uncapped_default_is_unchanged(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((256, 256), dtype=np.float32)) + w = v.add_rectangle_widget() + assert w.w == 128.0 and w.h == 128.0 # half the image, as before + + def test_explicit_size_wins_over_the_cap_default(self): + """An explicit w/h is the caller's business; the cap only bounds drags.""" + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((256, 256), dtype=np.float32)) + w = v.add_rectangle_widget(w=4, h=4, max_extent=16.0) + assert w.w == 4.0 and w.h == 4.0 + + def test_cap_larger_than_the_image_does_not_inflate_the_default(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + w = v.add_rectangle_widget(max_extent=1000.0) + assert w.w == 16.0 and w.h == 16.0 # still half the image + + +# ═══════════════════════════════════════════════════════════════════════════ +# 2. Drag behaviour in the browser — the logic lives in figure_esm.js, so a +# Python-only test would prove nothing about what the user experiences. +# ═══════════════════════════════════════════════════════════════════════════ + +def _plot_rect(): + """Page-coordinate plot area (x, y, w, h).""" + return (GRID_PAD + PAD_L, GRID_PAD + PAD_T, + FIG_W - PAD_L - PAD_R, FIG_H - PAD_T - PAD_B) + + +def _collect_panel_state(page) -> None: + """Record every ``model.set('panel__json', ...)`` write, seeded with the + state the page already holds. + + The drag handlers push updated widget geometry back through the model, so the + last write for a panel is its current state. Seeding from ``model.get`` first + matters because a test may read the BEFORE state, and until something is + dragged there has been no write to intercept. (There is no global panel + registry to read — the panel object lives inside the JS closure.)""" + page.evaluate("""() => { + window._aplPanelState = {}; + const m = window._aplModel; + const orig = m.set.bind(m); + m.set = (k, v) => { + const mm = /^panel_(.+)_json$/.exec(k); + if (mm) { try { window._aplPanelState[mm[1]] = JSON.parse(v); } catch(_) {} } + return orig(k, v); + }; + }""") + + +def _seed_panel_state(page, plot_id) -> None: + """Pull one panel's current state out of the model (no drag needed).""" + page.evaluate( + """(pid) => { + try { + const v = window._aplModel.get('panel_' + pid + '_json'); + if (v) window._aplPanelState[pid] = JSON.parse(v); + } catch(_) {} + }""", str(plot_id)) + + +def _widget_state(page, plot_id, idx=0): + """The widget dict from the most recent panel-state write, or None.""" + return page.evaluate( + """([pid, i]) => { + const st = window._aplPanelState && window._aplPanelState[pid]; + const ws = st && st.overlay_widgets; + return ws && ws[i] ? ws[i] : null; + }""", [str(plot_id), idx]) + + +def _x_to_page(xdata, n): + """Data x -> page x for a line plot of n points spanning the full view.""" + px, _py, pw, _ph = _plot_rect() + return px + (xdata / float(n - 1)) * pw + + +@pytest.mark.usefixtures("interact_page") +class TestRangeDragCap: + def _setup(self, interact_page, max_extent): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + n = 128 + plot = ax.plot(np.zeros(n)) + w = plot.add_range_widget(x0=40, x1=50, max_extent=max_extent) + page = interact_page(fig) + _collect_events(page) + _collect_panel_state(page) + return fig, plot, page, w, n + + def test_dragging_an_edge_stops_at_the_cap(self, interact_page): + """Drag the right edge far past the cap: the span pins at max_extent and + the LEFT edge — the one the user is not holding — must not move.""" + fig, plot, page, w, n = self._setup(interact_page, max_extent=16) + _px, py, _pw, ph = _plot_rect() + mid_y = py + ph // 2 + + page.mouse.move(_x_to_page(50, n), mid_y) + page.mouse.down() + page.mouse.move(_x_to_page(120, n), mid_y, steps=10) # way past the cap + page.mouse.up() + page.wait_for_timeout(80) + + st = _widget_state(page, plot._id) + assert st is not None, "widget state should be readable" + span = abs(st["x1"] - st["x0"]) + assert span <= 16.0 + 1e-6, f"span {span} exceeded the cap" + assert st["x0"] == pytest.approx(40.0, abs=1.0), \ + "the anchor edge moved — that is the phantom-movement bug" + + def test_uncapped_range_still_grows_freely(self, interact_page): + fig, plot, page, w, n = self._setup(interact_page, max_extent=None) + _px, py, _pw, ph = _plot_rect() + mid_y = py + ph // 2 + + page.mouse.move(_x_to_page(50, n), mid_y) + page.mouse.down() + page.mouse.move(_x_to_page(100, n), mid_y, steps=10) + page.mouse.up() + page.wait_for_timeout(80) + + st = _widget_state(page, plot._id) + assert abs(st["x1"] - st["x0"]) > 16.0, \ + "no cap was set, so the span should have grown past 16" + + def test_translating_a_capped_band_keeps_its_width(self, interact_page): + """Dragging the BODY moves both edges together; a translation can't + change the width, so the cap must not interfere with it.""" + fig, plot, page, w, n = self._setup(interact_page, max_extent=16) + _px, py, _pw, ph = _plot_rect() + mid_y = py + ph // 2 + + _seed_panel_state(page, plot._id) + before = _widget_state(page, plot._id) + width0 = abs(before["x1"] - before["x0"]) + + page.mouse.move(_x_to_page(45, n), mid_y) # middle of the band + page.mouse.down() + page.mouse.move(_x_to_page(65, n), mid_y, steps=8) + page.mouse.up() + page.wait_for_timeout(80) + + after = _widget_state(page, plot._id) + assert abs(after["x1"] - after["x0"]) == pytest.approx(width0, abs=0.5) + assert after["x0"] > before["x0"] + 5, "the band should have moved" + + +@pytest.mark.usefixtures("interact_page") +class TestNarrowBandIsGrabbable: + def test_middle_of_a_narrow_band_translates_rather_than_resizes( + self, interact_page): + """THE regression this guards: with fixed +-12 px edge zones, a band + narrower than ~24 px had no grabbable body, so aiming at the middle + resized an edge instead of moving the band. Each edge now takes at most + a third of the band, leaving the middle third for 'move'.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + n = 128 + plot = ax.plot(np.zeros(n)) + # ~4 data units of 128 across ~330 px => roughly 10 px on screen. + plot.add_range_widget(x0=60, x1=64) + page = interact_page(fig) + _collect_events(page) + _collect_panel_state(page) + + _px, py, _pw, ph = _plot_rect() + mid_y = py + ph // 2 + _seed_panel_state(page, plot._id) + before = _widget_state(page, plot._id) + width0 = abs(before["x1"] - before["x0"]) + + page.mouse.move(_x_to_page(62, n), mid_y) # dead centre of the band + page.mouse.down() + page.mouse.move(_x_to_page(82, n), mid_y, steps=8) + page.mouse.up() + page.wait_for_timeout(80) + + after = _widget_state(page, plot._id) + assert abs(after["x1"] - after["x0"]) == pytest.approx(width0, abs=1.0), \ + "grabbing the middle of a narrow band resized it instead of moving it" + assert after["x0"] > before["x0"] + 5, "the band should have moved" + + def test_edges_of_a_wide_band_still_resize(self, interact_page): + """The narrow-band fix must not cost us edge resizing on normal bands.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + n = 128 + plot = ax.plot(np.zeros(n)) + plot.add_range_widget(x0=20, x1=80) # wide: ~155 px on screen + page = interact_page(fig) + _collect_events(page) + _collect_panel_state(page) + + _px, py, _pw, ph = _plot_rect() + mid_y = py + ph // 2 + _seed_panel_state(page, plot._id) + before = _widget_state(page, plot._id) + + page.mouse.move(_x_to_page(80, n), mid_y) # right edge + page.mouse.down() + page.mouse.move(_x_to_page(100, n), mid_y, steps=8) + page.mouse.up() + page.wait_for_timeout(80) + + after = _widget_state(page, plot._id) + assert after["x1"] > before["x1"] + 5, "the right edge should have moved" + assert after["x0"] == pytest.approx(before["x0"], abs=1.0), \ + "the left edge should have stayed put" + + +@pytest.mark.usefixtures("interact_page") +class TestRectangleDragCap: + def test_resizing_stops_at_the_cap_and_keeps_the_anchor(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64), dtype=np.float32)) + widget = plot.add_rectangle_widget(x=8, y=8, w=4, h=4, max_extent=16) + page = interact_page(fig) + _collect_events(page) + _collect_panel_state(page) + + _seed_panel_state(page, plot._id) + before = _widget_state(page, plot._id) + assert before["max_w"] == 16.0 + + # Aim at the REAL bottom-right handle using the canvas geometry the draw + # path publishes (window._aplWidgetGeom). Deriving it from figure padding + # doesn't work for a 2-D panel — the image->canvas mapping depends on the + # zoom/extent state, and the grab radius is only HR=9 px, so a hard-coded + # corner grabs the BODY ('move'), which legitimately ignores the cap and + # would leave this test asserting nothing. + geom = page.evaluate( + """([pid, wid]) => { + const g = window._aplWidgetGeom && window._aplWidgetGeom[pid]; + return g ? (g[wid] || Object.values(g)[0] || null) : null; + }""", [str(plot._id), str(widget.id)]) + assert geom is not None, "widget geometry readback missing" + + canvas = page.evaluate( + """() => { + const c = document.querySelector('canvas'); + const r = c.getBoundingClientRect(); + return {left: r.left, top: r.top}; + }""") + br_x = canvas["left"] + geom["rx"] + geom["rw"] + br_y = canvas["top"] + geom["ry"] + geom["rh"] + + page.mouse.move(br_x, br_y) + page.mouse.down() + page.mouse.move(br_x + 300, br_y + 300, steps=12) # far past the cap + page.mouse.up() + page.wait_for_timeout(80) + + after = _widget_state(page, plot._id) + assert after["w"] > before["w"] + 0.5, \ + "no resize happened — the drag missed the handle" + + assert after["w"] <= 16.0 + 1e-6, f"width {after['w']} exceeded the cap" + assert after["h"] <= 16.0 + 1e-6, f"height {after['h']} exceeded the cap" + assert after["x"] == pytest.approx(before["x"], abs=1e-6), "anchor x moved" + assert after["y"] == pytest.approx(before["y"], abs=1e-6), "anchor y moved" diff --git a/anyplotlib/widgets/_widgets1d.py b/anyplotlib/widgets/_widgets1d.py index 87886947..7916dd04 100644 --- a/anyplotlib/widgets/_widgets1d.py +++ b/anyplotlib/widgets/_widgets1d.py @@ -83,13 +83,26 @@ class RangeWidget(Widget): ``style='fwhm'``. Ignored for ``style='band'``. Default ``0.0``. linewidth : float, optional Line stroke width in px. Default 2. + max_extent : float, optional + Maximum span width in DATA units. When set, the span physically stops + growing at this width while dragging: the edge under the cursor is + pinned and the opposite edge stays put, so the range never exceeds the + cap and never jumps. ``None`` (default) leaves it unbounded. + + Use this when span width costs real work downstream — e.g. an + integrating selector where the width is a number of frames to read. + Enforcing it in the widget makes the limit visible (the edge simply + stops) instead of applying a silent clamp after the fact. """ def __init__(self, push_fn, *, x0, x1, color="#00e5ff", - style: str = "band", y: float = 0.0, linewidth=2): + style: str = "band", y: float = 0.0, linewidth=2, + max_extent=None): super().__init__("range", push_fn, x0=float(x0), x1=float(x1), color=color, style=str(style), y=float(y), - linewidth=float(linewidth)) + linewidth=float(linewidth), + max_extent=(None if max_extent is None + else float(max_extent))) class PointWidget(Widget): diff --git a/anyplotlib/widgets/_widgets2d.py b/anyplotlib/widgets/_widgets2d.py index 2e52b758..47e99180 100644 --- a/anyplotlib/widgets/_widgets2d.py +++ b/anyplotlib/widgets/_widgets2d.py @@ -31,14 +31,30 @@ class RectangleWidget(Widget): Outline stroke width in px. Default 2. show_handles : bool, optional Draw the corner grab handles. Default ``True``. + max_extent : float or (float, float), optional + Maximum width/height in the widget's coordinates. A scalar caps both + axes; a ``(max_w, max_h)`` pair caps them separately. When set, the + rectangle physically stops growing at the cap while dragging — the + dragged corner pins and the opposite corner stays put. ``None`` + (default) leaves it unbounded. + + Use this when the rectangle's area costs real work downstream — e.g. an + integrating ROI whose size is a number of frames to read. """ def __init__(self, push_fn, *, x, y, w, h, color="#00e5ff", - linewidth=2, show_handles=True): + linewidth=2, show_handles=True, max_extent=None): + if max_extent is None: + max_w = max_h = None + elif isinstance(max_extent, (tuple, list)): + max_w, max_h = (float(max_extent[0]), float(max_extent[1])) + else: + max_w = max_h = float(max_extent) super().__init__("rectangle", push_fn, x=float(x), y=float(y), w=float(w), h=float(h), color=color, linewidth=float(linewidth), - show_handles=bool(show_handles)) + show_handles=bool(show_handles), + max_w=max_w, max_h=max_h) class CircleWidget(Widget): diff --git a/upcoming_changes/+narrow_band_grab.bugfix.rst b/upcoming_changes/+narrow_band_grab.bugfix.rst new file mode 100644 index 00000000..5554c780 --- /dev/null +++ b/upcoming_changes/+narrow_band_grab.bugfix.rst @@ -0,0 +1,7 @@ +Fixed the band-style :class:`~anyplotlib.widgets.RangeWidget` being impossible +to drag by its body when narrow. Each edge claimed a fixed ±12 px grab zone, so +a band under ~24 px wide on screen (routine when zoomed out, or when its span is +capped) had no grabbable middle: aiming at the body to translate the band caught +an edge and resized it instead. Each edge now takes at most a third of the +band's width, leaving the middle third for the move handle. Wide bands are +unaffected. diff --git a/upcoming_changes/+widget_max_extent.new_feature.rst b/upcoming_changes/+widget_max_extent.new_feature.rst new file mode 100644 index 00000000..81c785d3 --- /dev/null +++ b/upcoming_changes/+widget_max_extent.new_feature.rst @@ -0,0 +1,11 @@ +Added ``max_extent=`` to :class:`~anyplotlib.widgets.RangeWidget` and +:class:`~anyplotlib.widgets.RectangleWidget` (and the matching +``add_range_widget`` / ``add_rectangle_widget`` factories) — a size cap enforced +*while dragging*, so the widget physically stops growing instead of being +clamped after the fact. The dragged edge/corner pins and the opposite one stays +put, so the selection never jumps under the cursor. ``RangeWidget`` takes a span +width in data units; ``RectangleWidget`` takes a scalar (both axes) or a +``(max_w, max_h)`` pair. Default ``None`` leaves widgets unbounded. + +Use it when a widget's size drives real downstream work — e.g. an integrating +selector whose span is a number of frames to read.