Skip to content

Commit 906cea3

Browse files
DeltaGaclaude
andcommitted
v0.9.0 [Module decomposition + CanvasBindings + Clipboard]
- Extract _bindings.py (CanvasBindings frozen dataclass), _history.py (saves_history), _keyboard.py (KeyboardStateManager), _units.py (mm_to_px / px_to_mm) as standalone private modules; update draggable_rectangle.py and interactive_canvas.py to import from them. - Add CanvasBindings to public API: pass bindings= to InteractiveCanvas to remap any shortcut without subclassing. - Add clipboard system: copy() / cut() / paste() / duplicate() with Ctrl+C/X/V/D bindings, internal _clipboard buffer, full callback hooks. - Add STYLE.md and OPTIMIZATION.md contributor guides. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fe91ed9 commit 906cea3

18 files changed

Lines changed: 1256 additions & 226 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,5 @@ cython_debug/
151151
temp/
152152
tmp/
153153
.ruff_cache/
154-
.claude
154+
.claude
155+
CLAUDE.md

CHANGELOG.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.9.0] - 2026-03-16
11+
12+
### Added
13+
- **`CanvasBindings` frozen dataclass** (`_bindings.py`, public API): Centralises
14+
every tkinter event sequence used by `InteractiveCanvas` and `DraggableRectangle`
15+
as named fields with sensible defaults. Pass a custom instance to
16+
`InteractiveCanvas(bindings=...)` to remap any shortcut (zoom, undo/redo, Delete,
17+
panning, clipboard) without subclassing. `DEFAULT_BINDINGS` is the module-level
18+
singleton used when no custom instance is provided.
19+
- **Clipboard system** (`InteractiveCanvas`): Copy, cut, paste, and duplicate
20+
operations on selected rectangles, backed by an internal `_clipboard` snapshot
21+
list. All four operations are hookable via the existing callback system and
22+
bound to standard keyboard shortcuts from `CanvasBindings`:
23+
- `copy()``Ctrl+C`: snapshots selected rects into the clipboard.
24+
- `cut()``Ctrl+X`: copies then deletes selected rects.
25+
- `paste()``Ctrl+V`: recreates clipboard rects with a configurable offset,
26+
selects the new copies, and saves history.
27+
- `duplicate()``Ctrl+D`: copy + paste in one step.
28+
- **`_history.py`**`saves_history` decorator extracted into its own module;
29+
previously lived inline in `draggable_rectangle.py`.
30+
- **`_keyboard.py`**`KeyboardStateManager` extracted into its own module.
31+
- **`_units.py`**`mm_to_px` / `px_to_mm` pure-function utilities extracted
32+
into their own module; usable without a running canvas.
33+
- **`STYLE.md`** — Python style guide for contributors and AI coding agents.
34+
- **`OPTIMIZATION.md`** — Performance principles for the parent-child GUI pattern.
35+
36+
### Changed
37+
- **Module decomposition**: `draggable_rectangle.py` and `interactive_canvas.py`
38+
refactored to import from the four new private modules. Public API is unchanged;
39+
all symbols are still exported from `__init__.py`.
40+
- **`bindings` parameter on `InteractiveCanvas.__init__`**: New optional keyword
41+
argument; defaults to `CanvasBindings()`. `_create_bindings()` now reads all
42+
event strings from `self._bindings` instead of hardcoded literals.
43+
1044
## [0.8.0] - 2026-03-16
1145

1246
### Changed — Performance Optimizations
@@ -422,7 +456,10 @@ Maintenance and compatibility release improving code quality, testing infrastruc
422456
- Proper package structure with relative imports
423457
- Phase 1 critical bug fixes completed
424458

425-
[Unreleased]: https://github.com/DeltaGa/ctk_interactive_canvas/compare/v0.6.0...HEAD
459+
[Unreleased]: https://github.com/DeltaGa/ctk_interactive_canvas/compare/v0.9.0...HEAD
460+
[0.9.0]: https://github.com/DeltaGa/ctk_interactive_canvas/compare/v0.8.0...v0.9.0
461+
[0.8.0]: https://github.com/DeltaGa/ctk_interactive_canvas/compare/v0.7.0...v0.8.0
462+
[0.7.0]: https://github.com/DeltaGa/ctk_interactive_canvas/compare/v0.6.0...v0.7.0
426463
[0.6.0]: https://github.com/DeltaGa/ctk_interactive_canvas/compare/v0.5.0...v0.6.0
427464
[0.5.0]: https://github.com/DeltaGa/ctk_interactive_canvas/compare/v0.4.3...v0.5.0
428465
[0.4.3]: https://github.com/DeltaGa/ctk_interactive_canvas/compare/v0.4.2...v0.4.3

