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():