Wiresheet: grid-snapped layout, visible wire routing, knob legend

Layout rules for tidy and for dragging:
- Boxes snap to whole meta steps live while dragged, not only on release.
- The left-most column and top row clear the canvas edge by at least 30px,
  and neighbours by at least 30px, each rounded up to the next step.
- Box widths round up to the grid so a right edge lands on a grid line.
- Column gaps widen only where wires need lanes, so most stay at exactly 30px.
- A sheet too wide for meta's 0..255 range is laid out at 2, then 3, then 4
  grid squares per step. The multiplier is derived from the sheet's contents,
  so painting and tidying agree on it without recording anything in the file.
  The grid size in Preferences is untouched.

Wire routing:
- No wire may pass under a component. A link with no clear route is shown as
  knobs on both slots instead of a line hidden behind a box.
- Vertical runs hug a box within 15px, never closer than 10px, and two runs
  sharing a gap stand 10px apart.
- Horizontal runs of different links stay 5px apart. Runs from one output pin
  may share, since a fan-out is one signal.
- Straight routes claim their rows first, as their segments are pinned to
  their slots and cannot move later.

Knobs:
- Wire colour with a small white centre when the far end is on this sheet,
  grey when it is on another, half and half when a slot carries both.
- Selectable and deletable like a wire, and raised above the boxes so a click
  reaches them.
- Selecting a same-sheet pair draws a red dashed line between the two knobs.

Also:
- get_box_bounds returns the rectangle actually drawn rather than canvas.bbox,
  which was inflated by the outline stroke and by selection.
- Preferences offers to round the box width to a multiple of the grid.
- The tidy coordinate-limit warning said to lower the grid size; raising it is
  what helps, since the reach is 255 * grid size.
