From c59cd22153b283c289106ca7fbf92c13f5d80b30 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Sat, 12 Sep 2026 21:51:22 +0300 Subject: [PATCH] Fix live-device slot ID resolution, add print and tighten wire routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _slot_name_by_id() now resolves SOX link slot IDs against the inheritance-flattened final_components_registry instead of the per-type slot_registry whose local IDs were wrong for types with intermediate parents (e.g. RegFloat→Register→Component). This produced incorrect link endpoints, inflated linked-slot visibility, and different box sizes between file and live modes. Also: tighten routing constants for denser wiring, add wiresheet PNG print/export (Ctrl+P), sort link routing jobs by gap distance, remove debug route logging, and fix live tree-expand re-selection. Co-Authored-By: Claude Opus 4.6 --- README.md | 8 +- app_state.py | 2 +- device_session.py | 11 +++ main.py | 21 ++++- wiresheet.py | 198 +++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 222 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index afda58e..a9f06c0 100644 --- a/README.md +++ b/README.md @@ -633,12 +633,8 @@ And two things worth knowing about the manifests themselves rather than the code ## Known gaps -- The yellow square in each box header is drawn but wired to nothing; it is the natural - place for a per-box collapse/expand toggle. -- Undo covers box moves, adds and deletes. Nothing else pushes a command, so a change made - outside those three is not reversible. -- `on_canvas_release` snaps using the rounded-rectangle polygon's first coordinate, - which is `x1 + radius` rather than `x1`, so snapping is offset by the corner radius. +- The yellow square in each box header is a type-icon placeholder that is drawn but not yet + functional; no icon set has been wired to it. - Slot values are edited from the Properties view only; a wiresheet box row is still display-only. - The Link View stacks each side in one column, so a component with a dozen peers makes a diff --git a/app_state.py b/app_state.py index ecb93c3..6ba5f13 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.092" +VERSION = "0.0.0.093" 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/device_session.py b/device_session.py index 244b3c9..0fb1c53 100644 --- a/device_session.py +++ b/device_session.py @@ -339,6 +339,17 @@ class DeviceSession: "to": f"{to_path}.{to_slot_name}"}) def _slot_name_by_id(self, kit_id, type_id_val, slot_id): + # SOX links use absolute slot IDs across the full inheritance chain. + # final_components_registry has correctly resolved absolute IDs; + # the per-type slot_registry only stores local (per-type) IDs + # which are wrong for types with intermediate parents. + type_name = self.sox_client.type_name_map.get((kit_id, type_id_val)) + if type_name and type_name in app_state.final_components_registry: + for s in app_state.final_components_registry[type_name]: + if s["slot_id"] == slot_id: + return s["name"] + # Fallback to slot_registry (local IDs — wrong for inherited types, + # but keeps things working when the schema registry is not yet loaded) slots = self.sox_client.slot_registry.get((kit_id, type_id_val)) if slots: for s in slots: diff --git a/main.py b/main.py index 455b1a6..9e53b9c 100644 --- a/main.py +++ b/main.py @@ -635,7 +635,7 @@ def on_tree_select(event): tree.after_idle(_clear_restoring_flag) app_state.current_sheet_parent = element except Exception: - pass + import traceback; traceback.print_exc() if element is not None: components_to_render = [child for child in element if child.tag == "comp"] @@ -1351,6 +1351,8 @@ def attach_tree_scrollbars(container, tree): tree.bind("", lambda event: fit_tree_column(tree), add="+") def _on_tree_open(event): tree.after_idle(fit_tree_column, tree) + if _restoring_selection: + return if app_state.live_mode: opened = tree.focus() if opened: @@ -2091,14 +2093,20 @@ def _live_tree_expand(comp_id, tree_item_id): ds.build_links_elements(sax_root) def _ui(): + global _restoring_selection app_state.xml_root_element = sax_root sax_file.refresh_treeview_from_dom(sax_root) - # Re-open and select the node the user clicked + # Re-open and re-select the node without triggering a wiresheet + # re-render — the sheet is already showing the right content and + # a redundant close_wiresheet + open_wiresheet cycle can lose links. for iid, elem in app_state.tree_element_map.items(): if elem.get("id") == str(comp_id): + app_state.current_sheet_parent = elem + _restoring_selection = True tree.item(iid, open=True) tree.selection_set(iid) tree.see(iid) + tree.after_idle(_clear_restoring_flag) break app_state.set_status(f"Loaded {len(children)} children") @@ -2171,6 +2179,7 @@ SHORTCUTS = [ ("KP_0", do_zoom_reset), ("9", do_zoom_fit), ("KP_9", do_zoom_fit), + ("p", wiresheet.print_sheet), ] for key, handler in SHORTCUTS: window.bind_all(f"", handler) @@ -2211,6 +2220,8 @@ menu_file.add_command(label="Open", command=sax_file.open_file) menu_file.add_command(label="Save", command=do_save, accelerator=f"{accel_mod}+S") menu_file.add_command(label="Save As...", command=sax_file.save_file_as) menu_file.add_separator() +menu_file.add_command(label="Print...", command=wiresheet.print_sheet, accelerator=f"{accel_mod}+P") +menu_file.add_separator() menu_file.add_command(label="Exit", command=window.quit) menu_bar.add_cascade(label="File", menu=menu_file) @@ -2308,6 +2319,12 @@ _btn_tidy = ttk.Button(toolbar, text="⊞", width=3, command=do_tidy_layout) _btn_tidy.pack(side=tk.LEFT, padx=(3, 0)) _ToolTip(_btn_tidy, "Tidy Layout (Ctrl+W)") +ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=8, pady=2) + +_btn_print = ttk.Button(toolbar, text="🖨", width=3, command=wiresheet.print_sheet) +_btn_print.pack(side=tk.LEFT) +_ToolTip(_btn_print, "Print Wiresheet (Ctrl+P)") + 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 f072b97..bd27dda 100644 --- a/wiresheet.py +++ b/wiresheet.py @@ -1,6 +1,8 @@ """Canvas painting: the grid, component boxes, and the routed link wires.""" import math +import os +import sys import tkinter as tk import tkinter.font as tkfont @@ -9,8 +11,9 @@ import schema import undo from sax_dom import get_component_path, read_meta_property, update_meta_property -LINK_STUB = 12 # straight run leaving a slot before the wire is allowed to turn -LINK_CLEAR = 8 # gap kept between a horizontal wire lane and any box above or below + +LINK_STUB = 8 # straight run leaving a slot before the wire is allowed to turn +LINK_CLEAR = 5 # gap kept between a horizontal wire lane and any box above or below LINK_EPS = 0.5 # tolerance so a wire touching its own box edge is not a collision # Vertical wire runs. A run hugs the box it leaves or enters instead of drifting down the @@ -18,9 +21,9 @@ LINK_EPS = 0.5 # tolerance so a wire touching its own box edge is not a col # the closest it is ever allowed to get, and two runs sharing one gap stand # WIRE_LANE_GAP apart. A gap therefore has to be WIRE_HUG either side plus one # WIRE_LANE_GAP per extra run, which is why TIDY_GAP below is exactly 2 * WIRE_HUG. -WIRE_HUG = 15 -WIRE_MIN_CLEAR = 10 -WIRE_LANE_GAP = 10 +WIRE_HUG = 10 +WIRE_MIN_CLEAR = 6 +WIRE_LANE_GAP = 6 # No wire may pass under a component, and that outranks hugging: when the hug band is # already taken, a run may stand off this far instead so the wire can still be drawn @@ -32,7 +35,7 @@ WIRE_STANDOFF_MAX = 60 # a free horizontal lane is nudged until it clears any other wire's by this much. The # short stubs joining a slot cannot move -- they have to meet their pin -- so a fan-out # from one output pin still shares the run right beside the pin. -WIRE_ROW_GAP = 5 +WIRE_ROW_GAP = 3 LINK_COLOR = "#2f6fba" KNOB_OFFSHEET_COLOR = "#9aa0a6" # a link whose far end is on another wiresheet KNOB_RADIUS = 4 @@ -55,7 +58,7 @@ META_COORD_MAX = 255 # meta packs x and y into one byte each # agree on it without anything having to be recorded in the file. META_SCALE_MAX = 12 -TIDY_GAP = 30 # minimum clear space kept between two boxes +TIDY_GAP = 20 # 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 @@ -455,7 +458,8 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None, they are refused when they would hide under another wire: the wire is better off detouring, which puts it on a lane it can actually move. """ - for cx in channel_candidates(src_box, dst_box, boxes, low, high): + candidates = channel_candidates(src_box, dst_box, boxes, low, high) + for cx in candidates: lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, max_steps) if lane is None: continue @@ -541,8 +545,13 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None, # First pass: only the straight route, whose horizontal runs are pinned to their # slots and cannot be moved later. Letting these claim their rows first stops a # detouring wire from parking its movable lane on top of one of them. + attempt_names = ("straight(1)",) attempts = (lambda: straight_through(1),) else: + attempt_names = ( + "straight(1)", "around(hug)", "straight(8)", "around(standoff)", + "straight(1,stack)", "straight(8,stack)", "around(standoff,stack)", + ) attempts = ( lambda: straight_through(1), # hugging, own lane lambda: around_boxes(WIRE_HUG), # hugging, routed around @@ -555,7 +564,7 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None, lambda: straight_through(8, allow_stack=True), lambda: around_boxes(WIRE_STANDOFF_MAX, allow_stack=True), ) - for attempt in attempts: + for name, attempt in zip(attempt_names, attempts): candidate, runs = attempt() if candidate is not None: if taken is not None: @@ -703,6 +712,10 @@ def draw_links(): app_state.canvas_link_map[wire_tag] = link want_knob(knob_x, slot_y, "off_sheet", wire_tag) + # Sort by horizontal gap distance so shorter, simpler links claim lanes first. + # Ties are broken by vertical span, keeping the cheapest wires at the front. + jobs.sort(key=lambda j: (abs(j[3][0] - j[1][2]), abs(j[4] - j[2]))) + # Route in two passes. A wire that can run straight has its horizontal segments # pinned to its two slots, so it gets first claim on those rows; wires that have to # detour then pick lanes that keep clear of them. Routing in one pass lets a detour @@ -1319,3 +1332,170 @@ def tidy_layout(): render_current_sheet() return len(changes), clamped + + +# --------------------------------------------------------------------------- +# Print (PNG export via screen capture) +# --------------------------------------------------------------------------- + +_PRINT_PAD = 20 # breathing room around content in the saved image + + +def print_sheet(event=None): + """Saves the wiresheet as a PNG image sized to its content and opens it. + + The canvas is temporarily scrolled and resized so that every component and wire + is visible, then captured with PIL.ImageGrab. The grid is hidden during the + capture. The image size adapts automatically: a sparse sheet produces a small + image, a crowded one a larger image. When the content at the current zoom is + larger than the screen, the zoom is temporarily reduced so the full sheet fits. + """ + canvas = app_state.canvas + if canvas is None: + return + + bounds = sheet_content_bounds() + if bounds is None: + app_state.set_status("Nothing to print.") + return + + from PIL import ImageGrab + from tkinter import filedialog + + # Build a default file name from the file name and current sheet path. + title_parts = [] + if app_state.current_file_path: + title_parts.append(os.path.splitext(os.path.basename(app_state.current_file_path))[0]) + if app_state.current_sheet_parent is not None: + path = get_component_path(app_state.current_sheet_parent, + app_state.xml_root_element) + if path: + title_parts.append(path.replace("/", "_")) + default_name = ("_".join(title_parts) if title_parts else "wiresheet") + ".png" + + png_path = filedialog.asksaveasfilename( + parent=app_state.window, + title="Save Wiresheet as PNG", + defaultextension=".png", + initialfile=default_name, + filetypes=[("PNG Image", "*.png"), ("All Files", "*.*")], + ) + if not png_path: + return + + pad = _PRINT_PAD + + # Work out how large the content is at 1:1 and decide a print zoom that keeps + # the whole sheet within the available screen area. + content_w = bounds[2] - bounds[0] + 2 * pad + content_h = bounds[3] - bounds[1] + 2 * pad + + screen_w = canvas.winfo_screenwidth() - 100 + screen_h = canvas.winfo_screenheight() - 100 + max_zoom = min(screen_w / content_w, screen_h / content_h, app_state.zoom) + # Don't zoom below the minimum ladder level. + print_zoom = max(max_zoom, ZOOM_LEVELS[0]) + + # The original zoom is restored after capture. + original_zoom = app_state.zoom + zoom_changed = abs(print_zoom - original_zoom) > 1e-9 + + # Content rectangle at the print zoom (canvas pixel coordinates). + x1 = max(0.0, bounds[0] * print_zoom - pad) + y1 = max(0.0, bounds[1] * print_zoom - pad) + x2 = bounds[2] * print_zoom + pad + y2 = bounds[3] * print_zoom + pad + capture_w = int(math.ceil(x2 - x1)) + capture_h = int(math.ceil(y2 - y1)) + + # Remember the canvas's current geometry so it can be restored afterwards. + old_scrollregion = canvas.cget("scrollregion") + old_width = canvas.winfo_width() + old_height = canvas.winfo_height() + old_xview = canvas.xview() + old_yview = canvas.yview() + + try: + # Re-render at the print zoom if needed. + if zoom_changed: + app_state.zoom = print_zoom + render_current_sheet() + + # Hide the grid. + grid_items = canvas.find_withtag("grid") + for item in grid_items: + canvas.itemconfigure(item, state="hidden") + + # Expand the canvas widget and scroll region so the full content area is + # rendered on-screen in one shot. + canvas.config( + width=capture_w, + height=capture_h, + scrollregion=(x1, y1, x2, y2), + ) + canvas.xview_moveto(0.0) + canvas.yview_moveto(0.0) + + # Force Tk to process the resize and repaint before the grab. + canvas.update_idletasks() + canvas.update() + + # Grab the canvas area from the screen. + cx = canvas.winfo_rootx() + cy = canvas.winfo_rooty() + grab_w = min(capture_w, canvas.winfo_width()) + grab_h = min(capture_h, canvas.winfo_height()) + image = ImageGrab.grab(bbox=(cx, cy, cx + grab_w, cy + grab_h)) + + image.save(png_path) + + # Restore canvas geometry. + canvas.config( + width=old_width, + height=old_height, + scrollregion=old_scrollregion, + ) + canvas.xview_moveto(old_xview[0]) + canvas.yview_moveto(old_yview[0]) + + # Restore the grid. + for item in grid_items: + canvas.itemconfigure(item, state="normal") + + # Restore the original zoom if it was changed. + if zoom_changed: + app_state.zoom = original_zoom + render_current_sheet() + else: + canvas.update_idletasks() + + # Open the saved image with the system viewer. + if sys.platform == "win32": + os.startfile(png_path) + else: + import subprocess + opener = "open" if sys.platform == "darwin" else "xdg-open" + subprocess.Popen([opener, png_path]) + + app_state.set_status(f"Wiresheet saved: {png_path}") + + except Exception as exc: + # Restore everything on error. + try: + canvas.config( + width=old_width, + height=old_height, + scrollregion=old_scrollregion, + ) + canvas.xview_moveto(old_xview[0]) + canvas.yview_moveto(old_yview[0]) + for item in canvas.find_withtag("grid"): + canvas.itemconfigure(item, state="normal") + if zoom_changed: + app_state.zoom = original_zoom + render_current_sheet() + else: + canvas.update_idletasks() + except Exception: + pass + app_state.set_status(f"Print failed: {exc}", warn=True)