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
+110 -11
View File
@@ -58,6 +58,15 @@ META_SCALE_MAX = 12
TIDY_GAP = 30 # minimum clear space kept between two boxes
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):
"""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
# 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():
"""The three fonts a component box is drawn with. Box sizes are measured from these."""
name_size, type_size, slot_size = FONT_SIZES
return (
tkfont.Font(family="Segoe UI", size=9, weight="bold"),
tkfont.Font(family="Segoe UI", size=7),
tkfont.Font(family="Segoe UI", size=8),
tkfont.Font(family="Segoe UI", size=name_size, weight="bold"),
tkfont.Font(family="Segoe UI", size=type_size),
tkfont.Font(family="Segoe UI", size=slot_size),
)
@@ -167,17 +182,24 @@ def measure_box(comp, fonts, linked_slot_index):
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.delete("grid")
width = 3000
height = 3000
step = app_state.GRID_SIZE * app_state.zoom
if step < MIN_GRID_STEP:
return
for x in range(0, width, app_state.GRID_SIZE):
canvas.create_line(x, 0, x, height, fill="#e0e0e0", tags="grid")
for y in range(0, height, app_state.GRID_SIZE):
canvas.create_line(0, y, width, y, fill="#e0e0e0", tags="grid")
span = 3000 * app_state.zoom
for index in range(int(span // step) + 1):
offset = index * step
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")
@@ -926,8 +948,85 @@ def render_components(components_to_render):
fill="#b8b8b8", tags=("component", comp_tag)
)
canvas.config(scrollregion=(0, 0, max_reached_x + 200, max_reached_y + 200))
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():