Skip to content

Commit 1f468b7

Browse files
committed
perf(tooltips): Add a tooltip performance benchmark
1 parent c1fa385 commit 1f468b7

4 files changed

Lines changed: 682 additions & 0 deletions

File tree

ARCHITECTURE_TOOLTIP_SYSTEM.md

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
# Tooltip System Architecture & Timer Cleanup
2+
3+
## Overview
4+
5+
The tooltip system provides cross-platform, performant tooltips for the tkinter-based GUI.
6+
This document covers the architecture changes from commit 247255a and the critical timer
7+
cleanup mechanisms.
8+
9+
## Key Design Decisions
10+
11+
### 1. Lazy Loading Architecture
12+
13+
**Problem**: Large parameter tables (100+ parameters) created hundreds of tooltip Toplevel
14+
windows during initialization, causing:
15+
16+
- High memory overhead (each Tk Toplevel window ~8-10KB minimum)
17+
- Slow application startup time
18+
- Unnecessary GUI objects for non-hovered parameters
19+
20+
**Solution**: Tooltips are now created on-demand when the user hovers over a widget.
21+
22+
```text
23+
Timeline:
24+
+-----------------+ +-----------------+ +-----------------+
25+
| Tooltip Init | | Mouse Hover | | Show Tooltip |
26+
| (No GUI) | --> | Schedule Show | --> | Create Window |
27+
| Bindings only | | (250ms delay) | | Display |
28+
+-----------------+ +-----------------+ +-----------------+
29+
~0.3ms per item Timer fires ~2-3ms per item
30+
```
31+
32+
### 2. Cross-Platform Unification
33+
34+
**Before**: Platform-specific implementations
35+
36+
- macOS: Deferred creation, scheduled show/hide
37+
- Linux/Windows: Pre-created Toplevel, show/hide on demand
38+
39+
**After**: Unified implementation across all platforms
40+
41+
- All platforms use timer-based scheduling
42+
- Timer-based show/hide prevents flicker when moving through dense UIs
43+
- Identical behavior ensures consistent UX
44+
45+
### 3. Timer Management
46+
47+
The tooltip system uses three types of timers:
48+
49+
```text
50+
+--------------------------------------------------------------+
51+
| Timer Types and Lifecycle |
52+
+--------------------------------------------------------------+
53+
| |
54+
| "show" timer: |
55+
| - Scheduled by: schedule_show() |
56+
| - Fires: TOOLTIP_SHOW_DELAY_MS (250ms) |
57+
| - Executes: create_show() |
58+
| - Cleaned by: _cancel_show(), _on_widget_destroy() |
59+
| |
60+
| "hide" timer: |
61+
| - Removed in the current design |
62+
| - Tooltip is destroyed immediately by destroy_hide() |
63+
| - No hide-delay behavior remains |
64+
| |
65+
| "alpha" timer (macOS only): |
66+
| - Scheduled by: create_show() after deiconify |
67+
| - Fires: 50ms after deiconify |
68+
| - Executes: _activate_alpha() - fades in tooltip |
69+
| - Cleaned by: _cancel_timer("alpha"), _on_widget_destroy() |
70+
| |
71+
+--------------------------------------------------------------+
72+
```
73+
74+
## Critical Timer Cleanup Mechanisms
75+
76+
### 1. Widget Destruction Handler (`_on_widget_destroy`)
77+
78+
This is the **critical safety mechanism** that prevents timer leaks:
79+
80+
```python
81+
def _on_widget_destroy(self, event: Optional[tk.Event] = None) -> None:
82+
"""Stop any active timers if the widget is destroyed."""
83+
self._cancel_show() # Cancel "show" timer if pending
84+
self._cancel_timer("alpha") # Cancel "alpha" timer if pending
85+
86+
if self.tooltip:
87+
with contextlib.suppress(tk.TclError):
88+
self.tooltip.destroy()
89+
self.tooltip = None
90+
```
91+
92+
**Why this is critical**:
93+
94+
- If the widget is destroyed while a timer is pending, Tk will try to fire a callback
95+
on a non-existent widget
96+
- This causes `tk.TclError: invalid command name "..."`
97+
- The handler cleans up ALL timers before widget destruction
98+
99+
**Binding registered in `__init__`**:
100+
101+
```python
102+
self.widget.bind("<Destroy>", self._on_widget_destroy, "+")
103+
```
104+
105+
### 2. Timer Cancellation with Error Suppression
106+
107+
The `_cancel_timer()` method handles already-fired or stale timers gracefully:
108+
109+
```python
110+
def _cancel_timer(self, name: str) -> None:
111+
"""Safely cancel a timer and remove it."""
112+
timer_id = self.timers.pop(name, None)
113+
if timer_id:
114+
with contextlib.suppress(tk.TclError):
115+
self.widget.after_cancel(timer_id)
116+
```
117+
118+
**Edge cases handled**:
119+
120+
1. Timer ID in dict but Tk already fired it -- `tk.TclError` suppressed
121+
2. Widget destroyed before cancellation -- `tk.TclError` suppressed
122+
3. Multiple cancellation calls -- `timers.pop(name, None)` returns None safely
123+
4. Stale timer IDs from previous interactions -- Tk's `after_cancel` silently ignores
124+
125+
### 3. Mutual Timer Cancellation Pattern
126+
127+
When entering a widget that has a pending show timer:
128+
129+
```python
130+
def schedule_show(self, _event: Optional[tk.Event] = None) -> None:
131+
"""Delay tooltip creation slightly to avoid flicker during pointer movement."""
132+
self._cancel_show() # Cancel any pending show
133+
self.timers["show"] = self.widget.after(TOOLTIP_SHOW_DELAY_MS, self.create_show)
134+
```
135+
136+
**Purpose**: Prevents flicker when user quickly moves mouse through dense UI elements:
137+
138+
- Mouse leave triggers immediate destroy via `destroy_hide()`
139+
- Mouse re-enters before tooltip appears
140+
- Previous show timer canceled, new show timer scheduled
141+
- Result: Tooltip appears cleanly without flashing
142+
143+
### 4. Pointer Position Validation
144+
145+
The `create_show()` method validates pointer position before creating tooltip:
146+
147+
```python
148+
def create_show(self, _event: Optional[tk.Event] = None) -> None:
149+
"""Create and show the tooltip when the pointer is still over the widget."""
150+
try:
151+
pointed = self.widget.winfo_containing(
152+
self.widget.winfo_pointerx(), self.widget.winfo_pointery()
153+
)
154+
widget_path = str(self.widget)
155+
pointed_path = "" if pointed is None else str(pointed)
156+
if pointed is None or (
157+
pointed_path != widget_path
158+
and not pointed_path.startswith(widget_path + ".")
159+
):
160+
return # Pointer no longer over widget
161+
except tk.TclError:
162+
return # Widget destroyed during timer execution
163+
```
164+
165+
**Why this is necessary**:
166+
167+
- Timer fires even if widget is destroyed (TclError caught)
168+
- Pointer may have left widget during 250ms delay
169+
- Prevents creating orphaned tooltip windows
170+
171+
## Race Conditions Prevented
172+
173+
### 1. Multiple Concurrent Tooltips
174+
175+
**Problem**: If `create_show()` is called multiple times (e.g., multiple Enter events queued)
176+
177+
**Solution**: Check if tooltip already exists:
178+
179+
```python
180+
if self.tooltip:
181+
Tooltip._active_tooltip = self
182+
return # Avoid redundant tooltip creation
183+
```
184+
185+
### 2. Widget Destroyed During Timer Delay
186+
187+
**Problem**: User closes dialog while tooltip timer is pending
188+
189+
**Solution**: `_on_widget_destroy()` cancels all timers before widget is destroyed
190+
191+
### 3. Multiple Enter/Leave Rapid Succession
192+
193+
**Problem**: User quickly moves mouse through dense parameter table
194+
195+
**Solution**: Mutual cancellation in `schedule_show()`:
196+
197+
```python
198+
self._cancel_show() # Previous show timer canceled
199+
```
200+
201+
## Performance Impact
202+
203+
### Initialization Phase
204+
205+
- **Before**: Each tooltip created a Tk Toplevel window (8-10KB memory, tens of milliseconds per tooltip)
206+
- **After**: Only Tooltip wrapper objects are created lazily
207+
- **Measured result**: ~0.21ms per tooltip for lazy initialization
208+
- **Old eager baseline**: ~12ms per tooltip for up-front Toplevel creation
209+
- **Result**: Lazy init reduces startup overhead by an order of magnitude for large tables
210+
211+
### Hover Phase
212+
213+
- **Lazy first hover**: Measured ~285ms per tooltip with real event-loop and visible windows
214+
- Includes the 250ms `TOOLTIP_SHOW_DELAY_MS` timer
215+
- Includes actual Tk event handling, window manager work, and painting
216+
- **Eager show**: Measured ~0.11ms per tooltip when the window already exists
217+
- **Result**: Lazy loading shifts cost from startup to first hover
218+
- good when many tooltips are never hovered
219+
- less ideal when every tooltip is visited immediately
220+
221+
### Trade-off Analysis
222+
223+
- Large parameter tables load much faster
224+
- Memory usage significantly reduced
225+
- Better responsiveness during navigation after first hover
226+
- First tooltip hover includes the 250ms show delay plus creation cost
227+
- Users moving mouse over parameters for the first time see a noticeable, but intentional, delay
228+
229+
## Verification Checklist
230+
231+
For commit review and testing:
232+
233+
- [x] All timers cleaned up on widget destruction
234+
- [x] Stale timer IDs handled gracefully
235+
- [x] Pointer position validated before creating tooltip
236+
- [x] No memory leaks from orphaned Tk objects
237+
- [x] Consistent behavior across macOS, Linux, Windows
238+
- [x] Edge case: widget destroyed during create_show()
239+
- [x] Edge case: multiple Enter/Leave in rapid succession
240+
- [x] Edge case: timer fires after widget destroyed
241+
- [x] Performance benchmark shows expected improvements
242+
- [x] Backward compatibility with existing Tooltip API
243+
244+
## Testing Strategy
245+
246+
### Unit Tests (in `bdd_frontend_tkinter_show.py`)
247+
248+
- Timer cancellation on widget destruction
249+
- Pointer position validation
250+
- Redundant tooltip prevention
251+
- Cross-platform behavior consistency
252+
253+
### Integration Tests
254+
255+
- Large parameter table (100+ tooltips) initialization
256+
- Rapid mouse movement through dense parameter table
257+
- Widget destruction with pending timers
258+
- Application shutdown with active tooltips
259+
260+
### Performance Benchmarks (in `benchmarks/tooltip_performance.py`)
261+
262+
- Lazy vs eager initialization
263+
- Lazy first-hover creation vs eager show
264+
- Cleanup and destruction
265+
- Real Tk event-loop and visible-window timing
266+
267+
## Debugging Tips
268+
269+
### If tooltips don't appear after hover
270+
271+
1. Check `TOOLTIP_SHOW_DELAY_MS` constant (currently 250ms)
272+
2. Verify `create_show()` isn't returning early due to pointer check
273+
3. Check browser console for `tk.TclError` in timer execution
274+
275+
### If memory usage is high
276+
277+
1. Check for orphaned tooltip windows with `tooltip.tooltip is not None`
278+
2. Verify `_on_widget_destroy()` is being called
279+
3. Check timer dict for stale entries: `tooltip.timers`
280+
281+
### If tooltips flicker during mouse movement
282+
283+
1. Check `schedule_show()` is canceling previous timers
284+
2. Increase `TOOLTIP_SHOW_DELAY_MS` to reduce flicker
285+
3. Verify pointer validation logic in `create_show()`
286+
287+
## Related Code Files
288+
289+
- `frontend_tkinter_show.py`: Main tooltip implementation
290+
- `bdd_frontend_tkinter_show.py`: Comprehensive test suite
291+
- `test_tooltip_performance_benchmark.py`: Performance validation

0 commit comments

Comments
 (0)