Wiresheet zoom, from the View menu, the toolbar and Ctrl+wheel

A ladder of scales from 50% to 300%, stepped by Zoom In / Zoom Out / Zoom 100%,
by the two buttons beside the navigation arrows, and by Ctrl and the wheel,
which holds the point under the pointer still. A menu or keyboard zoom holds
the middle of the view instead, so it closes in on what is already being
looked at.

Painting and routing stay at 1:1, because that is where every metric is tuned:
box header and row heights, wire stubs, lane gaps, standoffs, knob radii. Zoom
scales the finished picture, so a zoomed sheet keeps the proportions of a 1:1
one and the router reaches the same decisions at every scale. Text comes along
because the three box fonts are resized once the boxes have been measured with
them.

redraw_links replaces the direct draw_links calls a drag or a drop makes: the
router reads box positions off the canvas, so the boxes go back to 1:1 for the
routing pass and the result is scaled again. Canvas coordinates carry the zoom,
which is why the drag, release and drop handlers now snap against
meta_scale * zoom -- a box dragged the same distance across the screen lands on
the same meta step at 50%, 100% and 200%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 23:03:35 +03:00
co-authored by Claude Opus 5
parent 0166be3454
commit 781138e3dc
4 changed files with 229 additions and 22 deletions
+103 -10
View File
@@ -52,6 +52,7 @@ _history_file_path = None # the file sheet_history was recorded against
# function that touches them checks first.
btn_back = None
btn_forward = None
zoom_label = None
menu_view = None
show_left_column_var = None
@@ -119,6 +120,71 @@ def do_tidy_layout(event=None):
return "break"
# --- Zoom ---
def update_zoom_label():
"""Puts the painted scale on the toolbar as a percentage."""
if zoom_label is not None:
zoom_label.config(text=f"{round(app_state.zoom * 100)}%")
def zoom_to(level, anchor=None):
"""Repaints the sheet at this zoom, holding one point of it still on screen.
``anchor`` is a canvas-relative (x, y) to keep under the same pixel -- the pointer,
for a wheel zoom -- and defaults to the middle of the visible area, so a menu or
keyboard zoom closes in on what is already being looked at rather than on the
top-left corner.
"""
canvas = app_state.canvas
before = app_state.zoom
if anchor is None:
anchor = (canvas.winfo_width() / 2, canvas.winfo_height() / 2)
# Where the held point sits on the unscaled sheet.
sheet_x = canvas.canvasx(anchor[0]) / before
sheet_y = canvas.canvasy(anchor[1]) / before
after = wiresheet.set_zoom(level)
update_zoom_label()
if after == before:
return
# Scroll the repainted sheet back under the held point. The scrollregion starts at
# the origin, so its far corner is the span the view fractions are measured against.
region = str(canvas.cget("scrollregion")).split()
if len(region) == 4:
span_x = float(region[2]) or 1.0
span_y = float(region[3]) or 1.0
canvas.xview_moveto(max(0.0, (sheet_x * after - anchor[0]) / span_x))
canvas.yview_moveto(max(0.0, (sheet_y * after - anchor[1]) / span_y))
def do_zoom_in(event=None):
zoom_to(wiresheet.zoom_step(1))
return "break"
def do_zoom_out(event=None):
zoom_to(wiresheet.zoom_step(-1))
return "break"
def do_zoom_reset(event=None):
zoom_to(1.0)
return "break"
def on_canvas_wheel_zoom(event):
"""Ctrl + wheel over the sheet, the gesture every other editor uses for zoom."""
if not event.delta:
return "break"
step = 1 if event.delta > 0 else -1
zoom_to(wiresheet.zoom_step(step), anchor=(event.x, event.y))
return "break"
# --- Event handlers ---
def on_tree_select(event):
@@ -251,7 +317,7 @@ def on_canvas_click(event):
# Bring the clicked box clear of any neighbour it happens to sit under.
canvas.tag_raise(group_tag)
wiresheet.draw_links()
wiresheet.redraw_links()
def do_link_mark():
@@ -520,13 +586,14 @@ def on_canvas_drag(event):
canvas.move(group_tag, cx - app_state.drag_data["x"], cy - app_state.drag_data["y"])
app_state.drag_data["x"] = cx
app_state.drag_data["y"] = cy
wiresheet.draw_links()
wiresheet.redraw_links()
return
# Snap to whole meta steps, which is the grid on an ordinary sheet and a multiple of
# it on one too wide for meta's range -- a position between steps cannot be stored.
step = app_state.meta_scale
margin = wiresheet.layout_margin(step)
# Canvas coordinates carry the sheet's zoom, so the step and the margin do too.
step = app_state.meta_scale * app_state.zoom
margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom
# Follow the pointer, but land the box on a step every step of the way, and keep it
# out of the margin strip along the top and left canvas edges.
@@ -542,7 +609,7 @@ def on_canvas_drag(event):
canvas.move(group_tag, shift_x, shift_y)
# Wires are not part of the dragged group, so they are re-routed against the
# new positions.
wiresheet.draw_links()
wiresheet.redraw_links()
def on_canvas_release(event):
@@ -550,14 +617,14 @@ def on_canvas_release(event):
return
canvas = app_state.canvas
step = app_state.meta_scale
step = app_state.meta_scale * app_state.zoom
group_tag = app_state.drag_data["tag"]
origin = wiresheet.get_box_origin(group_tag)
if origin is not None:
current_x, current_y = origin
margin = wiresheet.layout_margin(step)
margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom
snapped_x = max(margin, round(current_x / step) * step)
snapped_y = max(margin, round(current_y / step) * step)
@@ -589,7 +656,7 @@ def on_canvas_release(event):
app_state.mark_dirty()
app_state.drag_data["tag"] = None
wiresheet.draw_links()
wiresheet.redraw_links()
def fit_tree_column(tree):
@@ -1174,6 +1241,16 @@ SHORTCUTS = [
("z", do_undo),
("y", do_redo),
("b", toggle_left_column),
# Zoom, in every spelling the key can arrive as: with and without Shift, and on the
# numeric keypad.
("plus", do_zoom_in),
("equal", do_zoom_in),
("KP_Add", do_zoom_in),
("minus", do_zoom_out),
("underscore", do_zoom_out),
("KP_Subtract", do_zoom_out),
("0", do_zoom_reset),
("KP_0", do_zoom_reset),
]
for key, handler in SHORTCUTS:
window.bind_all(f"<Command-{key}>", handler)
@@ -1233,6 +1310,10 @@ menu_view.add_command(
label=MENU_FORWARD_LABEL, command=go_forward, accelerator="Alt+Right", state="disabled"
)
menu_view.add_separator()
menu_view.add_command(label="Zoom In", command=do_zoom_in, accelerator=f"{accel_mod}++")
menu_view.add_command(label="Zoom Out", command=do_zoom_out, accelerator=f"{accel_mod}+-")
menu_view.add_command(label="Zoom 100%", command=do_zoom_reset, accelerator=f"{accel_mod}+0")
menu_view.add_separator()
menu_view.add_command(label="Tidy Layout", command=do_tidy_layout, accelerator="Ctrl+W")
menu_bar.add_cascade(label="View", menu=menu_view)
@@ -1269,6 +1350,13 @@ btn_back.pack(side=tk.LEFT)
btn_forward = ttk.Button(toolbar, text="▶", width=3, command=go_forward, state="disabled")
btn_forward.pack(side=tk.LEFT, padx=(3, 0))
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=8, pady=2)
ttk.Button(toolbar, text="−", width=3, command=do_zoom_out).pack(side=tk.LEFT)
zoom_label = ttk.Label(toolbar, text="100%", width=6, anchor="center")
zoom_label.pack(side=tk.LEFT, padx=1)
ttk.Button(toolbar, text="+", width=3, command=do_zoom_in).pack(side=tk.LEFT)
paned_window = ttk.PanedWindow(window, orient=tk.HORIZONTAL)
paned_window.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
@@ -1461,8 +1549,8 @@ def on_palette_release(event):
drop_x = canvas.canvasx(event.x_root - canvas.winfo_rootx())
drop_y = canvas.canvasy(event.y_root - canvas.winfo_rooty())
step = app_state.meta_scale
min_step = wiresheet.layout_margin(step) // step
step = app_state.meta_scale * app_state.zoom
min_step = wiresheet.layout_margin(app_state.meta_scale) // app_state.meta_scale
grid_x = max(min_step, min(wiresheet.META_COORD_MAX, round(drop_x / step)))
grid_y = max(min_step, min(wiresheet.META_COORD_MAX, round(drop_y / step)))
@@ -1511,6 +1599,11 @@ canvas.bind("<Configure>", lambda e: wiresheet.draw_grid())
canvas.bind("<ButtonPress-3>", on_canvas_right_click)
canvas.bind("<ButtonPress-2>", on_canvas_right_click)
# Wheel zoom. Windows and macOS report a delta; X11 sends button 4 and 5 instead.
canvas.bind("<Control-MouseWheel>", on_canvas_wheel_zoom)
canvas.bind("<Control-Button-4>", do_zoom_in)
canvas.bind("<Control-Button-5>", do_zoom_out)
paned_window.add(frame_editor, weight=3)
undo.set_refresh_callback(refresh_all_views)