Zoom to Fit, with a toolbar icon beside the zoom controls

Fits the whole painted sheet in the visible canvas and scrolls it into view,
from the View menu, Ctrl/Cmd+9, or the four-corners button after the zoom
percentage. The ladder gains 25% and 33% at the bottom, so a wide sheet has
somewhere to fit into.

Fit is the one scale that is not on the ladder -- a sheet fits at whatever
scale it fits at -- so zoom_step now takes the first level above or below the
scale in force rather than the neighbour of the nearest rung.

The sheet is measured from item coordinates rather than canvas.bbox(), which
is inflated by the outline stroke: a stroke is the same two pixels at every
zoom, so a bbox divided back to 1:1 reports a slightly smaller sheet the
further in it is zoomed, and fitting an already fitted sheet crept from 288%
to 291% instead of standing still. Anything painted left of or above the
canvas origin is measured from the origin, since no amount of scrolling brings
it into view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 23:14:46 +03:00
co-authored by Claude Opus 5
parent 781138e3dc
commit ad8396ef7b
4 changed files with 125 additions and 8 deletions
+11 -2
View File
@@ -87,6 +87,7 @@ Undoing back to the depth of the last save clears the dirty tint rather than lea
| `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`/`Cmd` + `9` | Zoom to Fit: show the whole sheet |
| `Ctrl` + wheel | Zoom around the pointer |
Both `Command-` and `Control-` are bound for each, so the same build works on macOS and
@@ -99,8 +100,16 @@ 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
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
**Zoom In**, **Zoom Out** and **Zoom 100%** step a ladder of scales from 25% to 300%,
with the zoom buttons, the percentage and the fit icon sitting beside the navigation
arrows in the toolbar. **Zoom to Fit** is the one thing that lands between the ladder's
rungs -- a sheet fits at whatever scale it fits at -- and stepping in or out from there
rejoins the ladder. It measures the sheet from item coordinates rather than
`canvas.bbox()`, which is inflated by the outline stroke: a stroke is the same two pixels
at every zoom, so a bbox divided back to 1:1 reports a slightly smaller sheet the further
in it is zoomed, and fitting an already fitted sheet would creep instead of standing
still. Anything painted left of or above the canvas origin is measured from the origin
instead, since no amount of scrolling can bring it into view. 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
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser
import os
VERSION = "0.0.0.033"
VERSION = "0.0.0.034"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
+34
View File
@@ -176,6 +176,34 @@ def do_zoom_reset(event=None):
return "break"
def do_zoom_fit(event=None):
"""Zoom so the whole sheet is visible, and scroll it into view.
The fitted scale is not one of the ladder's, so the percentage on the toolbar is
where it is read off; stepping in or out from there rejoins the ladder.
"""
canvas = app_state.canvas
level = wiresheet.fit_zoom_level()
bounds = wiresheet.sheet_content_bounds()
if level is None or bounds is None:
app_state.set_status("Nothing to fit — open a sheet with components first.")
return "break"
applied = wiresheet.set_zoom(level)
update_zoom_label()
# Bring the sheet's own top-left corner to the top-left of the view, since a fitted
# sheet is measured from there rather than from the canvas origin.
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, (bounds[0] * applied - wiresheet.FIT_PADDING) / span_x))
canvas.yview_moveto(max(0.0, (bounds[1] * applied - wiresheet.FIT_PADDING) / span_y))
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:
@@ -1251,6 +1279,8 @@ SHORTCUTS = [
("KP_Subtract", do_zoom_out),
("0", do_zoom_reset),
("KP_0", do_zoom_reset),
("9", do_zoom_fit),
("KP_9", do_zoom_fit),
]
for key, handler in SHORTCUTS:
window.bind_all(f"<Command-{key}>", handler)
@@ -1313,6 +1343,7 @@ 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_command(label="Zoom to Fit", command=do_zoom_fit, accelerator=f"{accel_mod}+9")
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)
@@ -1357,6 +1388,9 @@ 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)
# U+26F6, the four-corners frame every other editor marks "fit to window" with.
ttk.Button(toolbar, text="⛶", width=3, command=do_zoom_fit).pack(side=tk.LEFT, padx=(3, 0))
paned_window = ttk.PanedWindow(window, orient=tk.HORIZONTAL)
paned_window.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
+79 -5
View File
@@ -60,8 +60,13 @@ TIDY_MARGIN = 30 # minimum clear space kept from the top and left canvas
# 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)
# are the round percentages people expect. Zoom to Fit is the one thing that lands
# between them, and the ladder's ends are the range it is allowed to reach.
ZOOM_LEVELS = (0.25, 0.33, 0.5, 0.67, 0.8, 1.0, 1.25, 1.5, 2.0, 3.0)
# Breathing room left around a sheet that has just been fitted, in painted pixels, so
# the outermost box is not flush against the canvas edge.
FIT_PADDING = 20
# 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.
@@ -1010,10 +1015,79 @@ def redraw_links():
def zoom_step(direction):
"""The next zoom level in (+1) or out (-1); the current one at either end of the ladder."""
"""The next zoom level in (+1) or out (-1); the current one at either end of the ladder.
The scale in force need not be one of the ladder's -- Zoom to Fit lands wherever the
sheet happens to fit -- so stepping in takes the first level above it and stepping
out the first below.
"""
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)]
if direction > 0:
above = [level for level in ZOOM_LEVELS if level > current + 1e-9]
return above[0] if above else ZOOM_LEVELS[-1]
below = [level for level in ZOOM_LEVELS if level < current - 1e-9]
return below[-1] if below else ZOOM_LEVELS[0]
def sheet_content_bounds():
"""The 1:1 rectangle the painted sheet occupies, or None if nothing is painted.
Wires are measured along with the boxes: one that loops back can reach further left
or higher than any box does, and a fit that cut it off would be hiding wiring.
Measured off item coordinates rather than canvas.bbox(), which is inflated by the
outline stroke. A stroke is the same two pixels at every zoom, so a bbox divided
back to 1:1 reports a slightly smaller sheet the further in it is zoomed -- enough
that fitting an already fitted sheet would creep instead of standing still.
"""
canvas = app_state.canvas
if canvas is None:
return None
xs, ys = [], []
for comp_tag in app_state.canvas_comp_map:
bounds = get_box_bounds(comp_tag)
if bounds:
xs.extend((bounds[0], bounds[2]))
ys.extend((bounds[1], bounds[3]))
for item in canvas.find_withtag("link"):
coords = canvas.coords(item)
xs.extend(coords[0::2])
ys.extend(coords[1::2])
if not xs or not ys:
return None
zoom = app_state.zoom
return (min(xs) / zoom, min(ys) / zoom, max(xs) / zoom, max(ys) / zoom)
def fit_zoom_level():
"""The scale at which the whole painted sheet fits the visible canvas, or None.
Not snapped to the ladder: a sheet fits at whatever scale it fits at, and set_zoom
holds that between the ladder's ends. Stepping in or out from there carries on up
and down the ladder as usual.
"""
canvas = app_state.canvas
bounds = sheet_content_bounds()
if bounds is None:
return None
# A canvas cannot scroll left of or above its origin, so anything out there cannot
# be brought into view however far the sheet is zoomed out. The fit is measured from
# the origin in that case rather than from the content's own corner.
left = max(0.0, bounds[0])
top = max(0.0, bounds[1])
content_width = bounds[2] - left
content_height = bounds[3] - top
view_width = canvas.winfo_width() - 2 * FIT_PADDING
view_height = canvas.winfo_height() - 2 * FIT_PADDING
if min(content_width, content_height, view_width, view_height) <= 0:
return None
return min(view_width / content_width, view_height / content_height)
def set_zoom(level):