Coverage for src / tracekit / visualization / keyboard.py: 100%
106 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Keyboard navigation support for TraceKit interactive visualizations.
3This module provides keyboard navigation handlers for interactive plots
4following WCAG 2.1 accessibility guidelines.
7Example:
8 >>> from tracekit.visualization.keyboard import KeyboardNavigator
9 >>> navigator = KeyboardNavigator(fig, ax)
10 >>> navigator.connect()
12References:
13 - WCAG 2.1 Guideline 2.1: Keyboard Accessible
14 - WAI-ARIA Authoring Practices for interactive widgets
15"""
17from __future__ import annotations
19from typing import TYPE_CHECKING, Any
21from matplotlib.axes import Axes
23if TYPE_CHECKING:
24 from matplotlib.backend_bases import KeyEvent
25 from matplotlib.figure import Figure
26 from matplotlib.text import Text
29class KeyboardNavigator:
30 """Keyboard navigation handler for interactive plots.
32 : Interactive visualizations are fully keyboard-navigable.
33 Provides keyboard shortcuts for panning, zooming, and navigation.
35 Keyboard shortcuts:
36 - Arrow keys: Pan left/right/up/down
37 - +/-: Zoom in/out
38 - Home: Reset to full view
39 - Tab: Cycle through subplots
40 - Escape: Cancel current operation
41 - ?: Show help
43 Args:
44 fig: Matplotlib figure
45 axes: Matplotlib axes or list of axes
46 pan_step: Pan step as fraction of current range (default: 0.1)
47 zoom_factor: Zoom factor for +/- keys (default: 1.2)
49 Example:
50 >>> import matplotlib.pyplot as plt
51 >>> from tracekit.visualization.keyboard import KeyboardNavigator
52 >>> fig, ax = plt.subplots()
53 >>> ax.plot([1, 2, 3], [1, 4, 2])
54 >>> nav = KeyboardNavigator(fig, ax)
55 >>> nav.connect()
56 >>> plt.show()
58 References:
59 ACC-003: Keyboard Navigation for Interactive Plots
60 """
62 def __init__(
63 self,
64 fig: Figure,
65 axes: Axes | list[Axes],
66 *,
67 pan_step: float = 0.1,
68 zoom_factor: float = 1.2,
69 ) -> None:
70 """Initialize keyboard navigator.
72 Args:
73 fig: Matplotlib figure
74 axes: Single axes or list of axes
75 pan_step: Pan step as fraction of range
76 zoom_factor: Zoom factor for zoom operations
77 """
78 self.fig = fig
79 self.axes_list = [axes] if isinstance(axes, Axes) else list(axes)
80 self.current_axes_index = 0
81 self.pan_step = pan_step
82 self.zoom_factor = zoom_factor
84 # Store original limits for reset
85 self.original_limits = {}
86 for i, ax in enumerate(self.axes_list):
87 self.original_limits[i] = {
88 "xlim": ax.get_xlim(),
89 "ylim": ax.get_ylim(),
90 }
92 self._connection_id: int | None = None
93 self._help_text: Text | None = None
95 def connect(self) -> None:
96 """Connect keyboard event handler to the figure.
98 : Tab navigates between plot elements.
99 Registers keyboard event callback with matplotlib.
101 Example:
102 >>> nav.connect()
103 >>> # Now keyboard events are handled
105 References:
106 ACC-003: Keyboard Navigation for Interactive Plots
107 """
108 self._connection_id = self.fig.canvas.mpl_connect("key_press_event", self._on_key) # type: ignore[arg-type]
109 self._highlight_active_axes()
111 def disconnect(self) -> None:
112 """Disconnect keyboard event handler.
114 Example:
115 >>> nav.disconnect()
117 References:
118 ACC-003: Keyboard Navigation for Interactive Plots
119 """
120 if self._connection_id is not None:
121 self.fig.canvas.mpl_disconnect(self._connection_id)
122 self._connection_id = None
124 def _on_key(self, event: KeyEvent) -> None:
125 """Handle keyboard events.
127 : Arrow keys move cursors, Enter selects/activates, Escape closes.
129 Args:
130 event: Matplotlib keyboard event
132 References:
133 ACC-003: Keyboard Navigation for Interactive Plots
134 """
135 if event.key is None:
136 return
138 ax = self.axes_list[self.current_axes_index]
140 # Arrow keys: Pan
141 if event.key == "left":
142 self._pan(ax, dx=-self.pan_step, dy=0)
143 elif event.key == "right":
144 self._pan(ax, dx=self.pan_step, dy=0)
145 elif event.key == "up":
146 self._pan(ax, dx=0, dy=self.pan_step)
147 elif event.key == "down":
148 self._pan(ax, dx=0, dy=-self.pan_step)
150 # Zoom
151 elif event.key == "+" or event.key == "=":
152 self._zoom(ax, factor=1.0 / self.zoom_factor)
153 elif event.key == "-" or event.key == "_":
154 self._zoom(ax, factor=self.zoom_factor)
156 # Reset view
157 elif event.key == "home":
158 self._reset_view(ax)
160 # Tab: Cycle through axes
161 elif event.key == "tab":
162 self._cycle_axes(reverse=event.key == "shift+tab")
164 # Help
165 elif event.key == "?":
166 self._show_help()
168 # Escape: Close help or reset
169 elif event.key == "escape":
170 self._hide_help()
172 else:
173 return # Unhandled key
175 self.fig.canvas.draw_idle()
177 def _pan(self, ax: Axes, dx: float, dy: float) -> None:
178 """Pan the axes view.
180 Args:
181 ax: Axes to pan
182 dx: Horizontal pan as fraction of range
183 dy: Vertical pan as fraction of range
185 References:
186 ACC-003: Arrow keys move cursors
187 """
188 xlim = ax.get_xlim()
189 ylim = ax.get_ylim()
191 x_range = xlim[1] - xlim[0]
192 y_range = ylim[1] - ylim[0]
194 x_shift = dx * x_range
195 y_shift = dy * y_range
197 ax.set_xlim(xlim[0] + x_shift, xlim[1] + x_shift)
198 ax.set_ylim(ylim[0] + y_shift, ylim[1] + y_shift)
200 def _zoom(self, ax: Axes, factor: float) -> None:
201 """Zoom the axes view.
203 Args:
204 ax: Axes to zoom
205 factor: Zoom factor (>1 zooms out, <1 zooms in)
207 References:
208 ACC-003: +/- keys zoom in/out
209 """
210 xlim = ax.get_xlim()
211 ylim = ax.get_ylim()
213 x_center = (xlim[0] + xlim[1]) / 2
214 y_center = (ylim[0] + ylim[1]) / 2
216 x_range = (xlim[1] - xlim[0]) * factor
217 y_range = (ylim[1] - ylim[0]) * factor
219 ax.set_xlim(x_center - x_range / 2, x_center + x_range / 2)
220 ax.set_ylim(y_center - y_range / 2, y_center + y_range / 2)
222 def _reset_view(self, ax: Axes) -> None:
223 """Reset axes to original view.
225 Args:
226 ax: Axes to reset
228 References:
229 ACC-003: Home resets to full view
230 """
231 idx = self.axes_list.index(ax)
232 original = self.original_limits[idx]
233 ax.set_xlim(original["xlim"])
234 ax.set_ylim(original["ylim"])
236 def _cycle_axes(self, reverse: bool = False) -> None:
237 """Cycle through axes with Tab key.
239 Args:
240 reverse: Cycle backwards (Shift+Tab)
242 References:
243 ACC-003: Tab navigates between plot elements
244 """
245 if len(self.axes_list) <= 1:
246 return
248 # Remove highlight from current axes
249 self._unhighlight_axes(self.axes_list[self.current_axes_index])
251 # Move to next/previous axes
252 if reverse:
253 self.current_axes_index = (self.current_axes_index - 1) % len(self.axes_list)
254 else:
255 self.current_axes_index = (self.current_axes_index + 1) % len(self.axes_list)
257 # Highlight new axes
258 self._highlight_active_axes()
260 def _highlight_active_axes(self) -> None:
261 """Highlight the currently active axes.
263 : Focus indicators for selected element.
265 References:
266 ACC-003: Focus indicators for selected element
267 """
268 ax = self.axes_list[self.current_axes_index]
269 # Add visual focus indicator
270 for spine in ax.spines.values():
271 spine.set_edgecolor("red")
272 spine.set_linewidth(2)
274 def _unhighlight_axes(self, ax: Axes) -> None:
275 """Remove highlight from axes.
277 Args:
278 ax: Axes to unhighlight
280 References:
281 ACC-003: Focus indicators for selected element
282 """
283 for spine in ax.spines.values():
284 spine.set_edgecolor("black")
285 spine.set_linewidth(1)
287 def _show_help(self) -> None:
288 """Show keyboard shortcuts help.
290 : ? key shows keyboard shortcuts help.
292 References:
293 ACC-003: ? key shows keyboard shortcuts help
294 """
295 if self._help_text is not None:
296 return # Already showing
298 help_message = """
299Keyboard Navigation Help
300========================
302Pan:
303 ←/→/↑/↓ Pan left/right/up/down
305Zoom:
306 +/- Zoom in/out
308View:
309 Home Reset to full view
311Navigation:
312 Tab Next subplot
313 Shift+Tab Previous subplot
315Help:
316 ? Show this help
317 Esc Close help
319Press Esc to close this help.
320"""
321 # Add text box to figure
322 self._help_text = self.fig.text(
323 0.5,
324 0.5,
325 help_message,
326 ha="center",
327 va="center",
328 fontsize=10,
329 family="monospace",
330 bbox={
331 "boxstyle": "round",
332 "facecolor": "wheat",
333 "alpha": 0.95,
334 "edgecolor": "black",
335 "linewidth": 2,
336 },
337 zorder=1000,
338 )
339 self.fig.canvas.draw_idle()
341 def _hide_help(self) -> None:
342 """Hide keyboard shortcuts help.
344 : Escape closes modals/menus.
346 References:
347 ACC-003: Escape closes modals/menus
348 """
349 if self._help_text is not None:
350 self._help_text.remove()
351 self._help_text = None
352 self.fig.canvas.draw_idle()
355def enable_keyboard_navigation(
356 fig: Figure,
357 axes: Axes | list[Axes] | None = None,
358 **kwargs: Any,
359) -> KeyboardNavigator:
360 """Enable keyboard navigation for a figure.
362 Convenience function to create and connect a KeyboardNavigator.
364 Args:
365 fig: Matplotlib figure
366 axes: Axes to navigate (default: all axes in figure)
367 **kwargs: Additional arguments passed to KeyboardNavigator
369 Returns:
370 Connected KeyboardNavigator instance
372 Example:
373 >>> import matplotlib.pyplot as plt
374 >>> from tracekit.visualization.keyboard import enable_keyboard_navigation
375 >>> fig, ax = plt.subplots()
376 >>> ax.plot([1, 2, 3], [1, 4, 2])
377 >>> nav = enable_keyboard_navigation(fig)
378 >>> plt.show()
380 References:
381 ACC-003: Keyboard Navigation for Interactive Plots
382 """
383 if axes is None:
384 axes = fig.get_axes()
386 navigator = KeyboardNavigator(fig, axes, **kwargs)
387 navigator.connect()
388 return navigator
391__all__ = [
392 "KeyboardNavigator",
393 "enable_keyboard_navigation",
394]