Compare commits

...
7 Commits
Author SHA1 Message Date
ArdaandClaude Opus 5.5 0d5e5115d9 Wiresheet: arrow nudges keep the selection and the sheet's focus
Ctrl+A hands the sheet the keyboard, and repainting the same sheet keeps
whatever of the selection is still on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:40:07 +03:00
ArdaandClaude Opus 5.5 1a4c84f9d5 Wiresheet: arrow keys nudge selected boxes one grid step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:35:34 +03:00
ArdaandClaude Opus 5.5 75df6d0a6e Wiresheet: left-drag rectangle selection, group move, Ctrl+A select all
Rectangle selection moves to the left button on bare sheet (Ctrl adds);
right-click is the menu again. Dragging a selected box by its title bar
moves the whole selection as one undoable step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:29:13 +03:00
ArdaandClaude Opus 5.5 81c4a1d31d Wiresheet: right-drag rectangle selects boxes, Ctrl adds to selection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:21:56 +03:00
ArdaandClaude Opus 5.5 0c69f18046 Tidy Layout straightens wires; router avoids crossings and box edges
Tidy Layout
- Box heights are settled in left-to-right and right-to-left sweeps so each
  box lines its slot up with the other end of its wires; gaps above the
  minimum are free, and columns are re-sorted to untangle crossings.
- A wire that skips columns takes a placeholder in each column it crosses,
  so boxes are ordered and spaced around it.
- A box nothing feeds is held back far enough left that wires from further
  left always enter higher slots of the same target.

Wire routing
- Only the short stubs at a wire's two slots may pass close to a box; the
  rest of every run keeps its clearance, so no wire grazes a box edge. A link
  with no clear route is shown as knobs.
- Lanes that cross no other wire are tried first; climbing wires route top
  source first, falling ones bottom first, and a deferred wire defers the
  rest of its gap so lanes keep that order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:17:04 +03:00
ArdaandClaude Opus 5.5 3d9db54956 Wiresheet: grey margin strip, saved-state tint on and off the sheet
- The top/left margin no box may enter is grey and ungridded, so the white
  sheet starts where boxes can actually be placed.
- Saving/unsaved state no longer resets the canvas to white: the sheet goes
  white/pink and the area around it grey/greyish pink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:18:58 +03:00
ArdaandClaude Opus 5.5 b3d96d96b6 Tidy Layout as signal flow; fixed-size wiresheet; signed Sox int writes
- Tidy Layout lays linked boxes out left to right by signal flow, in as few
  columns as the longest chain allows; feedback loops are cut where they run
  backwards, and sources sit just before the boxes they feed.
- Boxes with no link on the open sheet go to the bottom of the first column,
  alphabetically.
- A box that wires skipping its column would cross is pushed down below them.
- The wiresheet is fixed at meta's 255-step reach; outside it is light grey.
  Dragging keeps a box's top-left corner on the sheet.
