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

1"""Keyboard navigation support for TraceKit interactive visualizations. 

2 

3This module provides keyboard navigation handlers for interactive plots 

4following WCAG 2.1 accessibility guidelines. 

5 

6 

7Example: 

8 >>> from tracekit.visualization.keyboard import KeyboardNavigator 

9 >>> navigator = KeyboardNavigator(fig, ax) 

10 >>> navigator.connect() 

11 

12References: 

13 - WCAG 2.1 Guideline 2.1: Keyboard Accessible 

14 - WAI-ARIA Authoring Practices for interactive widgets 

15""" 

16 

17from __future__ import annotations 

18 

19from typing import TYPE_CHECKING, Any 

20 

21from matplotlib.axes import Axes 

22 

23if TYPE_CHECKING: 

24 from matplotlib.backend_bases import KeyEvent 

25 from matplotlib.figure import Figure 

26 from matplotlib.text import Text 

27 

28 

29class KeyboardNavigator: 

30 """Keyboard navigation handler for interactive plots. 

31 

32 : Interactive visualizations are fully keyboard-navigable. 

33 Provides keyboard shortcuts for panning, zooming, and navigation. 

34 

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 

42 

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) 

48 

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() 

57 

58 References: 

59 ACC-003: Keyboard Navigation for Interactive Plots 

60 """ 

61 

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. 

71 

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 

83 

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 } 

91 

92 self._connection_id: int | None = None 

93 self._help_text: Text | None = None 

94 

95 def connect(self) -> None: 

96 """Connect keyboard event handler to the figure. 

97 

98 : Tab navigates between plot elements. 

99 Registers keyboard event callback with matplotlib. 

100 

101 Example: 

102 >>> nav.connect() 

103 >>> # Now keyboard events are handled 

104 

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() 

110 

111 def disconnect(self) -> None: 

112 """Disconnect keyboard event handler. 

113 

114 Example: 

115 >>> nav.disconnect() 

116 

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 

123 

124 def _on_key(self, event: KeyEvent) -> None: 

125 """Handle keyboard events. 

126 

127 : Arrow keys move cursors, Enter selects/activates, Escape closes. 

128 

129 Args: 

130 event: Matplotlib keyboard event 

131 

132 References: 

133 ACC-003: Keyboard Navigation for Interactive Plots 

134 """ 

135 if event.key is None: 

136 return 

137 

138 ax = self.axes_list[self.current_axes_index] 

139 

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) 

149 

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) 

155 

156 # Reset view 

157 elif event.key == "home": 

158 self._reset_view(ax) 

159 

160 # Tab: Cycle through axes 

161 elif event.key == "tab": 

162 self._cycle_axes(reverse=event.key == "shift+tab") 

163 

164 # Help 

165 elif event.key == "?": 

166 self._show_help() 

167 

168 # Escape: Close help or reset 

169 elif event.key == "escape": 

170 self._hide_help() 

171 

172 else: 

173 return # Unhandled key 

174 

175 self.fig.canvas.draw_idle() 

176 

177 def _pan(self, ax: Axes, dx: float, dy: float) -> None: 

178 """Pan the axes view. 

179 

180 Args: 

181 ax: Axes to pan 

182 dx: Horizontal pan as fraction of range 

183 dy: Vertical pan as fraction of range 

184 

185 References: 

186 ACC-003: Arrow keys move cursors 

187 """ 

188 xlim = ax.get_xlim() 

189 ylim = ax.get_ylim() 

190 

191 x_range = xlim[1] - xlim[0] 

192 y_range = ylim[1] - ylim[0] 

193 

194 x_shift = dx * x_range 

195 y_shift = dy * y_range 

196 

197 ax.set_xlim(xlim[0] + x_shift, xlim[1] + x_shift) 

198 ax.set_ylim(ylim[0] + y_shift, ylim[1] + y_shift) 

199 

200 def _zoom(self, ax: Axes, factor: float) -> None: 

201 """Zoom the axes view. 

202 

203 Args: 

204 ax: Axes to zoom 

205 factor: Zoom factor (>1 zooms out, <1 zooms in) 

206 

207 References: 

208 ACC-003: +/- keys zoom in/out 

209 """ 

210 xlim = ax.get_xlim() 

211 ylim = ax.get_ylim() 

212 

213 x_center = (xlim[0] + xlim[1]) / 2 

214 y_center = (ylim[0] + ylim[1]) / 2 

215 

216 x_range = (xlim[1] - xlim[0]) * factor 

217 y_range = (ylim[1] - ylim[0]) * factor 

218 

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) 

221 

222 def _reset_view(self, ax: Axes) -> None: 

223 """Reset axes to original view. 

224 

225 Args: 

226 ax: Axes to reset 

227 

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"]) 

235 

236 def _cycle_axes(self, reverse: bool = False) -> None: 

237 """Cycle through axes with Tab key. 

238 

239 Args: 

240 reverse: Cycle backwards (Shift+Tab) 

241 

242 References: 

243 ACC-003: Tab navigates between plot elements 

244 """ 

245 if len(self.axes_list) <= 1: 

246 return 

247 

248 # Remove highlight from current axes 

249 self._unhighlight_axes(self.axes_list[self.current_axes_index]) 

250 

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) 

256 

257 # Highlight new axes 

258 self._highlight_active_axes() 

259 

260 def _highlight_active_axes(self) -> None: 

261 """Highlight the currently active axes. 

262 

263 : Focus indicators for selected element. 

264 

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) 

273 

274 def _unhighlight_axes(self, ax: Axes) -> None: 

275 """Remove highlight from axes. 

276 

277 Args: 

278 ax: Axes to unhighlight 

279 

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) 

286 

287 def _show_help(self) -> None: 

288 """Show keyboard shortcuts help. 

289 

290 : ? key shows keyboard shortcuts help. 

291 

292 References: 

293 ACC-003: ? key shows keyboard shortcuts help 

294 """ 

295 if self._help_text is not None: 

296 return # Already showing 

297 

298 help_message = """ 

299Keyboard Navigation Help 

300======================== 

301 

302Pan: 

303 ←/→/↑/↓ Pan left/right/up/down 

304 

305Zoom: 

306 +/- Zoom in/out 

307 

308View: 

309 Home Reset to full view 

310 

311Navigation: 

312 Tab Next subplot 

313 Shift+Tab Previous subplot 

314 

315Help: 

316 ? Show this help 

317 Esc Close help 

318 

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() 

340 

341 def _hide_help(self) -> None: 

342 """Hide keyboard shortcuts help. 

343 

344 : Escape closes modals/menus. 

345 

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() 

353 

354 

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. 

361 

362 Convenience function to create and connect a KeyboardNavigator. 

363 

364 Args: 

365 fig: Matplotlib figure 

366 axes: Axes to navigate (default: all axes in figure) 

367 **kwargs: Additional arguments passed to KeyboardNavigator 

368 

369 Returns: 

370 Connected KeyboardNavigator instance 

371 

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() 

379 

380 References: 

381 ACC-003: Keyboard Navigation for Interactive Plots 

382 """ 

383 if axes is None: 

384 axes = fig.get_axes() 

385 

386 navigator = KeyboardNavigator(fig, axes, **kwargs) 

387 navigator.connect() 

388 return navigator 

389 

390 

391__all__ = [ 

392 "KeyboardNavigator", 

393 "enable_keyboard_navigation", 

394]