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
+14
View File
@@ -85,6 +85,9 @@ Undoing back to the depth of the last save clears the dirty tint rather than lea
| `Ctrl`/`Cmd` + `B` | Show or hide the tree/palette column | | `Ctrl`/`Cmd` + `B` | Show or hide the tree/palette column |
| `Ctrl` + `W` | Tidy Layout: re-flow the open sheet | | `Ctrl` + `W` | Tidy Layout: re-flow the open sheet |
| `Alt` + `Left` / `Alt` + `Right` | Back and forward through the sheets opened this session | | `Alt` + `Left` / `Alt` + `Right` | Back and forward through the sheets opened this session |
| `Ctrl`/`Cmd` + `+` / `-` | Zoom the sheet in and out |
| `Ctrl`/`Cmd` + `0` | Zoom back to 100% |
| `Ctrl` + wheel | Zoom around the pointer |
Both `Command-` and `Control-` are bound for each, so the same build works on macOS and Both `Command-` and `Control-` are bound for each, so the same build works on macOS and
Windows; menu accelerators render as `Cmd` or `Ctrl` per platform. Tidy Layout is the Windows; menu accelerators render as `Cmd` or `Ctrl` per platform. Tidy Layout is the
@@ -96,6 +99,17 @@ exception, on `Control-` alone: `Cmd` + `W` closes the window on macOS.
it back at its configured width. ttk has no hide flag for a pane, so the column is dropped it back at its configured width. ttk has no hide flag for a pane, so the column is dropped
from the paned window and re-inserted at the front. from the paned window and re-inserted at the front.
**Zoom In**, **Zoom Out** and **Zoom 100%** step a ladder of scales, with the two zoom
buttons and the percentage sitting beside the navigation arrows in the toolbar. The sheet
is painted and routed at 1:1 -- box metrics, wire stubs, lane gaps, clearances and knob
radii are all tuned in those pixels -- and `apply_sheet_zoom` scales the finished picture,
so a zoomed sheet keeps exactly the proportions of a 1:1 one and the router makes the same
decisions at every scale. Text scales because the three box fonts are resized after the
boxes were measured with them. `redraw_links()` is what a drag or a drop calls: it puts
the boxes back to 1:1 for the routing pass and scales the result again, since the router
reads box positions straight off the canvas. Canvas coordinates therefore carry the zoom,
which is why the drag, release and drop handlers snap against `meta_scale * zoom`.
**Back** and **Forward** walk the sheets opened this session, mirrored by the two arrow **Back** and **Forward** walk the sheets opened this session, mirrored by the two arrow
buttons in the toolbar. The history records the element whose children are painted -- the buttons in the toolbar. The history records the element whose children are painted -- the
same thing `app_state.current_sheet_parent` holds -- rather than a tree item id, because same thing `app_state.current_sheet_parent` holds -- rather than a tree item id, because
+2 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser import configparser
import os import os
VERSION = "0.0.0.032" VERSION = "0.0.0.033"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10 DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True DEFAULT_ADD_TIMESTAMP = True
@@ -53,6 +53,7 @@ link_mark_element = None # Component marked as a link endpoint via "Link Ma
selected_link_tag = None # Canvas tag of the currently selected (red-highlighted) link wire selected_link_tag = None # Canvas tag of the currently selected (red-highlighted) link wire
unroutable_link_count = 0 # Links drawn as knobs because no wire could avoid a component unroutable_link_count = 0 # Links drawn as knobs because no wire could avoid a component
meta_scale = DEFAULT_GRID_SIZE # Pixels per meta coordinate step on the painted sheet meta_scale = DEFAULT_GRID_SIZE # Pixels per meta coordinate step on the painted sheet
zoom = 1.0 # Scale the open sheet is painted at; 1.0 is one pixel per pixel
knob_link_ends = {} # wire tag -> (x1, y1, x2, y2) for a link shown as two knobs knob_link_ends = {} # wire tag -> (x1, y1, x2, y2) for a link shown as two knobs
canvas_link_map = {} # Canvas link tag -> ET.Element (<link>) canvas_link_map = {} # Canvas link tag -> ET.Element (<link>)
xml_root_element = None # Keep a persistent root reference to redraw tree layouts cleanly xml_root_element = None # Keep a persistent root reference to redraw tree layouts cleanly
+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. # function that touches them checks first.
btn_back = None btn_back = None
btn_forward = None btn_forward = None
zoom_label = None
menu_view = None menu_view = None
show_left_column_var = None show_left_column_var = None
@@ -119,6 +120,71 @@ def do_tidy_layout(event=None):
return "break" 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 --- # --- Event handlers ---
def on_tree_select(event): 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. # Bring the clicked box clear of any neighbour it happens to sit under.
canvas.tag_raise(group_tag) canvas.tag_raise(group_tag)
wiresheet.draw_links() wiresheet.redraw_links()
def do_link_mark(): 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"]) 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["x"] = cx
app_state.drag_data["y"] = cy app_state.drag_data["y"] = cy
wiresheet.draw_links() wiresheet.redraw_links()
return return
# Snap to whole meta steps, which is the grid on an ordinary sheet and a multiple of # 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. # it on one too wide for meta's range -- a position between steps cannot be stored.
step = app_state.meta_scale # Canvas coordinates carry the sheet's zoom, so the step and the margin do too.
margin = wiresheet.layout_margin(step) 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 # 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. # 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) canvas.move(group_tag, shift_x, shift_y)
# Wires are not part of the dragged group, so they are re-routed against the # Wires are not part of the dragged group, so they are re-routed against the
# new positions. # new positions.
wiresheet.draw_links() wiresheet.redraw_links()
def on_canvas_release(event): def on_canvas_release(event):
@@ -550,14 +617,14 @@ def on_canvas_release(event):
return return
canvas = app_state.canvas canvas = app_state.canvas
step = app_state.meta_scale step = app_state.meta_scale * app_state.zoom
group_tag = app_state.drag_data["tag"] group_tag = app_state.drag_data["tag"]
origin = wiresheet.get_box_origin(group_tag) origin = wiresheet.get_box_origin(group_tag)
if origin is not None: if origin is not None:
current_x, current_y = origin 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_x = max(margin, round(current_x / step) * step)
snapped_y = max(margin, round(current_y / 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.mark_dirty()
app_state.drag_data["tag"] = None app_state.drag_data["tag"] = None
wiresheet.draw_links() wiresheet.redraw_links()
def fit_tree_column(tree): def fit_tree_column(tree):
@@ -1174,6 +1241,16 @@ SHORTCUTS = [
("z", do_undo), ("z", do_undo),
("y", do_redo), ("y", do_redo),
("b", toggle_left_column), ("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: for key, handler in SHORTCUTS:
window.bind_all(f"<Command-{key}>", handler) 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" label=MENU_FORWARD_LABEL, command=go_forward, accelerator="Alt+Right", state="disabled"
) )
menu_view.add_separator() 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_view.add_command(label="Tidy Layout", command=do_tidy_layout, accelerator="Ctrl+W")
menu_bar.add_cascade(label="View", menu=menu_view) 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 = ttk.Button(toolbar, text="▶", width=3, command=go_forward, state="disabled")
btn_forward.pack(side=tk.LEFT, padx=(3, 0)) 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 = ttk.PanedWindow(window, orient=tk.HORIZONTAL)
paned_window.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) 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_x = canvas.canvasx(event.x_root - canvas.winfo_rootx())
drop_y = canvas.canvasy(event.y_root - canvas.winfo_rooty()) drop_y = canvas.canvasy(event.y_root - canvas.winfo_rooty())
step = app_state.meta_scale step = app_state.meta_scale * app_state.zoom
min_step = wiresheet.layout_margin(step) // step 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_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))) 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-3>", on_canvas_right_click)
canvas.bind("<ButtonPress-2>", 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) paned_window.add(frame_editor, weight=3)
undo.set_refresh_callback(refresh_all_views) undo.set_refresh_callback(refresh_all_views)
+110 -11
View File
@@ -58,6 +58,15 @@ META_SCALE_MAX = 12
TIDY_GAP = 30 # minimum clear space kept between two boxes TIDY_GAP = 30 # minimum clear space kept between two boxes
TIDY_MARGIN = 30 # minimum clear space kept from the top and left canvas edges TIDY_MARGIN = 30 # minimum clear space kept from the top and left canvas edges
# The scales the sheet can be painted at. A ladder rather than a free multiplier, so
# stepping out and back in lands on 1.0 exactly instead of on 0.9999, and so the labels
# are the round percentages people expect.
ZOOM_LEVELS = (0.5, 0.67, 0.8, 1.0, 1.25, 1.5, 2.0, 3.0)
# Below this a grid line is closer to its neighbour than the line is wide, which reads
# as a grey wash rather than a grid, so the grid is left off instead.
MIN_GRID_STEP = 3
def snap_up(value, grid_size): def snap_up(value, grid_size):
"""Smallest multiple of grid_size that is >= value; grid lines sit on multiples. """Smallest multiple of grid_size that is >= value; grid lines sit on multiples.
@@ -115,12 +124,18 @@ def fit_text(text, font, max_px):
return (trimmed + ellipsis) if trimmed else ellipsis return (trimmed + ellipsis) if trimmed else ellipsis
# Name, type and slot text, at 1:1. Zoom scales the fonts from these, which is why the
# sizes are named here rather than written into sheet_fonts alone.
FONT_SIZES = (9, 7, 8)
def sheet_fonts(): def sheet_fonts():
"""The three fonts a component box is drawn with. Box sizes are measured from these.""" """The three fonts a component box is drawn with. Box sizes are measured from these."""
name_size, type_size, slot_size = FONT_SIZES
return ( return (
tkfont.Font(family="Segoe UI", size=9, weight="bold"), tkfont.Font(family="Segoe UI", size=name_size, weight="bold"),
tkfont.Font(family="Segoe UI", size=7), tkfont.Font(family="Segoe UI", size=type_size),
tkfont.Font(family="Segoe UI", size=8), tkfont.Font(family="Segoe UI", size=slot_size),
) )
@@ -167,17 +182,24 @@ def measure_box(comp, fonts, linked_slot_index):
def draw_grid(): def draw_grid():
"""Draws a grid onto the Canvas based on the scrollable region.""" """Draws a grid onto the Canvas based on the scrollable region.
Drawn at the sheet's zoom, so a box still lands on a line at every scale. The area
covered is the same patch of sheet whatever the zoom, which is why the span is
scaled along with the step.
"""
canvas = app_state.canvas canvas = app_state.canvas
canvas.delete("grid") canvas.delete("grid")
width = 3000 step = app_state.GRID_SIZE * app_state.zoom
height = 3000 if step < MIN_GRID_STEP:
return
for x in range(0, width, app_state.GRID_SIZE): span = 3000 * app_state.zoom
canvas.create_line(x, 0, x, height, fill="#e0e0e0", tags="grid") for index in range(int(span // step) + 1):
for y in range(0, height, app_state.GRID_SIZE): offset = index * step
canvas.create_line(0, y, width, y, fill="#e0e0e0", tags="grid") canvas.create_line(offset, 0, offset, span, fill="#e0e0e0", tags="grid")
canvas.create_line(0, offset, span, offset, fill="#e0e0e0", tags="grid")
canvas.tag_lower("grid") canvas.tag_lower("grid")
@@ -926,8 +948,85 @@ def render_components(components_to_render):
fill="#b8b8b8", tags=("component", comp_tag) fill="#b8b8b8", tags=("component", comp_tag)
) )
canvas.config(scrollregion=(0, 0, max_reached_x + 200, max_reached_y + 200))
draw_links() draw_links()
apply_sheet_zoom(fonts, max_reached_x + 200, max_reached_y + 200)
def apply_sheet_zoom(fonts, extent_x, extent_y):
"""Scales the finished sheet to app_state.zoom and sizes the canvas to match.
Painting and routing both work at 1:1, because that is where every metric here is
tuned -- box header and row heights, wire stubs, lane gaps, knob radii -- so zoom is
applied to the finished picture rather than threaded through each of them. Text
scales with it because the three fonts the boxes were drawn with are resized after
they were measured with, which is what keeps a zoomed box in exactly the proportions
of a 1:1 one.
"""
canvas = app_state.canvas
zoom = app_state.zoom
if zoom != 1.0:
canvas.scale("component", 0, 0, zoom, zoom)
canvas.scale("link", 0, 0, zoom, zoom)
for font, base_size in zip(fonts, FONT_SIZES):
font.configure(size=max(1, round(base_size * zoom)))
scale_knob_ends(zoom)
canvas.config(scrollregion=(0, 0, extent_x * zoom, extent_y * zoom))
draw_grid()
def scale_knob_ends(zoom):
"""Brings the cached knob-to-knob ends onto the painted scale.
They are recorded while the wires are routed, which happens at 1:1, and are read
back at click time to draw the dashed line across a selected pair of knobs.
"""
for wire_tag, ends in app_state.knob_link_ends.items():
app_state.knob_link_ends[wire_tag] = tuple(value * zoom for value in ends)
def redraw_links():
"""Re-routes the wires of a sheet that is already painted, at whatever zoom it is at.
The router reads box positions straight off the canvas and measures its clearances
in 1:1 pixels, so the boxes are put back to that scale for the duration and the
whole picture, new wires included, is scaled again afterwards. Callers that have
just moved a box -- a drag, a drop -- want this rather than draw_links.
"""
zoom = app_state.zoom
if zoom == 1.0:
draw_links()
return
canvas = app_state.canvas
canvas.scale("component", 0, 0, 1.0 / zoom, 1.0 / zoom)
draw_links()
canvas.scale("component", 0, 0, zoom, zoom)
canvas.scale("link", 0, 0, zoom, zoom)
scale_knob_ends(zoom)
def zoom_step(direction):
"""The next zoom level in (+1) or out (-1); the current one at either end of the ladder."""
current = app_state.zoom
nearest = min(range(len(ZOOM_LEVELS)), key=lambda i: abs(ZOOM_LEVELS[i] - current))
return ZOOM_LEVELS[min(max(nearest + direction, 0), len(ZOOM_LEVELS) - 1)]
def set_zoom(level):
"""Repaints the open sheet at this scale. Returns the level actually applied."""
level = min(max(level, ZOOM_LEVELS[0]), ZOOM_LEVELS[-1])
if level != app_state.zoom:
app_state.zoom = level
if app_state.xml_root_element is None:
# Nothing painted yet, but the grid is already on screen.
draw_grid()
else:
render_current_sheet()
return app_state.zoom
def render_current_sheet(): def render_current_sheet():