Skip to content

Commit 1b3bd8b

Browse files
committed
Add key-press handling for overlay widget placement and interaction
- Introduce `on_key` callback API for registering key-press handlers. - Implement functionality to add various shapes (rectangles, circles, annuli) at cursor position. - Allow deletion of the last clicked widget using the Delete key. - Update documentation to reflect new key binding features and usage examples.
1 parent 64d6df3 commit 1b3bd8b

4 files changed

Lines changed: 436 additions & 28 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""
2+
Key-Press Widget Placement
3+
==========================
4+
5+
Demonstrates the ``on_key`` callback API: press a key while the plot is
6+
focused to add an overlay widget centred on the current cursor position,
7+
or press **Delete** to remove the last widget you clicked.
8+
9+
**Key bindings**
10+
11+
+-------+---------------------------+
12+
| Key | Action |
13+
+=======+===========================+
14+
| ``q`` | Add a rectangle |
15+
+-------+---------------------------+
16+
| ``w`` | Add a circle |
17+
+-------+---------------------------+
18+
| ``e`` | Add an annulus |
19+
+-------+---------------------------+
20+
| ``Delete`` | Remove last-clicked |
21+
+-------+---------------------------+
22+
23+
**Built-in 2-D shortcuts** (not overridden in this example):
24+
25+
+-------+---------------------------+
26+
| Key | Action |
27+
+=======+===========================+
28+
| ``r`` | Reset zoom / pan |
29+
+-------+---------------------------+
30+
| ``c`` | Toggle colorbar |
31+
+-------+---------------------------+
32+
| ``l`` | Toggle log scale |
33+
+-------+---------------------------+
34+
| ``s`` | Toggle symlog scale |
35+
+-------+---------------------------+
36+
37+
The cursor coordinates reported in the event (``event.img_x``,
38+
``event.img_y``) are in image-pixel space, so widgets are centred exactly
39+
where the cursor was when the key was pressed.
40+
41+
.. note::
42+
Move the mouse over the image first so the plot panel receives focus,
43+
then press a key.
44+
"""
45+
46+
import numpy as np
47+
import anyplotlib as vw
48+
49+
# ── Synthetic test image ──────────────────────────────────────────────────────
50+
rng = np.random.default_rng(0)
51+
N = 256
52+
x = np.linspace(0, 4 * np.pi, N)
53+
XX, YY = np.meshgrid(x, x)
54+
data = np.sin(XX) * np.cos(YY) + 0.15 * rng.standard_normal((N, N))
55+
56+
# ── Figure ────────────────────────────────────────────────────────────────────
57+
fig, ax = vw.subplots(figsize=(520, 520))
58+
plot = ax.imshow(data)
59+
60+
# ── Key handlers ─────────────────────────────────────────────────────────────
61+
62+
@plot.on_key('q')
63+
def add_rectangle(event):
64+
"""Press 'q' — add a rectangle centred on the cursor."""
65+
cx, cy = event.img_x, event.img_y
66+
half_w, half_h = N * 0.08, N * 0.08
67+
plot.add_widget(
68+
"rectangle",
69+
x=cx - half_w, y=cy - half_h,
70+
w=half_w * 2, h=half_h * 2,
71+
color="#ffd54f",
72+
)
73+
74+
75+
@plot.on_key('w')
76+
def add_circle(event):
77+
"""Press 'w' — add a circle centred on the cursor."""
78+
plot.add_widget(
79+
"circle",
80+
cx=event.img_x, cy=event.img_y,
81+
r=N * 0.07,
82+
color="#80cbc4",
83+
)
84+
85+
86+
@plot.on_key('e')
87+
def add_annulus(event):
88+
"""Press 'e' — add an annulus centred on the cursor."""
89+
plot.add_widget(
90+
"annular",
91+
cx=event.img_x, cy=event.img_y,
92+
r_outer=N * 0.12,
93+
r_inner=N * 0.06,
94+
color="#ce93d8",
95+
)
96+
97+
98+
@plot.on_key('Delete')
99+
def delete_last(event):
100+
"""Press Delete — remove the last widget that was clicked / dragged."""
101+
wid = event.last_widget_id
102+
if wid and wid in {w.id for w in plot.list_widgets()}:
103+
plot.remove_widget(wid)
104+
105+
106+
# ── Catch-all handler (optional) — print every registered key press ──────────
107+
108+
@plot.on_key
109+
def log_key(event):
110+
print(f"[on_key] key={event.key!r} img=({event.img_x:.1f}, {event.img_y:.1f})"
111+
f" last_widget={event.last_widget_id!r}")
112+
113+
fig
114+

anyplotlib/callbacks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from dataclasses import dataclass, field
33
from typing import Any, Callable
44

5-
_VALID_EVENT_TYPES = ("on_click", "on_changed", "on_release")
5+
_VALID_EVENT_TYPES = ("on_click", "on_changed", "on_release", "on_key")
66

77

88
@dataclass

anyplotlib/figure_esm.js

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,8 @@ function render({ model, el }) {
312312
state: null,
313313
_hoverSi: -1, _hoverI: -1, // index of hovered marker group / marker (-1 = none)
314314
_hovBar: -1, // index of hovered bar (-1 = none)
315+
lastWidgetId: null, // id of the last clicked/dragged widget (for on_key Delete etc.)
316+
mouseX: 0, mouseY: 0, // last known canvas-relative cursor position
315317
// 2D extras (null for non-2D panels)
316318
cbCanvas: _p2d ? _p2d.cbCanvas : null,
317319
cbCtx: _p2d ? _p2d.cbCtx : null,
@@ -1221,7 +1223,25 @@ function render({ model, el }) {
12211223
_scheduleCommit();
12221224
}, { passive: false });
12231225

1226+
overlayCanvas.addEventListener('mousemove', (e) => {
1227+
const rect = overlayCanvas.getBoundingClientRect();
1228+
p.mouseX = e.clientX - rect.left;
1229+
p.mouseY = e.clientY - rect.top;
1230+
});
1231+
1232+
// Keyboard shortcuts
1233+
// Built-in: r=reset view. Registered keys are forwarded to Python first.
12241234
overlayCanvas.addEventListener('keydown', (e) => {
1235+
const st = p.state; if (!st) return;
1236+
const regKeys = st.registered_keys || [];
1237+
if (regKeys.includes(e.key) || regKeys.includes('*')) {
1238+
_emitEvent(p.id, 'on_key', null, {
1239+
key: e.key,
1240+
last_widget_id: p.lastWidgetId || null,
1241+
mouse_x: p.mouseX, mouse_y: p.mouseY,
1242+
});
1243+
e.preventDefault(); return;
1244+
}
12251245
if (e.key.toLowerCase() === 'r') {
12261246
p.state.azimuth = -60; p.state.elevation = 30; p.state.zoom = 1;
12271247
draw3d(p);
@@ -1677,6 +1697,7 @@ function render({ model, el }) {
16771697
const hit=_ovHitTest2d(mx, my, p);
16781698
if(hit){
16791699
p.ovDrag2d=hit;
1700+
p.lastWidgetId=(st.overlay_widgets||[])[hit.idx]?.id||null;
16801701
overlayCanvas.style.cursor='move';
16811702
e.preventDefault(); return;
16821703
}
@@ -1727,10 +1748,11 @@ function render({ model, el }) {
17271748

17281749
// Status bar + tooltip + widget hover cursor
17291750
overlayCanvas.addEventListener('mousemove',(e)=>{
1730-
if(p.ovDrag2d) return; // handled by document mousemove
1731-
const st=p.state; if(!st) return;
17321751
const rect=overlayCanvas.getBoundingClientRect();
17331752
const mx=e.clientX-rect.left, my=e.clientY-rect.top;
1753+
p.mouseX=mx; p.mouseY=my;
1754+
if(p.ovDrag2d) return; // handled by document mousemove
1755+
const st=p.state; if(!st) return;
17341756

17351757
// Update cursor based on widget hit
17361758
const whit=_ovHitTest2d(mx, my, p);
@@ -1778,8 +1800,28 @@ function render({ model, el }) {
17781800
});
17791801

17801802
// Keyboard shortcuts
1803+
// Built-ins: r=reset zoom, c=colorbar toggle, l=log scale, s=symlog scale.
1804+
// Any key listed in st.registered_keys (or '*' for all keys) is forwarded
1805+
// to Python via on_key and suppresses the matching built-in.
17811806
overlayCanvas.addEventListener('keydown',(e)=>{
17821807
const st=p.state; if(!st) return;
1808+
const regKeys=st.registered_keys||[];
1809+
if(regKeys.includes(e.key)||regKeys.includes('*')){
1810+
const imgW=Math.max(1,p.pw-PAD_L-PAD_R), imgH=Math.max(1,p.ph-PAD_T-PAD_B);
1811+
const [imgX,imgY]=_canvasToImg2d(p.mouseX,p.mouseY,st,imgW,imgH);
1812+
const xArr=st.x_axis||[], yArr=st.y_axis||[];
1813+
const iw=st.image_width||1, ih=st.image_height||1;
1814+
const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/iw):imgX;
1815+
const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/ih):imgY;
1816+
_emitEvent(p.id,'on_key',null,{
1817+
key:e.key,
1818+
last_widget_id:p.lastWidgetId||null,
1819+
mouse_x:p.mouseX, mouse_y:p.mouseY,
1820+
img_x:imgX, img_y:imgY,
1821+
phys_x:physX, phys_y:physY,
1822+
});
1823+
e.preventDefault(); return;
1824+
}
17831825
const key=e.key.toLowerCase();
17841826
if(key==='r'){
17851827
st.zoom=1; st.center_x=0.5; st.center_y=0.5;
@@ -1836,7 +1878,7 @@ function render({ model, el }) {
18361878
if(e.button!==0) return;
18371879
const st=p.state; if(!st) return;
18381880
const hit=_ovHitTest1d(e.clientX-overlayCanvas.getBoundingClientRect().left, e.clientY-overlayCanvas.getBoundingClientRect().top, p);
1839-
if(hit){p.ovDrag=hit;overlayCanvas.style.cursor=(hit.mode==='edge0'||hit.mode==='edge1')?'ew-resize':'move';e.preventDefault();return;}
1881+
if(hit){p.ovDrag=hit;p.lastWidgetId=(p.state.overlay_widgets||[])[hit.idx]?.id||null;overlayCanvas.style.cursor=(hit.mode==='edge0'||hit.mode==='edge1')?'ew-resize':'move';e.preventDefault();return;}
18401882
panStart={mx:e.clientX,x0:st.view_x0,x1:st.view_x1};
18411883
p.isPanning=true;overlayCanvas.style.cursor='grabbing';e.preventDefault();
18421884
});
@@ -1874,15 +1916,34 @@ function render({ model, el }) {
18741916
}
18751917
});
18761918

1919+
// Keyboard shortcuts
1920+
// Built-in: r=reset view. Any key in st.registered_keys (or '*') is
1921+
// forwarded to Python via on_key and suppresses the matching built-in.
18771922
overlayCanvas.addEventListener('keydown',(e)=>{
1878-
if(e.key.toLowerCase()==='r'){const st=p.state;if(!st)return;st.view_x0=0;st.view_x1=1;draw1d(p);model.set(`panel_${p.id}_json`,JSON.stringify(st));model.save_changes();e.preventDefault();}
1923+
const st=p.state; if(!st) return;
1924+
const regKeys=st.registered_keys||[];
1925+
if(regKeys.includes(e.key)||regKeys.includes('*')){
1926+
const r=_plotRect1d(p.pw,p.ph);
1927+
const xArr=st.x_axis||[];
1928+
const frac=_canvasXToFrac1d(p.mouseX,st.view_x0,st.view_x1,r);
1929+
const physX=xArr.length>=2?_fracToX1d(xArr,frac):frac;
1930+
_emitEvent(p.id,'on_key',null,{
1931+
key:e.key,
1932+
last_widget_id:p.lastWidgetId||null,
1933+
mouse_x:p.mouseX, mouse_y:p.mouseY,
1934+
phys_x:physX,
1935+
});
1936+
e.preventDefault(); return;
1937+
}
1938+
if(e.key.toLowerCase()==='r'){st.view_x0=0;st.view_x1=1;draw1d(p);model.set(`panel_${p.id}_json`,JSON.stringify(st));model.save_changes();e.preventDefault();}
18791939
});
18801940
overlayCanvas.tabIndex=0;overlayCanvas.style.outline='none';
18811941
overlayCanvas.addEventListener('mouseenter',()=>overlayCanvas.focus());
18821942
overlayCanvas.addEventListener('mousemove',(e)=>{
18831943
const st=p.state;if(!st)return;
18841944
const rect=overlayCanvas.getBoundingClientRect();
18851945
const mx=e.clientX-rect.left,my=e.clientY-rect.top;
1946+
p.mouseX=mx; p.mouseY=my;
18861947
const r=_plotRect1d(p.pw,p.ph);
18871948
if(mx<r.x||mx>r.x+r.w||my<r.y||my>r.y+r.h){
18881949
p.statusBar.style.display='none';tooltip.style.display='none';
@@ -2618,6 +2679,7 @@ function render({ model, el }) {
26182679
const hit = _ovHitTest1d(e.clientX - rect.left, e.clientY - rect.top, p);
26192680
if (hit) {
26202681
p.ovDrag = hit;
2682+
p.lastWidgetId = (p.state.overlay_widgets || [])[hit.idx]?.id || null;
26212683
overlayCanvas.style.cursor = 'ew-resize';
26222684
e.preventDefault();
26232685
}
@@ -2643,10 +2705,11 @@ function render({ model, el }) {
26432705
});
26442706

26452707
overlayCanvas.addEventListener('mousemove', (e) => {
2646-
if (p.ovDrag) return; // handled by document mousemove during drag
2647-
const st = p.state; if (!st) return;
26482708
const rect = overlayCanvas.getBoundingClientRect();
26492709
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
2710+
p.mouseX = mx; p.mouseY = my;
2711+
if (p.ovDrag) return; // handled by document mousemove during drag
2712+
const st = p.state; if (!st) return;
26502713

26512714
// Overlay widget cursor hint
26522715
const whit = _ovHitTest1d(mx, my, p);
@@ -2693,6 +2756,23 @@ function render({ model, el }) {
26932756
? String(st.x_labels[idx]) : null,
26942757
});
26952758
});
2759+
2760+
// Keyboard: registered_keys forwarded to Python; no built-in bar shortcuts.
2761+
overlayCanvas.addEventListener('keydown', (e) => {
2762+
const st = p.state; if (!st) return;
2763+
const regKeys = st.registered_keys || [];
2764+
if (regKeys.includes(e.key) || regKeys.includes('*')) {
2765+
_emitEvent(p.id, 'on_key', null, {
2766+
key: e.key,
2767+
last_widget_id: p.lastWidgetId || null,
2768+
mouse_x: p.mouseX, mouse_y: p.mouseY,
2769+
});
2770+
e.preventDefault();
2771+
}
2772+
});
2773+
overlayCanvas.tabIndex = 0;
2774+
overlayCanvas.style.outline = 'none';
2775+
overlayCanvas.addEventListener('mouseenter', () => overlayCanvas.focus());
26962776
}
26972777

26982778
// ── generic redraw ────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)