- Sox write_i4 wraps to signed, so meta with x >= 128 reaches the device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:15:22 +03:00
6 changed files with 868 additions and 189 deletions
+22 -5
View File
@@ -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.173"
VERSION = "0.0.0.180"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
@@ -41,6 +41,9 @@ status_label = None # the window's bottom status bar
# colour wherever the change was made.
DIRTY_TINT = "#ffe6e6"
CLEAN_TINT = "white"
# The wiresheet canvas around the sheet itself, clean and with unsaved changes.
OFF_SHEET_TINT = "#f3f3f3"
OFF_SHEET_DIRTY_TINT = "#f0dcdc"
# The file name on the tree's root row turns this colour while anything is unwritten,
# which is the one place a file name appears on screen -- a Tk window title cannot be
@@ -239,8 +242,7 @@ def mark_dirty():
global is_dirty
if not is_dirty:
is_dirty = True
if canvas is not None:
canvas.config(bg=DIRTY_TINT)
paint_sheet_tint()
refresh_dirty_display()
@@ -248,11 +250,26 @@ def mark_clean():
"""Clears the unsaved-state marker after a successful save or a fresh load."""
global is_dirty
is_dirty = False
if canvas is not None:
canvas.config(bg=CLEAN_TINT)
paint_sheet_tint()
refresh_dirty_display()
def sheet_tints():
"""(sheet, off-sheet) colours for the wiresheet in the current saved/unsaved state."""
if is_dirty:
return DIRTY_TINT, OFF_SHEET_DIRTY_TINT
return CLEAN_TINT, OFF_SHEET_TINT
def paint_sheet_tint():
"""Recolours the sheet (the "sheet_bg" rectangle) and the canvas around it."""
if canvas is None:
return
sheet, off_sheet = sheet_tints()
canvas.config(bg=off_sheet)
canvas.itemconfig("sheet_bg", fill=sheet)
# ── Live device connection state ────────────────────────────────
live_mode = False
device_session = None # device_session.DeviceSession instance
+96 -7
View File
@@ -11,7 +11,9 @@ either end. While dragging, the cursor points along the flow of the link over a
would take it, and turns into a no-entry sign over one that would not, so a bad drop is
visible before the button is released.
A right press that never moves still opens the component's context menu. A left press
A right press that never moves still opens the component's context menu. A left drag
that starts on bare sheet draws a selection rectangle, and selects the boxes wholly
inside it on release; with Ctrl held they join the current selection. A left press
anywhere on a box selects it, but only a press on its title bar moves it; on a slot row
it draws a link instead, as in Workbench.
"""
@@ -33,11 +35,15 @@ HOVER_COLOR = "#1f4e79"
DROP_OK_COLOR = "#2e9d3a"
DROP_BAD_COLOR = "#d62828"
RUBBER_COLOR = "#2f6fba"
MARQUEE_COLOR = "#2f6fba"
CTRL_MASK = 0x0004
_cursors = {} # role -> Tk cursor spec, built on first use
_press = None # pending right press: {"hit", "x", "y", "event"}
_drag = None # live link drag: {"hit", "anchor"}
_marquee = None # left press on bare sheet: {"x", "y", "cx", "cy", "state", "active"}
_menu_callback = None # opens the ordinary right-click menu for an event
_select_boxes = None # makes a list of comp tags the box selection
_link_dialog = None # opens the Link dialog: (source, target, src_slot, tgt_slot)
@@ -301,14 +307,16 @@ def _anchor(hit):
def clear():
"""Drops any hover ring, rubber band and drag state; called on every repaint."""
global _press, _drag
global _press, _drag, _marquee
canvas = app_state.canvas
if canvas is None:
return
canvas.delete("slot_hover")
canvas.delete("link_rubber")
canvas.delete("marquee")
_press = None
_drag = None
_marquee = None
_set_cursor(None)
@@ -442,6 +450,67 @@ def on_right_release(event):
_menu_callback(event)
# Left button on bare sheet: dragging pulls out a selection rectangle that selects every
# box wholly inside it on release (Ctrl adds them to the current selection).
def _on_bare_sheet(cx, cy):
"""True if no box or link lies under a canvas point."""
canvas = app_state.canvas
for item in canvas.find_overlapping(cx - 3, cy - 3, cx + 3, cy + 3):
tags = canvas.gettags(item)
if "component" in tags or "link" in tags:
return False
return True
def _marquee_motion(event):
"""Draws the selection rectangle once the pointer has moved far enough."""
global _marquee
if _marquee is None:
return
if not _marquee["active"]:
if abs(event.x - _marquee["x"]) < DRAG_THRESHOLD and \
abs(event.y - _marquee["y"]) < DRAG_THRESHOLD:
return
_marquee["active"] = True
app_state.canvas.delete("slot_hover")
canvas = app_state.canvas
canvas.delete("marquee")
canvas.create_rectangle(_marquee["cx"], _marquee["cy"],
canvas.canvasx(event.x), canvas.canvasy(event.y),
outline=MARQUEE_COLOR, width=1, dash=(4, 2),
tags=("marquee",))
canvas.tag_raise("marquee")
def _marquee_finish(event):
"""Ends a rectangle drag. Returns True if it selected, False if it was a click."""
global _marquee
marquee, _marquee = _marquee, None
canvas = app_state.canvas
canvas.delete("marquee")
if marquee is None or not marquee["active"]:
return False
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
left, right = sorted((marquee["cx"], cx))
top, bottom = sorted((marquee["cy"], cy))
inside = []
for comp_tag in app_state.canvas_comp_map:
box = wiresheet.get_box_bounds(comp_tag)
if box and left <= box[0] and top <= box[1] and box[2] <= right and box[3] <= bottom:
inside.append(comp_tag)
adding = (marquee["state"] | event.state) & CTRL_MASK
wiresheet.deselect_link() # links and boxes are never selected together
if adding:
current = [t for t in app_state.selected_canvas_tags if t not in inside]
_select_boxes(current + inside)
else:
_select_boxes(inside)
count = len(app_state.selected_canvas_tags)
app_state.set_status(f"{count} component{'s' if count != 1 else ''} selected.")
return True
# Left button: anywhere on a box selects it. Only the title bar moves it; a slot row
# drags out a link, and the rest of the box does nothing more than select.
@@ -451,6 +520,19 @@ def _on_title_bar(comp_tag, cy):
def on_left_press(event):
global _marquee
_marquee = None
canvas = app_state.canvas
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
if _on_bare_sheet(cx, cy):
app_state.drag_data["tag"] = None
if not event.state & CTRL_MASK:
# A plain click on bare sheet still clears the selection, drag or not.
wiresheet.deselect_link()
_select_boxes([])
_marquee = {"x": event.x, "y": event.y, "cx": cx, "cy": cy,
"state": event.state, "active": False}
return
_box_handlers["press"](event) # selection, and arms a box move
if _start_press(event) is not None:
app_state.canvas.delete("slot_hover")
@@ -460,30 +542,37 @@ def on_left_press(event):
def on_left_motion(event):
if _press is not None or _drag is not None:
if _marquee is not None:
_marquee_motion(event)
elif _press is not None or _drag is not None:
_drag_motion(event)
else:
_box_handlers["motion"](event)
def on_left_release(event):
if _finish(event) is None:
if _marquee is not None:
_marquee_finish(event)
elif _finish(event) in (None, "click"):
_box_handlers["release"](event)
_box_handlers = {}
def install(canvas, menu_callback, box_press, box_motion, box_release, link_dialog):
def install(canvas, menu_callback, box_press, box_motion, box_release, link_dialog,
select_boxes):
"""Binds hover, link dragging on both buttons, and the box handlers it wraps.
menu_callback opens the context menu; box_* are the sheet's own left-button
select-and-move handlers, which still run whenever a press is not on a slot row;
link_dialog opens the Link dialog when a link is dropped on a title bar.
link_dialog opens the Link dialog when a link is dropped on a title bar;
select_boxes makes a list of comp tags the selection after a rectangle drag.
"""
global _menu_callback, _link_dialog
global _menu_callback, _link_dialog, _select_boxes
_menu_callback = menu_callback
_link_dialog = link_dialog
_select_boxes = select_boxes
_box_handlers.update(press=box_press, motion=box_motion, release=box_release)
canvas.bind("<Motion>", on_motion)
canvas.bind("<Leave>", on_leave, add="+")
+178 -75
View File
@@ -933,7 +933,7 @@ def do_tidy_layout(event=None):
multiplier = app_state.meta_scale // app_state.GRID_SIZE
if moved == 0:
messagebox.showinfo("Tidy Layout", "Nothing to move — no boxes overlap.")
messagebox.showinfo("Tidy Layout", "Nothing to move — the sheet is already tidy.")
elif clamped:
messagebox.showwarning(
"Tidy Layout",
@@ -991,14 +991,8 @@ def zoom_to(level, anchor=None):
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))
# Scroll the repainted sheet back under the held point.
wiresheet.scroll_view_to(sheet_x * after - anchor[0], sheet_y * after - anchor[1])
def do_zoom_in(event=None):
@@ -1054,12 +1048,8 @@ def do_zoom_fit(event=None):
applied = wiresheet.set_zoom(level)
update_zoom_label()
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, (bounds[0] * applied - wiresheet.FIT_PADDING) / span_x))
canvas.yview_moveto(max(0.0, (bounds[1] * applied - wiresheet.FIT_PADDING) / span_y))
wiresheet.scroll_view_to(bounds[0] * applied - wiresheet.FIT_PADDING,
bounds[1] * applied - wiresheet.FIT_PADDING)
return "break"
@@ -1256,16 +1246,11 @@ def scroll_canvas_to(tag):
"""Scrolls the sheet so the tagged items sit in the middle of the view."""
canvas = app_state.canvas
box = canvas.bbox(tag)
region = str(canvas.cget("scrollregion")).split()
if box is None or len(region) != 4:
if box is None:
return
span_x = float(region[2]) or 1.0
span_y = float(region[3]) or 1.0
middle_x = (box[0] + box[2]) / 2 - canvas.winfo_width() / 2
middle_y = (box[1] + box[3]) / 2 - canvas.winfo_height() / 2
canvas.xview_moveto(max(0.0, middle_x / span_x))
canvas.yview_moveto(max(0.0, middle_y / span_y))
wiresheet.scroll_view_to((box[0] + box[2]) / 2 - canvas.winfo_width() / 2,
(box[1] + box[3]) / 2 - canvas.winfo_height() / 2)
def select_link_element(link_elem):
@@ -1359,6 +1344,26 @@ def select_link_on_open_sheet(link_elem):
app_state.set_status("That link is not painted on this sheet.", warn=True)
def select_all_boxes(event=None):
"""Ctrl+A: selects every box on the open wiresheet."""
# Bound application-wide like Del, so a text field keeps its own select-all.
if editor_panel_showing():
return None
focused = app_state.window.focus_get()
if focused is not None:
if focused.winfo_toplevel() is not app_state.window:
return None
if focused.winfo_class() in TEXT_ENTRY_CLASSES:
return None
wiresheet.deselect_link() # links and boxes are never selected together
select_canvas_boxes(list(app_state.canvas_comp_map))
# Hand the sheet the keyboard, so the arrows move the boxes rather than the tree.
app_state.canvas.focus_set()
count = len(app_state.selected_canvas_tags)
app_state.set_status(f"{count} component{'s' if count != 1 else ''} selected.")
return "break"
def delete_selected_component(event=None):
"""Deletes the highlighted canvas component (or selected link) and all its descendants recursively."""
# Del is bound application-wide, so it also reaches a Properties field being typed in
@@ -1547,6 +1552,15 @@ def on_canvas_click(event):
_toggle_canvas_box(group_tag)
return
# Pressing one of several selected boxes keeps them all selected, so a drag moves
# them together; a press that never moves narrows the selection to it on release.
keep_group = (group_tag and not wire_tag and group_tag in app_state.selected_canvas_tags
and len(app_state.selected_canvas_tags) > 1)
app_state.drag_data["group"] = []
app_state.drag_data["moved"] = False
app_state.drag_data["collapse_to"] = group_tag if keep_group else None
if not keep_group:
select_canvas_boxes([])
if wire_tag:
wiresheet.select_link(wire_tag)
@@ -1556,20 +1570,26 @@ def on_canvas_click(event):
app_state.drag_data["tag"] = group_tag
app_state.drag_data["x"] = cx
app_state.drag_data["y"] = cy
if keep_group:
# Del and the menus still act on the box pressed last.
app_state.selected_canvas_tag = group_tag
else:
select_canvas_boxes([group_tag])
# Where the box starts out. The drag is measured as an offset from this pair
# Where each moving box starts out. The drag is measured as an offset from these
# 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.
dragged = app_state.canvas_comp_map.get(group_tag)
meta_prop = dragged.find("prop[@name='meta']") if dragged is not None else None
app_state.drag_data["meta_before"] = meta_prop.get("val") if meta_prop is not None else None
# Bring the clicked box clear of any neighbour it happens to sit under.
# Each entry also snapshots the pre-drag meta, so the move is one undoable step.
moving = list(app_state.selected_canvas_tags) if keep_group else [group_tag]
for tag in moving:
origin = wiresheet.get_box_origin(tag)
comp = app_state.canvas_comp_map.get(tag)
meta_prop = comp.find("prop[@name='meta']") if comp is not None else None
app_state.drag_data["group"].append({
"tag": tag, "origin": origin,
"meta_before": meta_prop.get("val") if meta_prop is not None else None,
})
# Bring the moving boxes clear of any neighbour they happen to sit under.
canvas.tag_raise(tag)
canvas.tag_raise(group_tag)
wiresheet.redraw_links()
@@ -2086,18 +2106,17 @@ def on_canvas_drag(event):
return
canvas = app_state.canvas
group_tag = app_state.drag_data["tag"]
cx = canvas.canvasx(event.x)
cy = canvas.canvasy(event.y)
group = app_state.drag_data.get("group") or []
box_x = app_state.drag_data.get("box_x")
origin = wiresheet.get_box_origin(group_tag)
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"])
if not group or any(entry["origin"] is None for entry in group):
# Nothing to align against, so the boxes just follow the pointer one to one.
for tag in [entry["tag"] for entry in group] or [app_state.drag_data["tag"]]:
canvas.move(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
app_state.drag_data["moved"] = True
wiresheet.redraw_links()
return
@@ -2106,39 +2125,68 @@ def on_canvas_drag(event):
# 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
# A box's corner also stays on the sheet, the furthest meta can store it.
limit = wiresheet.META_COORD_MAX * 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)
# Follow the pointer, but keep every moving box out of the margin strip along the
# top and left canvas edges and on the sheet -- the whole group stops together, so
# the boxes keep their places relative to one another.
dx = cx - app_state.drag_data["x"]
dy = cy - app_state.drag_data["y"]
xs = [entry["origin"][0] for entry in group]
ys = [entry["origin"][1] for entry in group]
dx = min(limit - max(xs), max(margin - min(xs), dx))
dy = min(limit - max(ys), max(margin - min(ys), dy))
shifted = False
for entry in group:
# Land each box on a step every step of the way.
start_x, start_y = entry["origin"]
target_x = min(limit, max(margin, round((start_x + dx) / step) * step))
target_y = min(limit, max(margin, round((start_y + dy) / step) * step))
origin = wiresheet.get_box_origin(entry["tag"])
if origin is None:
continue
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
canvas.move(entry["tag"], shift_x, shift_y)
shifted = True
if shifted:
app_state.drag_data["moved"] = True
# Wires are not part of the dragged groups, so they are re-routed against the
# new positions.
wiresheet.redraw_links()
def on_canvas_release(event):
collapse_to = app_state.drag_data.pop("collapse_to", None)
if collapse_to and not app_state.drag_data.get("moved"):
# A click that never moved on one of several selected boxes picks it alone.
select_canvas_boxes([collapse_to])
if not app_state.drag_data["tag"]:
return
canvas = app_state.canvas
step = app_state.meta_scale * app_state.zoom
group_tag = app_state.drag_data["tag"]
origin = wiresheet.get_box_origin(group_tag)
margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom
limit = wiresheet.META_COORD_MAX * step
group = app_state.drag_data.get("group") or [
{"tag": app_state.drag_data["tag"], "meta_before": None}]
if origin is not None:
changes = []
failed = []
for entry in group:
group_tag = entry["tag"]
origin = wiresheet.get_box_origin(group_tag)
if origin is None:
continue
current_x, current_y = origin
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)
snapped_x = min(limit, max(margin, round(current_x / step) * step))
snapped_y = min(limit, max(margin, round(current_y / step) * step))
shift_x = snapped_x - current_x
shift_y = snapped_y - current_y
@@ -2151,15 +2199,17 @@ def on_canvas_release(event):
raw_y = min(wiresheet.META_COORD_MAX, int(round(snapped_y / step)))
comp_element = app_state.canvas_comp_map.get(group_tag)
if comp_element is not None:
if comp_element is None:
continue
meta_str = None
orig_prop = comp_element.find("prop[@name='meta']")
if orig_prop is not None:
meta_str = orig_prop.get("val")
orig_x, orig_y, _, _, _, _, _, _ = read_meta_property(meta_str)
if orig_x != raw_x or orig_y != raw_y:
meta_before = app_state.drag_data.get("meta_before", meta_str)
if orig_x == raw_x and orig_y == raw_y:
continue
meta_before = entry.get("meta_before") or meta_str
update_meta_property(comp_element, meta_str, raw_x, raw_y)
if app_state.live_mode:
new_meta = comp_element.find("prop[@name='meta']")
@@ -2170,24 +2220,70 @@ def on_canvas_release(event):
remove_prop(comp_element, "meta")
else:
set_prop_value(comp_element, "meta", meta_str)
app_state.drag_data["tag"] = None
wiresheet.render_current_sheet()
live_hooks.warn_failure(
"Move", f"{comp_element.get('name', 'The component')} was not "
"moved on the device")
return
command_class = (undo.LiveMetaMoveCommand if app_state.live_mode
else undo.MetaMoveCommand)
undo.push(command_class(
[(comp_element, meta_before, raw_x, raw_y)],
label=f"move {comp_element.get('name', 'component')}"
))
app_state.mark_dirty()
failed.append(comp_element.get("name", "?"))
continue
changes.append((comp_element, meta_before, raw_x, raw_y))
app_state.drag_data["tag"] = None
app_state.drag_data["group"] = []
if changes:
command_class = (undo.LiveMetaMoveCommand if app_state.live_mode
else undo.MetaMoveCommand)
label = (f"move {changes[0][0].get('name', 'component')}" if len(changes) == 1
else f"move {len(changes)} components")
undo.push(command_class(changes, label=label))
app_state.mark_dirty()
if failed:
wiresheet.render_current_sheet()
live_hooks.warn_failure("Move", f"{', '.join(failed)} not moved on the device")
return
wiresheet.redraw_links()
def nudge_selected_boxes(dx, dy):
"""Arrow keys: moves every selected box one meta step, as one undoable move."""
canvas = app_state.canvas
tags = [t for t in app_state.selected_canvas_tags if t in app_state.canvas_comp_map]
if not tags or app_state.drag_data.get("tag"):
return "break"
step = app_state.meta_scale * app_state.zoom
margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom
limit = wiresheet.META_COORD_MAX * step
origins = {t: wiresheet.get_box_origin(t) for t in tags}
if any(o is None for o in origins.values()):
return "break"
# The group stops as one at the margin strip and the sheet's far edge.
shift_x = min(limit - max(o[0] for o in origins.values()),
max(margin - min(o[0] for o in origins.values()), dx * step))
shift_y = min(limit - max(o[1] for o in origins.values()),
max(margin - min(o[1] for o in origins.values()), dy * step))
if not shift_x and not shift_y:
return "break"
# Moved on the canvas, then written back exactly as a finished mouse drag is.
group = []
for tag in tags:
comp = app_state.canvas_comp_map[tag]
meta_prop = comp.find("prop[@name='meta']")
group.append({"tag": tag, "origin": origins[tag],
"meta_before": meta_prop.get("val") if meta_prop is not None else None})
canvas.move(tag, shift_x, shift_y)
app_state.drag_data.update(tag=tags[0], group=group, moved=True, collapse_to=None)
pressed_last = app_state.selected_canvas_tag
on_canvas_release(None)
# The same boxes stay selected, and the sheet keeps the keyboard, so the next
# arrow press moves them again.
still_here = [t for t in tags if t in app_state.canvas_comp_map and t != pressed_last]
if pressed_last in app_state.canvas_comp_map:
still_here.append(pressed_last)
select_canvas_boxes(still_here)
canvas.focus_set()
return "break"
def fit_tree_column(tree):
"""Sizes a Treeview's tree column to its widest visible row.
@@ -3261,6 +3357,8 @@ SHORTCUTS = [
("9", do_zoom_fit),
("KP_9", do_zoom_fit),
("p", wiresheet.print_sheet),
("a", select_all_boxes),
("A", select_all_boxes),
]
for key, handler in SHORTCUTS:
window.bind_all(f"<Command-{key}>", handler)
@@ -3755,11 +3853,11 @@ canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X)
canvas = tk.Canvas(
frame_editor,
bg="white",
bg=app_state.OFF_SHEET_TINT,
highlightthickness=0,
xscrollcommand=canvas_hscroll.set,
yscrollcommand=canvas_vscroll.set,
scrollregion=(0, 0, 2000, 2000),
scrollregion=(0, 0, *wiresheet.sheet_extent()),
)
app_state.canvas = canvas
canvas.pack(fill=tk.BOTH, expand=True)
@@ -3773,8 +3871,13 @@ canvas.bind("<Configure>", lambda e: wiresheet.draw_grid())
# selects and moves boxes and a plain right click is the menu, as before.
link_drag.install(canvas, on_canvas_right_click,
on_canvas_click, on_canvas_drag, on_canvas_release,
link_dialog=open_link_dialog)
link_dialog=open_link_dialog, select_boxes=select_canvas_boxes)
canvas.bind("<ButtonPress-2>", on_canvas_right_click)
# A click gives the sheet the keyboard, so the arrows nudge its selected boxes rather
# than walking whatever tree or list last had focus.
canvas.bind("<ButtonPress-1>", lambda e: canvas.focus_set(), add="+")
for _key, _dx, _dy in (("Left", -1, 0), ("Right", 1, 0), ("Up", 0, -1), ("Down", 0, 1)):
canvas.bind(f"<{_key}>", lambda e, dx=_dx, dy=_dy: nudge_selected_boxes(dx, dy))
canvas.bind("<Double-1>", on_canvas_double_click)
# Wheel zoom. Windows and macOS report a delta; X11 sends button 4 and 5 instead.
+2 -2
View File
@@ -180,7 +180,7 @@ def reset_to_startup_state():
if app_state.canvas is not None:
app_state.canvas.delete("all")
wiresheet.draw_grid()
wiresheet.fit_sheet_region()
if app_state.window is not None:
app_state.window.title(app_state.base_window_title())
@@ -223,7 +223,7 @@ def open_file():
for item in tree.get_children():
tree.delete(item)
canvas.delete("all")
wiresheet.draw_grid()
wiresheet.fit_sheet_region()
xml_tree = ET.parse(file_path)
app_state.xml_root_element = xml_tree.getroot()
+3 -1
View File
@@ -134,7 +134,9 @@ class SoxMsg:
def write_i4(self, v):
self._grow(4)
struct.pack_into(">i", self._buf, self._pos, v)
# Wrapped to signed: meta packs x into the top byte, so x >= 128 reads as an
# unsigned 32-bit value although the device stores the same bits as sys::int.
struct.pack_into(">i", self._buf, self._pos, ((v + 2**31) % 2**32) - 2**31)
self._pos += 4
def write_i8(self, v):
+548 -80
View File
@@ -122,6 +122,7 @@ FIT_PADDING = 20
# 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
SHEET_MARGIN_GRIDS = 3 # grid squares of grey kept past anything that overflows the sheet
def snap_up(value, grid_size):
@@ -238,28 +239,42 @@ def measure_box(comp, fonts, linked_slot_index):
def draw_grid():
"""Draws a grid onto the Canvas based on the scrollable region.
"""Paints the sheet and grids it; the canvas around it is the off-sheet tint.
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.
Drawn at the sheet's zoom, so a box still lands on a line at every scale.
"""
canvas = app_state.canvas
canvas.delete("grid")
# The sheet starts past the margin strip no box may enter, which stays grey too.
x1 = y1 = layout_margin(app_state.meta_scale) * app_state.zoom
x2, y2 = (coord * app_state.zoom for coord in sheet_extent())
canvas.create_rectangle(x1, y1, x2, y2, outline="#c8c8c8", tags=("grid", "sheet_bg"))
app_state.paint_sheet_tint() # white or pink by saved state, grey or pink around it
step = app_state.GRID_SIZE * app_state.zoom
if step < MIN_GRID_STEP:
return
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")
if step >= MIN_GRID_STEP:
draw_grid_lines(canvas, step, x1, y1, x2, y2)
canvas.tag_lower("grid")
def sheet_extent():
"""1:1 (width, height) of the sheet: the furthest position meta can store a box at.
Only a box's top-left corner has to fit; a box reaching past the edge is drawn over
the grey, and keeping it inside is left to the author.
"""
side = META_COORD_MAX * (app_state.meta_scale or app_state.GRID_SIZE)
return side, side
def draw_grid_lines(canvas, step, x1, y1, x2, y2):
for index in range(math.ceil(x1 / step - 1e-6), math.floor(x2 / step + 1e-6) + 1):
canvas.create_line(index * step, y1, index * step, y2, fill="#e0e0e0", tags="grid")
for index in range(math.ceil(y1 / step - 1e-6), math.floor(y2 / step + 1e-6) + 1):
canvas.create_line(x1, index * step, x2, index * step, fill="#e0e0e0", tags="grid")
def create_round_rectangle(canvas, x1, y1, x2, y2, radius=4, **kwargs):
"""Draws a visually smooth rounded rectangle using native canvas arcs and polygons."""
points = [
@@ -337,18 +352,32 @@ def segment_hits_boxes(x1, y1, x2, y2, boxes):
def path_is_clear(points, boxes, clear):
"""True if no part of the path runs under a box or crowds one.
Every run between the first turn and the last keeps `clear` from each box: `end`
above and below, `side` left and right. The two runs pinned to the slots are only
held out from under the boxes, since they have to reach their pins wherever the
rows happen to be.
Every run keeps `clear` from each box: `end` above and below, `side` left and
right. Only the two stubs pinned to the slots -- `side` out of the source and
`arrive` into the target -- are held merely out from under the boxes, since they
have to reach their pins wherever the rows happen to be. A run that carries on
level past its stub is still a run, so it may not graze a box edge on the way.
"""
points = wire_style.simplify(points)
padded = [(x1 - clear.side, y1 - clear.end, x2 + clear.side, y2 + clear.end)
for x1, y1, x2, y2 in boxes]
last = len(points) - 2
for idx, ((x1, y1), (x2, y2)) in enumerate(zip(points, points[1:])):
pinned = idx in (0, last)
if segment_hits_boxes(x1, y1, x2, y2, boxes if pinned else padded):
level = abs(y1 - y2) < LINK_EPS
lo, hi = x1, x2 # the part of the run that must keep its distance
if level and idx == 0:
lo = x1 + math.copysign(min(clear.side, abs(x2 - x1)), x2 - x1)
if level and idx == last:
hi = x2 - math.copysign(min(clear.arrive, abs(x2 - lo)), x2 - lo)
if idx in (0, last) and level:
if segment_hits_boxes(x1, y1, x2, y2, boxes):
return False
if abs(hi - lo) > LINK_EPS and segment_hits_boxes(lo, y1, hi, y2, padded):
return False
elif idx in (0, last):
if segment_hits_boxes(x1, y1, x2, y2, boxes):
return False
elif segment_hits_boxes(x1, y1, x2, y2, padded):
return False
return True
@@ -433,14 +462,15 @@ def lane_is_crowded(lane, run_lo, run_hi, claimed, pins, apart):
def free_lane(cx, run_lo, run_hi, boxes, taken, low, high, clear, max_steps=8,
pins=None):
pins=None, accept=None):
"""Nudges cx outwards in `clear.lane` steps until the run has a lane of its own.
A lane is rejected while it crowds a box (closer than `clear.side`) or while it
would run alongside an already placed run that overlaps it vertically -- two runs
passing through the same gap have to stand `clear.lane` apart. Returns None when
no lane inside the gap works. `max_steps` of 1 permits no nudging at all, which is
how the caller asks for the exact hug position and nothing else.
how the caller asks for the exact hug position and nothing else. `accept`, when
given, can turn down a lane that is otherwise free, and the search carries on.
"""
for step in range(0, max_steps):
for sign in ((0,) if step == 0 else (1, -1)):
@@ -449,11 +479,38 @@ def free_lane(cx, run_lo, run_hi, boxes, taken, low, high, clear, max_steps=8,
continue
if vertical_run_clearance(lane, run_lo, run_hi, boxes) < clear.side - LINK_EPS:
continue
if not lane_is_crowded(lane, run_lo, run_hi, taken, pins, clear.lane):
if lane_is_crowded(lane, run_lo, run_hi, taken, pins, clear.lane):
continue
if accept is None or accept(lane):
return lane
return None
def crosses_wires(points, ledger, pins):
"""True if the path would cross a run another wire has already laid down.
A crossing gets a hop, so it is allowed, but a lane that avoids one is worth
looking for first. Wires sharing a pin are one signal and never count.
"""
points = wire_style.simplify(points)
for (x1, y1), (x2, y2) in zip(points, points[1:]):
if abs(x1 - x2) < LINK_EPS: # vertical: against other wires' rows
lo, hi = min(y1, y2), max(y1, y2)
for ry, rx1, rx2, row_pins in ledger["rows"]:
if (row_pins != pins and not same_signal(pins, row_pins)
and rx1 + LINK_EPS < x1 < rx2 - LINK_EPS
and lo + LINK_EPS < ry < hi - LINK_EPS):
return True
else: # horizontal: against their lanes
lo, hi = min(x1, x2), max(x1, x2)
for cx, c_lo, c_hi, col_pins in ledger["cols"]:
if (col_pins != pins and not same_signal(pins, col_pins)
and lo + LINK_EPS < cx < hi - LINK_EPS
and c_lo + LINK_EPS < y1 < c_hi - LINK_EPS):
return True
return False
def hug_lane(edge_x, side, run_lo, run_hi, boxes, claimed, clear, max_offset=0,
min_offset=0, pins=None):
"""Lane for a run pinned to one box edge, starting `clear.side` out from it.
@@ -550,20 +607,36 @@ def route_link_path(src_box, src_y, dst_box, dst_y, boxes, taken=None,
return any(row_is_stacked(y, x1, x2, claimed_rows, pins, clear.row)
for y, x1, x2, _ in rows_of(candidate))
def straight_through(max_steps, allow_stack=False):
def path_through(lane):
return [start, leave, (lane, src_y), (lane, dst_y), arrive, end]
def straight_through(max_steps, allow_stack=False, uncrossed=False, hug_only=False):
"""A single vertical channel in the gap between the two boxes.
The two horizontal runs are pinned to their slots, so unless `allow_stack`
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.
off detouring, which puts it on a lane it can actually move. With `uncrossed`
only a lane that crosses no other wire will do; with `hug_only` only the lane
beside its own source is tried (beside either end for a level wire), not ones
beside the boxes in between.
"""
accept = None
if uncrossed:
accept = lambda lane: not crosses_wires(path_through(lane), ledger, pins)
candidates = channel_candidates(src_box, dst_box, boxes, low, high, clear)
if hug_only:
# A bent wire hugs its source only: turning at the far end instead would
# take the lane the wires below it need to turn outside it.
hugs = (src_box[2] + clear.hug,)
if abs(src_y - dst_y) < LINK_EPS:
hugs += (dst_box[0] - clear.arrive,)
candidates = [cx for cx in candidates if cx in hugs]
for cx in candidates:
lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, clear,
max_steps, pins)
max_steps, pins, accept)
if lane is None:
continue
candidate = [start, leave, (lane, src_y), (lane, dst_y), arrive, end]
candidate = path_through(lane)
if not path_is_clear(candidate, boxes, clear):
continue
if not allow_stack and stacked(candidate):
@@ -651,10 +724,15 @@ 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.
attempts = (lambda: straight_through(1),)
# Only an uncrossed one, too: a wire that would cross another waits for the
# second pass, where it can look further for a lane that does not.
# A wire whose hug lane is taken waits as well, rather than jumping to a lane far
# across the gap whose long run would then shut out the wires routed after it.
attempts = (lambda: straight_through(1, uncrossed=True, hug_only=True),)
else:
standoff = max(WIRE_STANDOFF_MAX, 3 * clear.side)
attempts = (
lambda: straight_through(8, uncrossed=True), # own lane, crossing nothing
lambda: straight_through(1), # hugging, own lane
lambda: around_boxes(clear.hug), # hugging, routed around
lambda: straight_through(8), # lane nudged out of the band
@@ -876,8 +954,16 @@ def draw_links():
jobs = collect_link_jobs(links_elem, path_to_tag, want_knob)
boxes = [b for b in (get_box_bounds(t) for t in path_to_tag.values()) if b]
# Shorter, simpler links claim lanes first; ties go to the smaller vertical span.
jobs.sort(key=lambda j: (abs(j.dst_box[0] - j.src_box[2]), abs(j.dst_y - j.src_y)))
# Links between nearer columns claim lanes first, and straight ones before bent ones.
# Among bent wires leaving one column, the first routed hugs its source closest, so
# climbing wires go top source first and falling ones bottom source first: each
# later wire then turns outside the earlier ones instead of crossing them.
def routing_order(job):
bent = abs(job.dst_y - job.src_y) > LINK_EPS
climbing = job.dst_y < job.src_y
return (round(job.dst_box[0] - job.src_box[0]), bent, not climbing,
job.src_y if climbing else -job.src_y)
jobs.sort(key=routing_order)
# What the wires placed so far have claimed, shared across the whole sheet.
# Routed in two passes: a wire that can run straight has its horizontal runs pinned
@@ -887,8 +973,16 @@ def draw_links():
# placed closer than the grid rule allows still get a wire, just a tighter one.
roomy = grid_clearance()
ledger = {"cols": [], "rows": [row for job in jobs for row in job.stub_rows(roomy)]}
# A wire that cannot go direct defers the bent wires after it across the same gap
# in the same direction too, so they still take their lanes in routing order.
deferred = set()
for job in jobs:
group = (round(job.src_box[0]), round(job.dst_box[0]), job.dst_y < job.src_y)
if group in deferred and abs(job.dst_y - job.src_y) > LINK_EPS:
continue
job.route(boxes, ledger, roomy, direct_only=True)
if job.points is None:
deferred.add(group)
for clear in (roomy, tight_clearance()):
for job in jobs:
if job.points is None:
@@ -1140,6 +1234,11 @@ def render_components(components_to_render):
"""Handles parsing and visual painting of given component listings on canvas with slot attributes."""
canvas = app_state.canvas
app_state.active_components_list = components_to_render
# Repainting the same sheet (a refresh, an undo, a device update) keeps whatever of
# the selection is still on it; another sheet starts with nothing selected.
kept = [app_state.canvas_comp_map.get(t) for t in app_state.selected_canvas_tags
if t != app_state.selected_canvas_tag]
kept.append(app_state.canvas_comp_map.get(app_state.selected_canvas_tag))
app_state.selected_canvas_tag = None
app_state.selected_canvas_tags.clear()
@@ -1160,9 +1259,6 @@ def render_components(components_to_render):
meta_scale = sheet_meta_scale(components_to_render)
app_state.meta_scale = meta_scale
max_reached_x = 1000
max_reached_y = 1000
for comp in components_to_render:
meta_prop = comp.find("prop[@name='meta']")
meta_str = meta_prop.get("val") if meta_prop is not None else None
@@ -1175,22 +1271,26 @@ def render_components(components_to_render):
app_state.canvas_comp_map[comp_tag] = comp
app_state.canvas_tag_by_element[comp] = comp_tag
box_width, box_height, slot_offsets = draw_component_box(
_, _, slot_offsets = draw_component_box(
canvas, comp, coord_x, coord_y, fonts, linked_slot_index,
("component", comp_tag), outline_tags=("block_outline",)
)
app_state.component_slot_offsets[comp_tag] = slot_offsets
if coord_x + box_width > max_reached_x:
max_reached_x = coord_x + box_width
if coord_y + box_height > max_reached_y:
max_reached_y = coord_y + box_height
draw_links()
apply_sheet_zoom(fonts, max_reached_x + 200, max_reached_y + 200)
apply_sheet_zoom(fonts)
on_sheet = set(components_to_render)
for element in kept:
if element is not None and element in on_sheet:
tag = app_state.canvas_tag_by_element[element]
app_state.selected_canvas_tags.add(tag)
app_state.selected_canvas_tag = tag
for tag in app_state.selected_canvas_tags:
canvas.itemconfig(f"{tag} && block_outline", outline="#1f4e79", width=3)
def apply_sheet_zoom(fonts, extent_x, extent_y):
def apply_sheet_zoom(fonts):
"""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
@@ -1212,10 +1312,46 @@ def apply_sheet_zoom(fonts, extent_x, extent_y):
scale_knob_ends(zoom)
canvas.config(scrollregion=(0, 0, extent_x * zoom, extent_y * zoom))
fit_sheet_region()
def fit_sheet_region():
"""Sets the scrollable area: the whole sheet, plus whatever hangs off its edges.
The sheet itself never grows (see sheet_extent). A box near the right or bottom
edge can still reach past it, or a wire loop past the top, so the scrollable area
takes those in too, with SHEET_MARGIN_GRIDS of grey beyond them.
"""
canvas = app_state.canvas
zoom = app_state.zoom
margin = SHEET_MARGIN_GRIDS * app_state.GRID_SIZE
width, height = sheet_extent()
x1, y1, x2, y2 = 0, 0, width, height
bounds = sheet_content_bounds()
if bounds is not None:
if bounds[0] < 0:
x1 = bounds[0] - margin
if bounds[1] < 0:
y1 = bounds[1] - margin
if bounds[2] > width:
x2 = bounds[2] + margin
if bounds[3] > height:
y2 = bounds[3] + margin
canvas.config(scrollregion=(x1 * zoom, y1 * zoom, x2 * zoom, y2 * zoom))
draw_grid()
def scroll_view_to(x, y):
"""Scrolls so canvas point (x, y) sits at the view's top-left, as near as the sheet allows."""
canvas = app_state.canvas
region = str(canvas.cget("scrollregion")).split()
if len(region) != 4:
return
x1, y1, x2, y2 = (float(v) for v in region)
canvas.xview_moveto(max(0.0, (x - x1) / ((x2 - x1) or 1.0)))
canvas.yview_moveto(max(0.0, (y - y1) / ((y2 - y1) or 1.0)))
def scale_knob_ends(zoom):
"""Brings the cached knob-to-knob ends onto the painted scale.
@@ -1237,6 +1373,7 @@ def redraw_links():
zoom = app_state.zoom
if zoom == 1.0:
draw_links()
fit_sheet_region() # a box dragged over the edge brings the grey past it into view
return
canvas = app_state.canvas
@@ -1245,6 +1382,7 @@ def redraw_links():
canvas.scale("component", 0, 0, zoom, zoom)
canvas.scale("link", 0, 0, zoom, zoom)
scale_knob_ends(zoom)
fit_sheet_region()
def zoom_step(direction):
@@ -1307,14 +1445,8 @@ def fit_zoom_level():
if bounds is None:
return None
# A canvas cannot scroll left of or above its origin, so anything out there cannot
# be brought into view however far the sheet is zoomed out. The fit is measured from
# the origin in that case rather than from the content's own corner.
left = max(0.0, bounds[0])
top = max(0.0, bounds[1])
content_width = bounds[2] - left
content_height = bounds[3] - top
content_width = bounds[2] - bounds[0]
content_height = bounds[3] - bounds[1]
view_width = canvas.winfo_width() - 2 * FIT_PADDING
view_height = canvas.winfo_height() - 2 * FIT_PADDING
@@ -1411,59 +1543,358 @@ def column_gap_for(run_count):
+ max(0, run_count - 1) * clear.lane)
def plan_columns(columns, column_keys, demand, scale):
LAYOUT_SWEEPS = 4 # down-and-up passes plan_columns makes to straighten the wires
def plan_columns(columns, column_keys, demand, scale, wires=()):
"""Walks the columns at `scale` px per meta step, without touching the DOM.
Returns (placements, clamped) where a placement is (item, step_x, step_y). Boxes are
positioned in pixels exactly as they will be painted, then divided down into meta
steps, so a coarser scale reaches further across the sheet for the same 0..255 range.
Column x positions come from widths and wire lanes alone. Heights are then settled
to make the wires run as straight as they can: each box is offered the height that
lines its slot up with the slot at the other end of its wires (the median, when it
has several), and each column is re-sorted by those heights, which also untangles
crossings. Boxes keep at least TIDY_GAP apart, but any gap above that is free -- a
box drops as far as its wires want. Sweeps run left to right, lining boxes up with
what feeds them, and right to left, lining them up with what they feed.
`wires` are (src_column, dst_column, src_item, src_slot, dst_item, dst_slot), left to
right only. One that skips columns stands in each column it crosses as a placeholder
box one wire tall, so it is sorted and spaced like the boxes there. The router turns
such a wire down beside its source, crossing the columns in between level with its
target slot, so a box still in its way is pushed down below it. The last sweep runs
right to left so those heights are final when that is checked.
"""
margin = snap_up(TIDY_MARGIN, scale)
clear_end = grid_clearance().end
count = len(column_keys)
# Column x positions depend only on widths and wire lanes, so they come first.
column_x = []
cursor_x = margin # the left-most column clears the canvas edge by the margin
for index, grid_x in enumerate(column_keys):
column_x.append(cursor_x)
gap = column_gap_for(demand.get(index, 0))
cursor_x = snap_up(cursor_x + max(i["width"] for i in columns[grid_x]) + gap,
scale)
order = [list(columns[key]) for key in column_keys]
# A wire skipping columns gets a placeholder in each column it crosses, as tall as a
# wire, so it is sorted and spaced among that column's boxes like one of them. That
# is what keeps a box from being placed on the wrong side of a wire it then has to
# cross. The wire is modelled as a chain through its placeholders, so every link of
# the chain joins neighbouring columns.
segments = [] # (a, a_slot, b, b_slot), a one column left of b
for src_col, dst_col, src, s_slot, dst, d_slot in wires:
previous, previous_slot = src, s_slot
for index in range(src_col + 1, dst_col):
placeholder = {"wire": True, "height": 0, "slot_offsets": {}}
order[index].append(placeholder)
segments.append((previous, previous_slot, placeholder, None))
previous, previous_slot = placeholder, None
segments.append((previous, previous_slot, dst, d_slot))
incoming = {id(item): [] for column in order for item in column}
outgoing = {id(item): [] for column in order for item in column}
for segment in segments:
outgoing[id(segment[0])].append(segment)
incoming[id(segment[2])].append(segment)
def offset(item, slot):
if item.get("wire"):
return 0
return item["slot_offsets"].get(slot, item["height"] / 2)
def gap(above, below):
"""Clear space between two neighbours in a column, boxes or wires."""
if above.get("wire") and below.get("wire"):
return grid_clearance().row
if above.get("wire") or below.get("wire"):
return clear_end
return TIDY_GAP
top = {}
for column in order: # starting point: packed from the top
cursor_y = margin
for item in column:
top[id(item)] = cursor_y
cursor_y = snap_up(cursor_y + item["height"] + TIDY_GAP, scale)
def wanted_from_left(item):
return [(top[id(a)] + offset(a, a_slot) - offset(item, b_slot), offset(item, b_slot))
for a, a_slot, _, b_slot in incoming[id(item)]]
def wanted_from_right(item):
return [(top[id(b)] + offset(b, b_slot) - offset(item, a_slot), offset(item, a_slot))
for _, a_slot, b, b_slot in outgoing[id(item)]]
def place(index, primary, fallback):
column = order[index]
wanted = {}
anchor = {}
for item in column:
if item.get("loose"):
continue
heights = sorted(primary(item) or fallback(item))
if heights:
spot, slot_offset = heights[(len(heights) - 1) // 2]
else:
spot, slot_offset = top[id(item)], 0
wanted[id(item)] = spot
anchor[id(item)] = spot + slot_offset # the wire height it lines up with
# Sorted by the wire each box lines up with, so neighbours' wires do not cross;
# unlinked boxes stay packed below everything else, in their own order.
column.sort(key=lambda i: (bool(i.get("loose")), anchor.get(id(i), 0)))
rows = sorted(
top[id(dst)] + offset(dst, d_slot)
for src_col, dst_col, _, _, dst, d_slot in wires
if src_col < index < dst_col
)
floor_y = margin
above = None
for item in column:
if above is not None:
floor_y = above_bottom + gap(above, item)
if item.get("loose"):
spot = snap_up(floor_y, scale)
elif item.get("wire"):
spot = max(floor_y, wanted[id(item)]) # never stored, so not snapped
else:
spot = max(snap_up(floor_y, scale), round(wanted[id(item)] / scale) * scale)
if not item.get("wire"):
# Step below every passing wire the box would still sit across, keeping
# the clearance the router wants between a horizontal run and a box.
moved = True
while moved:
moved = False
for row in rows:
if spot - clear_end < row < spot + item["height"] + clear_end:
spot = snap_up(row + clear_end, scale)
moved = True
top[id(item)] = spot
above, above_bottom = item, spot + item["height"]
def lift():
"""Shifts the whole sheet up until its top-most box sits on the margin again.
Boxes only ever settle downwards, so without this the sweeps would carry the
whole picture further down the sheet each time round.
"""
tops = [top[id(i)] for column in order for i in column
if not i.get("loose") and not i.get("wire")]
shift = (min(tops, default=margin) - margin) // scale * scale
if shift > 0:
for key in top:
top[key] -= shift
for _ in range(LAYOUT_SWEEPS):
for index in range(count):
place(index, wanted_from_left, wanted_from_right)
lift()
for index in reversed(range(count)):
place(index, wanted_from_right, wanted_from_left)
lift()
placements = []
clamped = 0
cursor_x = margin # the left-most column clears the canvas edge by the margin
for index, grid_x in enumerate(column_keys):
column = sorted(columns[grid_x], key=lambda i: i["grid_y"])
cursor_y = margin
for index, column in enumerate(order):
for item in column:
step_x = min(META_COORD_MAX, round(cursor_x / scale))
step_y = min(META_COORD_MAX, round(cursor_y / scale))
if item.get("wire"):
continue
step_x = min(META_COORD_MAX, round(column_x[index] / scale))
step_y = min(META_COORD_MAX, round(top[id(item)] / scale))
if step_x == META_COORD_MAX or step_y == META_COORD_MAX:
clamped += 1
placements.append((item, step_x, step_y))
# Clear the box just placed by at least TIDY_GAP, then step to the next
# whole meta step so the box still lands on a storable position.
cursor_y = snap_up(cursor_y + item["height"] + TIDY_GAP, scale)
gap = column_gap_for(demand.get(index, 0))
cursor_x = snap_up(cursor_x + max(i["width"] for i in column) + gap, scale)
return placements, clamped
def sheet_link_pairs(components):
"""(source, src_slot, target, dst_slot) for each link between two components here."""
root = app_state.xml_root_element
pairs = []
if root is None:
return pairs
links_elem = root.find("links")
if links_elem is None:
return pairs
comp_by_path = {get_component_path(comp, root): comp for comp in components}
for link in links_elem.findall("link"):
from_ref, to_ref = link.get("from", ""), link.get("to", "")
if "." not in from_ref or "." not in to_ref:
continue
src_path, src_slot = from_ref.rsplit(".", 1)
dst_path, dst_slot = to_ref.rsplit(".", 1)
src = comp_by_path.get(src_path)
dst = comp_by_path.get(dst_path)
if src is not None and dst is not None and src is not dst:
pairs.append((src, src_slot, dst, dst_slot))
return pairs
def flow_columns(items, pairs):
"""Lays linked boxes out as a left-to-right signal flow, in as few columns as it can.
Returns a list of columns, each listed top to bottom. A box goes one column right of
the furthest box feeding it, so the column count is the length of the longest chain
and cannot be smaller. A box nothing feeds is then pulled right to sit just before
the first box it feeds, which keeps its wires short. Feedback loops are cut where
they run backwards relative to the boxes' current positions, so a loop keeps the
direction the author drew it in.
"""
def position(item):
return item["grid_x"], item["grid_y"], item["comp"].get("name", "")
item_of = {item["comp"]: item for item in items}
succs = {id(item): [] for item in items}
for src, _, dst, _ in pairs:
if item_of[dst] not in succs[id(item_of[src])]:
succs[id(item_of[src])].append(item_of[dst])
for out in succs.values():
out.sort(key=position)
# Depth-first from the left-most boxes; an edge back onto the current path is
# feedback and is left out of the layering.
forward = {id(item): [] for item in items}
state = {} # id -> 1 on the current path, 2 finished
for start in sorted(items, key=position):
if id(start) in state:
continue
state[id(start)] = 1
stack = [(start, iter(succs[id(start)]))]
while stack:
node, pending = stack[-1]
nxt = next(pending, None)
if nxt is None:
state[id(node)] = 2
stack.pop()
elif state.get(id(nxt)) == 1:
continue # feedback
else:
forward[id(node)].append(nxt)
if id(nxt) not in state:
state[id(nxt)] = 1
stack.append((nxt, iter(succs[id(nxt)])))
preds = {id(item): [] for item in items}
for item in items:
for nxt in forward[id(item)]:
preds[id(nxt)].append(item)
# Longest path from the sources, in topological order.
layer = {}
indegree = {id(item): len(preds[id(item)]) for item in items}
ready = sorted((i for i in items if not indegree[id(i)]), key=position)
order = []
while ready:
node = ready.pop(0)
order.append(node)
layer[id(node)] = max((layer[id(p)] + 1 for p in preds[id(node)]), default=0)
for nxt in forward[id(node)]:
indegree[id(nxt)] -= 1
if not indegree[id(nxt)]:
ready.append(nxt)
for node in reversed(order):
if not preds[id(node)] and forward[id(node)]:
layer[id(node)] = min(layer[id(n)] for n in forward[id(node)]) - 1
# Into any one box, wires from further left must arrive at higher slots than wires
# from nearer columns. A long wire crosses the columns in between level with its
# slot, so a nearer box feeding a slot above it would have to cross it on the way.
# Only boxes nothing feeds are free to move, so they are held back far enough left.
feeders = {} # id(target) -> [(slot height, source)]
for src, _, dst, d_slot in pairs:
src_item, dst_item = item_of[src], item_of[dst]
if dst_item in forward[id(src_item)]:
feeders.setdefault(id(dst_item), []).append(
(dst_item["slot_offsets"].get(d_slot, 0), src_item))
sources = [n for n in order if not preds[id(n)] and forward[id(n)]]
changed = True
while changed:
changed = False
for node in sources:
limit = layer[id(node)]
for target_id, fed in feeders.items():
own = [height for height, src in fed if src is node]
for height, other in fed:
if other is not node and own and height > min(own):
limit = min(limit, layer[id(other)])
limit = max(0, limit)
if limit < layer[id(node)]:
layer[id(node)] = limit
changed = True
columns = [[] for _ in range(max(layer.values(), default=-1) + 1)]
for item in sorted(items, key=lambda i: (i["grid_y"], i["grid_x"])):
columns[layer[id(item)]].append(item)
# Order each column after the boxes feeding it, so wires run as level as they can;
# then once back right to left, settling the boxes nothing feeds beside the boxes
# they feed. Ties keep the author's top-to-bottom order.
row = {}
def settle(column, neighbours):
def key(item):
near = [row[id(n)] for n in neighbours(item) if id(n) in row]
return sum(near) / len(near) if near else row.get(id(item), float("inf"))
column.sort(key=key)
for index, item in enumerate(column):
row[id(item)] = index
for column in columns:
settle(column, lambda i: preds[id(i)])
for column in reversed(columns):
settle(column, lambda i: forward[id(i)] if not preds[id(i)] else ())
return columns
def build_layout_items(components):
"""Column-grouped layout items for a sheet, measured rather than read off the canvas.
Returns (columns, column_keys, demand). Called both while painting -- to settle the
scale before anything is drawn -- and by tidy, so the two agree.
Returns (columns, column_keys, demand, wires), each column listed top to bottom.
Linked boxes are arranged by flow_columns. A box with no link to another box on this
sheet -- unlinked, or linked only off-sheet -- goes to the bottom of the first
column, alphabetically by name.
Called both while painting -- to settle the scale before anything is drawn -- and by
tidy, so the two agree.
"""
fonts = sheet_fonts()
linked_slot_index = build_linked_slot_index()
pairs = sheet_link_pairs(components)
on_sheet = {comp for src, _, dst, _ in pairs for comp in (src, dst)}
columns = {}
linked = []
loose = []
for comp in components:
meta_prop = comp.find("prop[@name='meta']")
meta_val = meta_prop.get("val") if meta_prop is not None else None
grid_x, grid_y = read_meta_property(meta_val)[:2]
width, height = measure_box(comp, fonts, linked_slot_index)[:2]
columns.setdefault(grid_x, []).append({
width, height, slot_rows = measure_box(comp, fonts, linked_slot_index)[:3]
item = {
"comp": comp, "meta": meta_val,
"grid_x": grid_x, "grid_y": grid_y,
"width": width, "height": height,
})
# Where each slot's wire meets the box, as painted by draw_component_box.
"slot_offsets": {
raw_name: HEADER_HEIGHT + row * SLOT_ROW_HEIGHT + SLOT_ROW_HEIGHT // 2
for row, (raw_name, _, _) in enumerate(slot_rows)
},
}
(linked if comp in on_sheet else loose).append(item)
columns = dict(enumerate(flow_columns(linked, pairs)))
if loose:
loose.sort(key=lambda i: (i["comp"].get("name", "").casefold(),
i["comp"].get("name", "")))
for item in loose:
item["loose"] = True # plan_columns keeps these packed at the bottom
columns.setdefault(0, []).extend(loose)
column_keys = sorted(columns)
column_of_comp = {
@@ -1471,7 +1902,42 @@ def build_layout_items(components):
for index, key in enumerate(column_keys)
for item in columns[key]
}
return columns, column_keys, wire_demand_by_boundary(column_of_comp)
return (columns, column_keys, wire_demand_by_boundary(column_of_comp),
forward_wires(columns, column_of_comp))
def forward_wires(columns, column_of_comp):
"""Every wire running left to right between two columns, for plan_columns.
Each is (src_column, dst_column, src_item, src_slot, dst_item, dst_slot). Feedback
wires, running right to left, are left out: they go over or under the boxes anyway.
"""
wires = []
root = app_state.xml_root_element
links_elem = root.find("links") if root is not None else None
if links_elem is None:
return wires
item_by_path = {
get_component_path(item["comp"], root): item
for column in columns.values()
for item in column
}
for link in links_elem.findall("link"):
from_ref, to_ref = link.get("from", ""), link.get("to", "")
if "." not in from_ref or "." not in to_ref:
continue
src_path, src_slot = from_ref.rsplit(".", 1)
dst_path, dst_slot = to_ref.rsplit(".", 1)
src_item = item_by_path.get(src_path)
dst_item = item_by_path.get(dst_path)
if src_item is None or dst_item is None:
continue
src_col = column_of_comp[src_item["comp"]]
dst_col = column_of_comp[dst_item["comp"]]
if dst_col > src_col:
wires.append((src_col, dst_col, src_item, src_slot, dst_item, dst_slot))
return wires
def sheet_meta_scale(components):
@@ -1485,22 +1951,24 @@ def sheet_meta_scale(components):
if not components:
return grid_size
columns, column_keys, demand = build_layout_items(components)
columns, column_keys, demand, wires = build_layout_items(components)
if not column_keys:
return grid_size
for multiplier in range(1, META_SCALE_MAX + 1):
_, clamped = plan_columns(columns, column_keys, demand, grid_size * multiplier)
_, clamped = plan_columns(columns, column_keys, demand, grid_size * multiplier,
wires)
if not clamped:
return grid_size * multiplier
return grid_size * META_SCALE_MAX
def tidy_layout():
"""Re-flows the open sheet so no two boxes overlap, preserving the author's columns.
"""Re-flows the open sheet as a left-to-right signal flow with no two boxes overlapping.
Boxes keep their column grouping and their top-to-bottom order; only the spacing is
rebuilt, from the sizes actually painted. Every placement lands on a whole meta step,
Columns follow the wiring (see flow_columns), using as few as the longest chain of
links allows; spacing is rebuilt from the sizes actually painted. Boxes with no link to another box on the
sheet are gathered at the bottom of the first column, A to Z. Every placement lands on a whole meta step,
the first column and row clear the canvas edge by at least TIDY_MARGIN, and
neighbours are left at least TIDY_GAP apart, widened where wires need lanes. A sheet
too wide for meta's 0..255 range is laid out at a coarser multiple of the grid rather
@@ -1511,12 +1979,12 @@ def tidy_layout():
return 0, 0
components = list(app_state.canvas_comp_map.values())
columns, column_keys, demand = build_layout_items(components)
columns, column_keys, demand, wires = build_layout_items(components)
if not column_keys:
return 0, 0
scale = sheet_meta_scale(components)
placements, clamped = plan_columns(columns, column_keys, demand, scale)
placements, clamped = plan_columns(columns, column_keys, demand, scale, wires)
changes = [
(item["comp"], item["meta"], step_x, step_y)