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:
@@ -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` + `W` | Tidy Layout: re-flow the open sheet |
|
||||
| `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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
+2
-1
@@ -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.032"
|
||||
VERSION = "0.0.0.033"
|
||||
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||
DEFAULT_GRID_SIZE = 10
|
||||
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
|
||||
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
|
||||
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
|
||||
canvas_link_map = {} # Canvas link tag -> ET.Element (<link>)
|
||||
xml_root_element = None # Keep a persistent root reference to redraw tree layouts cleanly
|
||||
|
||||
@@ -52,6 +52,7 @@ _history_file_path = None # the file sheet_history was recorded against
|
||||
# function that touches them checks first.
|
||||
btn_back = None
|
||||
btn_forward = None
|
||||
zoom_label = None
|
||||
menu_view = None
|
||||
show_left_column_var = None
|
||||
|
||||
@@ -119,6 +120,71 @@ def do_tidy_layout(event=None):
|
||||
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 ---
|
||||
|
||||
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.
|
||||
canvas.tag_raise(group_tag)
|
||||
wiresheet.draw_links()
|
||||
wiresheet.redraw_links()
|
||||
|
||||
|
||||
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"])
|
||||
app_state.drag_data["x"] = cx
|
||||
app_state.drag_data["y"] = cy
|
||||
wiresheet.draw_links()
|
||||
wiresheet.redraw_links()
|
||||
return
|
||||
|
||||
# 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.
|
||||
step = app_state.meta_scale
|
||||
margin = wiresheet.layout_margin(step)
|
||||
# Canvas coordinates carry the sheet's zoom, so the step and the margin do too.
|
||||
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
|
||||
# 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)
|
||||
# Wires are not part of the dragged group, so they are re-routed against the
|
||||
# new positions.
|
||||
wiresheet.draw_links()
|
||||
wiresheet.redraw_links()
|
||||
|
||||
|
||||
def on_canvas_release(event):
|
||||
@@ -550,14 +617,14 @@ def on_canvas_release(event):
|
||||
return
|
||||
|
||||
canvas = app_state.canvas
|
||||
step = app_state.meta_scale
|
||||
step = app_state.meta_scale * app_state.zoom
|
||||
group_tag = app_state.drag_data["tag"]
|
||||
origin = wiresheet.get_box_origin(group_tag)
|
||||
|
||||
if origin is not None:
|
||||
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_y = max(margin, round(current_y / step) * step)
|
||||
|
||||
@@ -589,7 +656,7 @@ def on_canvas_release(event):
|
||||
app_state.mark_dirty()
|
||||
|
||||
app_state.drag_data["tag"] = None
|
||||
wiresheet.draw_links()
|
||||
wiresheet.redraw_links()
|
||||
|
||||
|
||||
def fit_tree_column(tree):
|
||||
@@ -1174,6 +1241,16 @@ SHORTCUTS = [
|
||||
("z", do_undo),
|
||||
("y", do_redo),
|
||||
("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:
|
||||
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"
|
||||
)
|
||||
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_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.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.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_y = canvas.canvasy(event.y_root - canvas.winfo_rooty())
|
||||
|
||||
step = app_state.meta_scale
|
||||
min_step = wiresheet.layout_margin(step) // step
|
||||
step = app_state.meta_scale * app_state.zoom
|
||||
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_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-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)
|
||||
|
||||
undo.set_refresh_callback(refresh_all_views)
|
||||
|
||||
+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