From b3d96d96b638cf57ba950d4db60e0ec3e50fc8a4 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Sun, 18 Oct 2026 10:15:22 +0300 Subject: [PATCH] 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 --- app_state.py | 2 +- main.py | 46 +++---- sax_file.py | 4 +- sox.py | 4 +- wiresheet.py | 357 ++++++++++++++++++++++++++++++++++++++++++--------- 5 files changed, 321 insertions(+), 92 deletions(-) diff --git a/app_state.py b/app_state.py index 26de13a..fef3937 100644 --- a/app_state.py +++ b/app_state.py @@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it. import configparser import os -VERSION = "0.0.0.173" +VERSION = "0.0.0.174" PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") DEFAULT_GRID_SIZE = 10 DEFAULT_ADD_TIMESTAMP = True diff --git a/main.py b/main.py index 03ee601..7654012 100644 --- a/main.py +++ b/main.py @@ -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): @@ -2111,8 +2096,10 @@ def on_canvas_drag(event): # 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) + # The box's corner also stays on the sheet, the furthest meta can store it. + limit = wiresheet.META_COORD_MAX * step + target_x = min(limit, max(margin, round(wanted_x / step) * step)) + target_y = min(limit, max(margin, round(wanted_y / step) * step)) shift_x = target_x - origin[0] shift_y = target_y - origin[1] @@ -2137,8 +2124,9 @@ def on_canvas_release(event): 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) + limit = wiresheet.META_COORD_MAX * 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 @@ -3755,11 +3743,11 @@ canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X) canvas = tk.Canvas( frame_editor, - bg="white", + bg=wiresheet.OFF_SHEET_COLOUR, 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) diff --git a/sax_file.py b/sax_file.py index 0b3135e..dff31bc 100644 --- a/sax_file.py +++ b/sax_file.py @@ -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() diff --git a/sox.py b/sox.py index 8b39574..d437bdd 100644 --- a/sox.py +++ b/sox.py @@ -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): diff --git a/wiresheet.py b/wiresheet.py index b763581..667a660 100644 --- a/wiresheet.py +++ b/wiresheet.py @@ -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)