Fix live-device slot ID resolution, add print and tighten wire routing
_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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-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.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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("<Configure>", 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"<Command-{key}>", 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)
|
||||
|
||||
|
||||
+189
-9
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user