diff --git a/README.md b/README.md
index d8e320d..00122a3 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app_state.py b/app_state.py
index 1eaf083..74fa15b 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.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 ()
xml_root_element = None # Keep a persistent root reference to redraw tree layouts cleanly
diff --git a/main.py b/main.py
index 6a1027a..02c1949 100644
--- a/main.py
+++ b/main.py
@@ -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"", 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("", lambda e: wiresheet.draw_grid())
canvas.bind("", on_canvas_right_click)
canvas.bind("", on_canvas_right_click)
+# Wheel zoom. Windows and macOS report a delta; X11 sends button 4 and 5 instead.
+canvas.bind("", on_canvas_wheel_zoom)
+canvas.bind("", do_zoom_in)
+canvas.bind("", do_zoom_out)
+
paned_window.add(frame_editor, weight=3)
undo.set_refresh_callback(refresh_all_views)
diff --git a/wiresheet.py b/wiresheet.py
index aa1aa17..e25dd61 100644
--- a/wiresheet.py
+++ b/wiresheet.py
@@ -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():