- Tidy Layout moves from its own menu into Edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:05:58 +03:00
co-authored by Claude Opus 5
parent 03e62ba03c
commit e2cd13b9a0
3 changed files with 862 additions and 173 deletions
+3
View File
@@ -44,6 +44,9 @@ selected_canvas_tag = None # Tracks the currently clicked/highlighted canvas
selected_tree_item_id = None # Tracks the currently right-clicked Treeview item selected_tree_item_id = None # Tracks the currently right-clicked Treeview item
link_mark_element = None # Component marked as a link endpoint via "Link Mark" link_mark_element = None # Component marked as a link endpoint via "Link Mark"
selected_link_tag = None # Canvas tag of the currently selected (red-highlighted) link wire 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
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>) canvas_link_map = {} # Canvas link tag -> ET.Element (<link>)
xml_root_element = None # Keep a persistent root reference to redraw tree layouts cleanly xml_root_element = None # Keep a persistent root reference to redraw tree layouts cleanly
current_sheet_parent = None # Element whose children are painted; None means the app root current_sheet_parent = None # Element whose children are painted; None means the app root
+98 -24
View File
@@ -42,6 +42,9 @@ def do_save(event=None):
return "break" return "break"
NL = chr(10) # newline, for the multi-line message boxes
def do_tidy_layout(event=None): def do_tidy_layout(event=None):
"""Menu command: re-flow the open sheet so nothing overlaps.""" """Menu command: re-flow the open sheet so nothing overlaps."""
if not app_state.canvas_comp_map: if not app_state.canvas_comp_map:
@@ -49,15 +52,28 @@ def do_tidy_layout(event=None):
return "break" return "break"
moved, clamped = wiresheet.tidy_layout() moved, clamped = wiresheet.tidy_layout()
multiplier = app_state.meta_scale // app_state.GRID_SIZE
if moved == 0: if moved == 0:
messagebox.showinfo("Tidy Layout", "Nothing to move — no boxes overlap.") messagebox.showinfo("Tidy Layout", "Nothing to move — no boxes overlap.")
elif clamped: elif clamped:
messagebox.showwarning( messagebox.showwarning(
"Tidy Layout", "Tidy Layout",
f"Repositioned {moved} component(s).\n\n" f"Repositioned {moved} component(s).\n\n"
f"{clamped} hit the coordinate limit of {wiresheet.META_COORD_MAX} grid units " f"{clamped} still hit the coordinate limit of {wiresheet.META_COORD_MAX} "
"and were clamped, because meta stores x and y in one byte each. " "steps, because meta stores x and y in one byte each. This sheet is already "
"Lower the grid size in Preferences to fit more in." f"laid out at {multiplier} grid squares per step, the widest meta can reach, "
"so it does not fit however it is spaced. Split it across sub-sheets."
)
elif multiplier > 1:
messagebox.showinfo(
"Tidy Layout",
f"Repositioned {moved} component(s)." + NL + NL +
f"This sheet needs more width than meta's {wiresheet.META_COORD_MAX} "
f"coordinate steps allow at one grid square each, so it is laid out at "
f"{multiplier} grid squares per step ({app_state.meta_scale} px). Boxes "
f"settle on every {multiplier} grid lines rather than every one. Your grid "
"size in Preferences is unchanged."
) )
return "break" return "break"
@@ -179,6 +195,12 @@ def on_canvas_click(event):
app_state.drag_data["y"] = cy app_state.drag_data["y"] = cy
app_state.selected_canvas_tag = group_tag app_state.selected_canvas_tag = group_tag
# Where the box starts out. The drag is measured as an offset from this pair
# rather than accumulated per motion event, so repeated snapping cannot drift.
origin = wiresheet.get_box_origin(group_tag)
app_state.drag_data["box_x"] = origin[0] if origin else None
app_state.drag_data["box_y"] = origin[1] if origin else None
# Snapshot the pre-drag meta so the move can be pushed as one undoable step. # Snapshot the pre-drag meta so the move can be pushed as one undoable step.
dragged = app_state.canvas_comp_map.get(group_tag) dragged = app_state.canvas_comp_map.get(group_tag)
meta_prop = dragged.find("prop[@name='meta']") if dragged is not None else None meta_prop = dragged.find("prop[@name='meta']") if dragged is not None else None
@@ -444,18 +466,40 @@ def on_canvas_drag(event):
return return
canvas = app_state.canvas canvas = app_state.canvas
group_tag = app_state.drag_data["tag"]
cx = canvas.canvasx(event.x) cx = canvas.canvasx(event.x)
cy = canvas.canvasy(event.y) cy = canvas.canvasy(event.y)
delta_x = cx - app_state.drag_data["x"] box_x = app_state.drag_data.get("box_x")
delta_y = cy - app_state.drag_data["y"] origin = wiresheet.get_box_origin(group_tag)
canvas.move(app_state.drag_data["tag"], delta_x, delta_y)
if box_x is None or origin is None:
# Nothing to align against, so the box just follows the pointer one to one.
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["x"] = cx
app_state.drag_data["y"] = cy app_state.drag_data["y"] = cy
wiresheet.draw_links()
return
# Wires are not part of the dragged group, so they are re-routed against the new positions. # 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)
# 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.
wanted_x = box_x + (cx - app_state.drag_data["x"])
wanted_y = app_state.drag_data["box_y"] + (cy - app_state.drag_data["y"])
target_x = max(margin, round(wanted_x / step) * step)
target_y = max(margin, round(wanted_y / step) * step)
shift_x = target_x - origin[0]
shift_y = target_y - origin[1]
if shift_x or shift_y:
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.draw_links()
@@ -464,16 +508,16 @@ def on_canvas_release(event):
return return
canvas = app_state.canvas canvas = app_state.canvas
grid_size = app_state.GRID_SIZE step = app_state.meta_scale
group_tag = app_state.drag_data["tag"] group_tag = app_state.drag_data["tag"]
outline_item = canvas.find_withtag(f"{group_tag} && block_outline") origin = wiresheet.get_box_origin(group_tag)
if outline_item: if origin is not None:
coords = canvas.coords(outline_item[0]) current_x, current_y = origin
current_x, current_y = coords[0], coords[1]
snapped_x = round(current_x / grid_size) * grid_size margin = wiresheet.layout_margin(step)
snapped_y = round(current_y / grid_size) * grid_size snapped_x = max(margin, round(current_x / step) * step)
snapped_y = max(margin, round(current_y / step) * step)
shift_x = snapped_x - current_x shift_x = snapped_x - current_x
shift_y = snapped_y - current_y shift_y = snapped_y - current_y
@@ -482,8 +526,8 @@ def on_canvas_release(event):
canvas.move(group_tag, shift_x, shift_y) canvas.move(group_tag, shift_x, shift_y)
app_state.mark_dirty() app_state.mark_dirty()
raw_x = int(snapped_x / grid_size) raw_x = min(wiresheet.META_COORD_MAX, int(round(snapped_x / step)))
raw_y = int(snapped_y / grid_size) raw_y = min(wiresheet.META_COORD_MAX, int(round(snapped_y / step)))
comp_element = app_state.canvas_comp_map.get(group_tag) comp_element = app_state.canvas_comp_map.get(group_tag)
if comp_element is not None: if comp_element is not None:
@@ -591,6 +635,22 @@ def open_preferences_dialog():
f"{app_state.MAX_ALLOWED_BOX_WIDTH} pixels." f"{app_state.MAX_ALLOWED_BOX_WIDTH} pixels."
) )
# The width cap is applied after each box is fitted to its text, so a cap
# that is not a multiple of the grid leaves the widest boxes off-grid.
proposed = wiresheet.suggest_box_width_for_grid(width, val)
if proposed != width:
if not messagebox.askyesno(
"Box Width Off Grid",
f"A box width of {width} px is not a multiple of the {val} px grid, "
f"so the widest boxes would not line up with it.\n\n"
f"Change the box width to {proposed} px?",
parent=dialog,
):
return
width = proposed
entry_box_width.delete(0, tk.END)
entry_box_width.insert(0, str(width))
app_state.BOX_WIDTH = width app_state.BOX_WIDTH = width
app_state.GRID_SIZE = val app_state.GRID_SIZE = val
app_state.ADD_TIMESTAMP = var_timestamp.get() app_state.ADD_TIMESTAMP = var_timestamp.get()
@@ -693,6 +753,21 @@ def verify_startup_configurations():
"before trying to view or load profile source files." "before trying to view or load profile source files."
) )
open_preferences_dialog() open_preferences_dialog()
return
# editor.properties can be hand-edited, so the pair is reconciled here too.
proposed = wiresheet.suggest_box_width_for_grid(app_state.BOX_WIDTH, app_state.GRID_SIZE)
if proposed != app_state.BOX_WIDTH and messagebox.askyesno(
"Box Width Off Grid",
f"The saved box width of {app_state.BOX_WIDTH} px is not a multiple of the "
f"{app_state.GRID_SIZE} px grid, so the widest boxes will not line up with it.\n\n"
f"Change the box width to {proposed} px?",
):
app_state.BOX_WIDTH = proposed
app_state.save_preferences()
wiresheet.draw_grid()
if app_state.active_components_list:
wiresheet.render_components(app_state.active_components_list)
# --- UI Setup --- # --- UI Setup ---
@@ -767,6 +842,8 @@ menu_edit.add_command(label="Undo", command=do_undo, accelerator=f"{accel_mod}+Z
menu_edit.add_command(label="Redo", command=do_redo, accelerator=f"{accel_mod}+Y") menu_edit.add_command(label="Redo", command=do_redo, accelerator=f"{accel_mod}+Y")
menu_edit.add_separator() menu_edit.add_separator()
menu_edit.add_command(label="Delete Component", command=delete_selected_component, accelerator="Del") menu_edit.add_command(label="Delete Component", command=delete_selected_component, accelerator="Del")
menu_edit.add_separator()
menu_edit.add_command(label="Tidy Layout", command=do_tidy_layout)
menu_bar.add_cascade(label="Edit", menu=menu_edit) menu_bar.add_cascade(label="Edit", menu=menu_edit)
menu_kits = tk.Menu(menu_bar, tearoff=0) menu_kits = tk.Menu(menu_bar, tearoff=0)
@@ -776,10 +853,6 @@ menu_kits.add_command(
) )
menu_bar.add_cascade(label="Kits", menu=menu_kits) menu_bar.add_cascade(label="Kits", menu=menu_kits)
menu_layout = tk.Menu(menu_bar, tearoff=0)
menu_layout.add_command(label="Tidy Layout", command=do_tidy_layout)
menu_bar.add_cascade(label="Layout", menu=menu_layout)
menu_app_info = tk.Menu(menu_bar, tearoff=0) menu_app_info = tk.Menu(menu_bar, tearoff=0)
menu_app_info.add_command(label="App Info", command=show_app_info) menu_app_info.add_command(label="App Info", command=show_app_info)
menu_bar.add_cascade(label="App", menu=menu_app_info) menu_bar.add_cascade(label="App", menu=menu_app_info)
@@ -993,9 +1066,10 @@ def on_palette_release(event):
drop_x = canvas.canvasx(event.x_root - canvas.winfo_rootx()) drop_x = canvas.canvasx(event.x_root - canvas.winfo_rootx())
drop_y = canvas.canvasy(event.y_root - canvas.winfo_rooty()) drop_y = canvas.canvasy(event.y_root - canvas.winfo_rooty())
grid_size = app_state.GRID_SIZE step = app_state.meta_scale
grid_x = max(0, min(wiresheet.META_COORD_MAX, round(drop_x / grid_size))) min_step = wiresheet.layout_margin(step) // step
grid_y = max(0, min(wiresheet.META_COORD_MAX, round(drop_y / grid_size))) 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)))
sax_file.add_component(kit_name, type_name, grid_x, grid_y) sax_file.add_component(kit_name, type_name, grid_x, grid_y)
+744 -132
View File
File diff suppressed because it is too large Load Diff