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>
This commit is contained in:
2026-10-18 10:15:22 +03:00
co-authored by Claude Opus 5.5
parent 160f65c95e
commit b3d96d96b6
5 changed files with 321 additions and 92 deletions
+298 -59
View File
@@ -122,6 +122,8 @@ 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
OFF_SHEET_COLOUR = "#f3f3f3" # canvas outside the area a box's origin can be stored in
def snap_up(value, grid_size):
@@ -238,28 +240,40 @@ 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 white and grids it; the canvas around it stays OFF_SHEET_COLOUR.
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")
x1, y1 = 0, 0
x2, y2 = (coord * app_state.zoom for coord in sheet_extent())
canvas.create_rectangle(x1, y1, x2, y2, fill="white", outline="#c8c8c8", tags="grid")
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 = [
@@ -1160,9 +1174,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 +1186,17 @@ 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)
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 +1218,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 +1279,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 +1288,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 +1351,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 +1449,225 @@ def column_gap_for(run_count):
+ max(0, run_count - 1) * clear.lane)
def plan_columns(columns, column_keys, demand, scale):
def plan_columns(columns, column_keys, demand, scale, spans=()):
"""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.
`spans` are the wires that skip columns, as (src_column, dst_column, dst_item,
dst_slot). The router turns such a wire down beside its source, so it crosses every
column in between level with its target slot; a box in its way is pushed down below
it instead of making the wire detour. Columns are filled right to left for that,
since a wire's height is set by its target, which always lies further right.
"""
margin = snap_up(TIDY_MARGIN, scale)
placements = []
clear_end = grid_clearance().end
clamped = 0
cursor_x = margin # the left-most column clears the canvas edge by the margin
# 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 = sorted(columns[grid_x], key=lambda i: i["grid_y"])
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)
top_of = {} # id(item) -> pixel y its box was given
placed = {} # id(item) -> (step_x, step_y)
for index in reversed(range(len(column_keys))):
rows = sorted(
top_of[id(dst_item)] + dst_item["slot_offsets"].get(
dst_slot, dst_item["height"] / 2)
for src_col, dst_col, dst_item, dst_slot in spans
if src_col < index < dst_col
)
cursor_y = margin
for item in column:
step_x = min(META_COORD_MAX, round(cursor_x / scale))
step_y = min(META_COORD_MAX, round(cursor_y / scale))
for item in columns[column_keys[index]]: # already in top-to-bottom order
top = cursor_y
# Step below every passing wire the box would sit across, keeping the
# clearance the router wants between a horizontal run and a box edge.
moved = True
while moved:
moved = False
for row in rows:
if top - clear_end < row < top + item["height"] + clear_end:
top = snap_up(row + clear_end, scale)
moved = True
top_of[id(item)] = top
step_x = min(META_COORD_MAX, round(column_x[index] / scale))
step_y = min(META_COORD_MAX, round(top / scale))
if step_x == META_COORD_MAX or step_y == META_COORD_MAX:
clamped += 1
placements.append((item, step_x, step_y))
placed[id(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)
cursor_y = snap_up(top + item["height"] + TIDY_GAP, scale)
placements = [
(item, *placed[id(item)])
for grid_x in column_keys
for item in columns[grid_x]
]
return placements, clamped
def sheet_link_pairs(components):
"""(source, target) for each link between two different components on this sheet."""
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 = comp_by_path.get(from_ref.rsplit(".", 1)[0])
dst = comp_by_path.get(to_ref.rsplit(".", 1)[0])
if src is not None and dst is not None and src is not dst:
pairs.append((src, dst))
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
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, spans), 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 pair in pairs for comp in pair}
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", "")))
columns.setdefault(0, []).extend(loose)
column_keys = sorted(columns)
column_of_comp = {
@@ -1471,7 +1675,40 @@ 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),
column_skipping_spans(columns, column_of_comp))
def column_skipping_spans(columns, column_of_comp):
"""Wires running left to right past at least one column, for plan_columns.
Each is (src_column, dst_column, dst_item, dst_slot).
"""
spans = []
root = app_state.xml_root_element
links_elem = root.find("links") if root is not None else None
if links_elem is None:
return spans
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_item = item_by_path.get(from_ref.rsplit(".", 1)[0])
dst_path, dst_slot = to_ref.rsplit(".", 1)
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 >= 2:
spans.append((src_col, dst_col, dst_item, dst_slot))
return spans
def sheet_meta_scale(components):
@@ -1485,22 +1722,24 @@ def sheet_meta_scale(components):
if not components:
return grid_size
columns, column_keys, demand = build_layout_items(components)
columns, column_keys, demand, spans = 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,
spans)
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 +1750,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, spans = 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, spans)
changes = [
(item["comp"], item["meta"], step_x, step_y)