OPTIMIZATION.md

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
# Optimization Guide — Object-Oriented Parent-Child GUI Systems
2+
3+
Performance principles for Python GUI libraries where a parent widget manages a collection of interactive child objects that receive events at display refresh rate (~60 Hz).
4+
5+
---
6+
7+
## 1. Know Your Hot Path
8+
9+
The **hot path** is any code that executes on every user interaction during a drag or resize — typically 60 times per second. Every microsecond wasted there is perceptible.
10+
11+
Identify the hot path by asking:
12+
- Is this method called on every mouse-move event?
13+
- Is this method called inside a loop over all managed objects?
14+
- Is this method called by a tkinter binding that fires continuously?
15+
16+
If yes to any: treat it as hot-path. Everything else is cold.
17+
18+
**Profile before optimizing.** Use `cProfile` or `py-spy` to confirm where time is actually spent before making changes. Premature optimization is the root of maintenance debt.
19+
20+
```python
21+
import cProfile
22+
cProfile.run("root.mainloop()", sort="cumulative")
23+
```
24+
25+
---
26+
27+
## 2. Attribute Access on the Hot Path
28+
29+
Python attribute lookup follows the MRO chain on every access. On a 60 Hz loop, redundant lookups accumulate.
30+
31+
### Cache repeated attribute access in local variables
32+
33+
```python
34+
# Slow — three global lookups per call
35+
def on_drag(self, event):
36+
self.canvas.move(self.rect, event.x - self.canvas.last_x, ...)
37+
self.canvas.move(self.resize_handle, ...)
38+
39+
# Fast — one lookup, local binding
40+
def on_drag(self, event):
41+
canvas = self.canvas
42+
canvas.move(self.rect, event.x - canvas.last_x, ...)
43+
canvas.move(self.resize_handle, ...)
44+
```
45+
46+
### Replace `hasattr` / `getattr` with cached booleans
47+
48+
`hasattr` and `getattr` are expensive on the hot path. Pre-compute capability flags once in `__init__`:
49+
50+
```python
51+
# In __init__ (cold path — runs once)
52+
self._has_dispatch: bool = hasattr(canvas, "_dispatch_rect")
53+
self._has_move_attached: bool = hasattr(canvas, "move_attached_items")
54+
55+
# In on_drag (hot path — runs at 60 Hz)
56+
if self._has_move_attached:
57+
canvas.move_attached_items(self, dx, dy)
58+
```
59+
60+
This pattern is especially valuable when checking for optional parent capabilities — capabilities that may or may not exist depending on how the parent was configured.
61+
62+
---
63+
64+
## 3. Data Structure Choice for O(1) Lookups
65+
66+
### Membership testing: use `set`, not `list`
67+
68+
```python
69+
# O(n) — scans all items
70+
if rect in my_list:
71+
72+
# O(1) — hash lookup
73+
if id(rect) in my_set:
74+
```
75+
76+
### Reverse maps eliminate scanning
77+
78+
Maintain a reverse dict alongside the forward dict:
79+
80+
```python
81+
self.objects: Dict[int, ChildObject] = {} # item_id → object
82+
self._rect_to_id: Dict[int, int] = {} # id(object) → item_id
83+
self._registered: set[int] = set() # id(object) presence check
84+
```
85+
86+
When you need `item_id` given an object, use `self._rect_to_id.get(id(obj))` — O(1) instead of scanning `self.objects.values()`.
87+
88+
**Keep reverse maps in sync.** Every insertion or deletion must update all related structures atomically.
89+
90+
---
91+
92+
## 4. The Python–C Boundary Cost (tkinter)
93+
94+
Every call to a tkinter method (`canvas.coords()`, `canvas.move()`, `canvas.itemconfig()`) crosses the Python–C boundary. This cost is fixed per call, regardless of payload size.
95+
96+
**Minimize round-trips:**
97+
- Batch reads: collect all coordinates in one pass before beginning writes.
98+
- Avoid calling `canvas.coords(item)` inside a tight loop when you already know the coordinates from a previous step.
99+
- Use `canvas.move(item, dx, dy)` instead of `canvas.coords(item, x1, y1, x2, y2)` when only translating — `move` is implemented as a single C call while `coords` requires computing and passing four new values.
100+
101+
---
102+
103+
## 5. The Mutable Hash Key Pitfall
104+
105+
**Never use an object with a coordinate-based `__hash__` as a dict key when its coordinates will change during iteration.**
106+
107+
Python dicts store items by hash at insertion time. If the hash changes after insertion (because the underlying data changed), lookups will silently fail with `KeyError` even though the object is in the dict:
108+
109+
```python
110+
# BROKEN — rect.__hash__ is coordinate-based
111+
cache = {r: r.canvas.coords(r.rect) for r in rectangles}
112+
for rect in rectangles:
113+
rc = cache[rect] # KeyError after set_topleft_pos changes hash
114+
rect.set_topleft_pos(...) # mutates hash
115+
```
116+
117+
**Fix: use `id(obj)` as the key.** `id()` is the memory address — it never changes for a live object:
118+
119+
```python
120+
# CORRECT
121+
cache = {id(r): r.canvas.coords(r.rect) for r in rectangles}
122+
for rect in rectangles:
123+
rc = cache[id(rect)] # always stable
124+
rect.set_topleft_pos(...)
125+
```
126+
127+
This applies to sets as well: never put a mutable-hash object in a set if its hash can change while it's a member.
128+
129+
The general rule: `__hash__` must return a stable value for the lifetime of the object, or the object must be declared unhashable (`__hash__ = None`).
130+
131+
---
132+
133+
## 6. Reconciliation vs. Destroy-and-Rebuild
134+
135+
When restoring state (undo/redo, reload), prefer **in-place reconciliation** over destroying all objects and rebuilding from scratch.
136+
137+
Destroy-and-rebuild:
138+
- Invalidates every external reference to child objects.
139+
- Forces all caller code to re-fetch references after every state change.
140+
- Triggers unnecessary canvas item creation/destruction overhead.
141+
142+
Reconciliation (React-style diffing):
143+
1. Update items that exist in both old and new state — in-place mutations preserve Python object identity.
144+
2. Delete items present in current state but absent from the target state.
145+
3. Create items present in the target state but absent from current state.
146+
147+
```python
148+
current_ids = set(self.objects.keys())
149+
target_ids = set(state.keys())
150+
151+
for item_id in current_ids & target_ids:
152+
self._update_in_place(self.objects[item_id], state[item_id])
153+
154+
for item_id in current_ids - target_ids:
155+
self._delete(item_id)
156+
157+
for item_id in target_ids - current_ids:
158+
self._create_from_state(item_id, state[item_id])
159+
```
160+
161+
This is the same pattern used by game-engine entity managers and virtual DOM renderers — it is the correct pattern for any system where object identity matters to external holders.
162+
163+
---
164+
165+
## 7. Coordinate Arithmetic Optimization
166+
167+
Division is slower than multiplication. When you repeatedly divide by the same value (e.g. a zoom scale factor), pre-compute the inverse and multiply:
168+
169+
```python
170+
# In a setup or property setter (cold)
171+
self._zoom_inv = 1.0 / self.zoom_scale
172+
173+
# In coordinate conversion (hot path)
174+
logical_x = canvas_x * self._zoom_inv # multiply — faster than divide
175+
```
176+
177+
For identity transforms (zoom == 1.0, no pan offset), add an early-return guard:
178+
179+
```python
180+
def canvas_to_logical(self, x: float, y: float) -> tuple[float, float]:
181+
if self._offset_x == 0.0 and self._offset_y == 0.0 and self._zoom_inv == 1.0:
182+
return x, y
183+
return (x - self._offset_x) * self._zoom_inv, (y - self._offset_y) * self._zoom_inv
184+
```
185+
186+
---
187+
188+
## 8. Lazy vs. Eager Initialization
189+
190+
| Pattern | When to use |
191+
|---|---|
192+
| Eager (in `__init__`) | Structures always needed; cheap to create; eliminates `getattr` guards |
193+
| Lazy (on first use) | Structures rarely needed; expensive to create (e.g. image thumbnails) |
194+
195+
**Default to eager** for anything that guards a hot-path conditional. The tiny startup cost is paid once; the eliminated `getattr` overhead is paid every frame.
196+
197+
```python
198+
# Eager — no runtime guards needed
199+
self._thumbnail_cache: Dict[str, Image] = {}
200+
201+
# Lazy — only compute on first access
202+
@property
203+
def thumbnail(self) -> Image:
204+
if self._thumbnail is None:
205+
self._thumbnail = self._compute_thumbnail()
206+
return self._thumbnail
207+
```
208+
209+
---
210+
211+
## 9. Selection Area Optimization
212+
213+
When hit-testing a rectangular selection region against a large set of objects, use the underlying engine's spatial query instead of looping in Python:
214+
215+
```python
216+
# Slow — O(n) Python loop
217+
selected = [r for r in all_rects if overlaps(r, selection_bbox)]
218+
219+
# Fast — O(log n) or hardware-accelerated via tkinter's spatial index
220+
enclosed_ids = set(canvas.find_enclosed(x1, y1, x2, y2))
221+
```
222+
223+
Convert the result to a `set` immediately if you need repeated membership tests.
224+
225+
---
226+
227+
## 10. Memory Layout and Object Count
228+
229+
- Minimize the number of canvas items per logical object. Each canvas item has overhead in tkinter's internal C structures.
230+
- Delete canvas items explicitly when a logical object is deleted (`canvas.delete(item_id)`). Tkinter does not garbage-collect canvas items when the Python object goes out of scope.
231+
- Use `canvas.delete("all")` only on full resets — never inside per-frame or per-object loops.
232+
233+
---
234+
235+
## 11. Profiling Checklist
236+
237+
Before declaring an optimization complete:
238+
239+
- [ ] Measured baseline FPS or latency with `cProfile` or `time.perf_counter`.
240+
- [ ] Confirmed the optimization targets code that is actually on the measured hot path.
241+
- [ ] Re-measured after the change to confirm improvement.
242+
- [ ] No regression in correctness (all tests pass).
243+
- [ ] No increase in code complexity that outweighs the gain.
244+
245+
**Do not optimize until you have measured. Do not stop optimizing until you have re-measured.**

0 commit comments

Comments
 (0)