diff --git a/README.md b/README.md index 00122a3..9972c2f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app_state.py b/app_state.py index 74fa15b..c1ccf23 100644 --- a/app_state.py +++ b/app_state.py @@ -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 diff --git a/main.py b/main.py index 02c1949..188c5ea 100644 --- a/main.py +++ b/main.py @@ -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"", 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) diff --git a/wiresheet.py b/wiresheet.py index e25dd61..3216c5e 100644 --- a/wiresheet.py +++ b/wiresheet.py @@ -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):