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:
+110
-11
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user