Compare commits
7
Commits
160f65c95e
...
0d5e5115d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d5e5115d9 | ||
|
|
1a4c84f9d5 | ||
|
|
75df6d0a6e | ||
|
|
81c4a1d31d | ||
|
|
0c69f18046 | ||
|
|
3d9db54956 | ||
|
|
b3d96d96b6 |
+22
-5
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
|
|||||||
import configparser
|
import configparser
|
||||||
import os
|
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")
|
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||||
DEFAULT_GRID_SIZE = 10
|
DEFAULT_GRID_SIZE = 10
|
||||||
DEFAULT_ADD_TIMESTAMP = True
|
DEFAULT_ADD_TIMESTAMP = True
|
||||||
@@ -41,6 +41,9 @@ status_label = None # the window's bottom status bar
|
|||||||
# colour wherever the change was made.
|
# colour wherever the change was made.
|
||||||
DIRTY_TINT = "#ffe6e6"
|
DIRTY_TINT = "#ffe6e6"
|
||||||
CLEAN_TINT = "white"
|
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,
|
# 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
|
# 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
|
global is_dirty
|
||||||
if not is_dirty:
|
if not is_dirty:
|
||||||
is_dirty = True
|
is_dirty = True
|
||||||
if canvas is not None:
|
paint_sheet_tint()
|
||||||
canvas.config(bg=DIRTY_TINT)
|
|
||||||
refresh_dirty_display()
|
refresh_dirty_display()
|
||||||
|
|
||||||
|
|
||||||
@@ -248,11 +250,26 @@ def mark_clean():
|
|||||||
"""Clears the unsaved-state marker after a successful save or a fresh load."""
|
"""Clears the unsaved-state marker after a successful save or a fresh load."""
|
||||||
global is_dirty
|
global is_dirty
|
||||||
is_dirty = False
|
is_dirty = False
|
||||||
if canvas is not None:
|
paint_sheet_tint()
|
||||||
canvas.config(bg=CLEAN_TINT)
|
|
||||||
refresh_dirty_display()
|
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 device connection state ────────────────────────────────
|
||||||
live_mode = False
|
live_mode = False
|
||||||
device_session = None # device_session.DeviceSession instance
|
device_session = None # device_session.DeviceSession instance
|
||||||
|
|||||||
+96
-7
@@ -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
|
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.
|
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
|
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.
|
it draws a link instead, as in Workbench.
|
||||||
"""
|
"""
|
||||||
@@ -33,11 +35,15 @@ HOVER_COLOR = "#1f4e79"
|
|||||||
DROP_OK_COLOR = "#2e9d3a"
|
DROP_OK_COLOR = "#2e9d3a"
|
||||||
DROP_BAD_COLOR = "#d62828"
|
DROP_BAD_COLOR = "#d62828"
|
||||||
RUBBER_COLOR = "#2f6fba"
|
RUBBER_COLOR = "#2f6fba"
|
||||||
|
MARQUEE_COLOR = "#2f6fba"
|
||||||
|
CTRL_MASK = 0x0004
|
||||||
|
|
||||||
_cursors = {} # role -> Tk cursor spec, built on first use
|
_cursors = {} # role -> Tk cursor spec, built on first use
|
||||||
_press = None # pending right press: {"hit", "x", "y", "event"}
|
_press = None # pending right press: {"hit", "x", "y", "event"}
|
||||||
_drag = None # live link drag: {"hit", "anchor"}
|
_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
|
_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)
|
_link_dialog = None # opens the Link dialog: (source, target, src_slot, tgt_slot)
|
||||||
|
|
||||||
|
|
||||||
@@ -301,14 +307,16 @@ def _anchor(hit):
|
|||||||
|
|
||||||
def clear():
|
def clear():
|
||||||
"""Drops any hover ring, rubber band and drag state; called on every repaint."""
|
"""Drops any hover ring, rubber band and drag state; called on every repaint."""
|
||||||
global _press, _drag
|
global _press, _drag, _marquee
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
if canvas is None:
|
if canvas is None:
|
||||||
return
|
return
|
||||||
canvas.delete("slot_hover")
|
canvas.delete("slot_hover")
|
||||||
canvas.delete("link_rubber")
|
canvas.delete("link_rubber")
|
||||||
|
canvas.delete("marquee")
|
||||||
_press = None
|
_press = None
|
||||||
_drag = None
|
_drag = None
|
||||||
|
_marquee = None
|
||||||
_set_cursor(None)
|
_set_cursor(None)
|
||||||
|
|
||||||
|
|
||||||
@@ -442,6 +450,67 @@ def on_right_release(event):
|
|||||||
_menu_callback(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
|
# 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.
|
# 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):
|
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
|
_box_handlers["press"](event) # selection, and arms a box move
|
||||||
if _start_press(event) is not None:
|
if _start_press(event) is not None:
|
||||||
app_state.canvas.delete("slot_hover")
|
app_state.canvas.delete("slot_hover")
|
||||||
@@ -460,30 +542,37 @@ def on_left_press(event):
|
|||||||
|
|
||||||
|
|
||||||
def on_left_motion(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)
|
_drag_motion(event)
|
||||||
else:
|
else:
|
||||||
_box_handlers["motion"](event)
|
_box_handlers["motion"](event)
|
||||||
|
|
||||||
|
|
||||||
def on_left_release(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["release"](event)
|
||||||
|
|
||||||
|
|
||||||
_box_handlers = {}
|
_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.
|
"""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
|
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;
|
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
|
_menu_callback = menu_callback
|
||||||
_link_dialog = link_dialog
|
_link_dialog = link_dialog
|
||||||
|
_select_boxes = select_boxes
|
||||||
_box_handlers.update(press=box_press, motion=box_motion, release=box_release)
|
_box_handlers.update(press=box_press, motion=box_motion, release=box_release)
|
||||||
canvas.bind("<Motion>", on_motion)
|
canvas.bind("<Motion>", on_motion)
|
||||||
canvas.bind("<Leave>", on_leave, add="+")
|
canvas.bind("<Leave>", on_leave, add="+")
|
||||||
|
|||||||
@@ -933,7 +933,7 @@ def do_tidy_layout(event=None):
|
|||||||
multiplier = app_state.meta_scale // app_state.GRID_SIZE
|
multiplier = app_state.meta_scale // app_state.GRID_SIZE
|
||||||
|
|
||||||
if moved == 0:
|
if moved == 0:
|
||||||
messagebox.showinfo("Tidy Layout", "Nothing to move — no boxes overlap.")
|
messagebox.showinfo("Tidy Layout", "Nothing to move — the sheet is already tidy.")
|
||||||
elif clamped:
|
elif clamped:
|
||||||
messagebox.showwarning(
|
messagebox.showwarning(
|
||||||
"Tidy Layout",
|
"Tidy Layout",
|
||||||
@@ -991,14 +991,8 @@ def zoom_to(level, anchor=None):
|
|||||||
if after == before:
|
if after == before:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Scroll the repainted sheet back under the held point. The scrollregion starts at
|
# Scroll the repainted sheet back under the held point.
|
||||||
# the origin, so its far corner is the span the view fractions are measured against.
|
wiresheet.scroll_view_to(sheet_x * after - anchor[0], sheet_y * after - anchor[1])
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
def do_zoom_in(event=None):
|
def do_zoom_in(event=None):
|
||||||
@@ -1054,12 +1048,8 @@ def do_zoom_fit(event=None):
|
|||||||
applied = wiresheet.set_zoom(level)
|
applied = wiresheet.set_zoom(level)
|
||||||
update_zoom_label()
|
update_zoom_label()
|
||||||
|
|
||||||
region = str(canvas.cget("scrollregion")).split()
|
wiresheet.scroll_view_to(bounds[0] * applied - wiresheet.FIT_PADDING,
|
||||||
if len(region) == 4:
|
bounds[1] * applied - wiresheet.FIT_PADDING)
|
||||||
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))
|
|
||||||
return "break"
|
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."""
|
"""Scrolls the sheet so the tagged items sit in the middle of the view."""
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
box = canvas.bbox(tag)
|
box = canvas.bbox(tag)
|
||||||
region = str(canvas.cget("scrollregion")).split()
|
if box is None:
|
||||||
if box is None or len(region) != 4:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
span_x = float(region[2]) or 1.0
|
wiresheet.scroll_view_to((box[0] + box[2]) / 2 - canvas.winfo_width() / 2,
|
||||||
span_y = float(region[3]) or 1.0
|
(box[1] + box[3]) / 2 - canvas.winfo_height() / 2)
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
def select_link_element(link_elem):
|
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)
|
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):
|
def delete_selected_component(event=None):
|
||||||
"""Deletes the highlighted canvas component (or selected link) and all its descendants recursively."""
|
"""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
|
# Del is bound application-wide, so it also reaches a Properties field being typed in
|
||||||
@@ -1547,7 +1552,16 @@ def on_canvas_click(event):
|
|||||||
_toggle_canvas_box(group_tag)
|
_toggle_canvas_box(group_tag)
|
||||||
return
|
return
|
||||||
|
|
||||||
select_canvas_boxes([])
|
# 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:
|
if wire_tag:
|
||||||
wiresheet.select_link(wire_tag)
|
wiresheet.select_link(wire_tag)
|
||||||
return
|
return
|
||||||
@@ -1556,20 +1570,26 @@ def on_canvas_click(event):
|
|||||||
app_state.drag_data["tag"] = group_tag
|
app_state.drag_data["tag"] = group_tag
|
||||||
app_state.drag_data["x"] = cx
|
app_state.drag_data["x"] = cx
|
||||||
app_state.drag_data["y"] = cy
|
app_state.drag_data["y"] = cy
|
||||||
select_canvas_boxes([group_tag])
|
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.
|
# rather than accumulated per motion event, so repeated snapping cannot drift.
|
||||||
origin = wiresheet.get_box_origin(group_tag)
|
# Each entry also snapshots the pre-drag meta, so the move is one undoable step.
|
||||||
app_state.drag_data["box_x"] = origin[0] if origin else None
|
moving = list(app_state.selected_canvas_tags) if keep_group else [group_tag]
|
||||||
app_state.drag_data["box_y"] = origin[1] if origin else None
|
for tag in moving:
|
||||||
|
origin = wiresheet.get_box_origin(tag)
|
||||||
# Snapshot the pre-drag meta so the move can be pushed as one undoable step.
|
comp = app_state.canvas_comp_map.get(tag)
|
||||||
dragged = app_state.canvas_comp_map.get(group_tag)
|
meta_prop = comp.find("prop[@name='meta']") if comp is not None else None
|
||||||
meta_prop = dragged.find("prop[@name='meta']") if dragged is not None else None
|
app_state.drag_data["group"].append({
|
||||||
app_state.drag_data["meta_before"] = meta_prop.get("val") if meta_prop is not None else None
|
"tag": tag, "origin": origin,
|
||||||
|
"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.
|
})
|
||||||
|
# Bring the moving boxes clear of any neighbour they happen to sit under.
|
||||||
|
canvas.tag_raise(tag)
|
||||||
canvas.tag_raise(group_tag)
|
canvas.tag_raise(group_tag)
|
||||||
wiresheet.redraw_links()
|
wiresheet.redraw_links()
|
||||||
|
|
||||||
@@ -2086,18 +2106,17 @@ def on_canvas_drag(event):
|
|||||||
return
|
return
|
||||||
|
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
group_tag = app_state.drag_data["tag"]
|
|
||||||
cx = canvas.canvasx(event.x)
|
cx = canvas.canvasx(event.x)
|
||||||
cy = canvas.canvasy(event.y)
|
cy = canvas.canvasy(event.y)
|
||||||
|
group = app_state.drag_data.get("group") or []
|
||||||
|
|
||||||
box_x = app_state.drag_data.get("box_x")
|
if not group or any(entry["origin"] is None for entry in group):
|
||||||
origin = wiresheet.get_box_origin(group_tag)
|
# 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"]]:
|
||||||
if box_x is None or origin is None:
|
canvas.move(tag, cx - app_state.drag_data["x"], cy - app_state.drag_data["y"])
|
||||||
# Nothing to align against, so the box just follows the pointer one to one.
|
|
||||||
canvas.move(group_tag, cx - app_state.drag_data["x"], cy - app_state.drag_data["y"])
|
|
||||||
app_state.drag_data["x"] = cx
|
app_state.drag_data["x"] = cx
|
||||||
app_state.drag_data["y"] = cy
|
app_state.drag_data["y"] = cy
|
||||||
|
app_state.drag_data["moved"] = True
|
||||||
wiresheet.redraw_links()
|
wiresheet.redraw_links()
|
||||||
return
|
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.
|
# Canvas coordinates carry the sheet's zoom, so the step and the margin do too.
|
||||||
step = app_state.meta_scale * app_state.zoom
|
step = app_state.meta_scale * app_state.zoom
|
||||||
margin = wiresheet.layout_margin(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
|
# Follow the pointer, but keep every moving box out of the margin strip along the
|
||||||
# out of the margin strip along the top and left canvas edges.
|
# top and left canvas edges and on the sheet -- the whole group stops together, so
|
||||||
wanted_x = box_x + (cx - app_state.drag_data["x"])
|
# the boxes keep their places relative to one another.
|
||||||
wanted_y = app_state.drag_data["box_y"] + (cy - app_state.drag_data["y"])
|
dx = cx - app_state.drag_data["x"]
|
||||||
target_x = max(margin, round(wanted_x / step) * step)
|
dy = cy - app_state.drag_data["y"]
|
||||||
target_y = max(margin, round(wanted_y / step) * step)
|
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))
|
||||||
|
|
||||||
shift_x = target_x - origin[0]
|
shifted = False
|
||||||
shift_y = target_y - origin[1]
|
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(entry["tag"], shift_x, shift_y)
|
||||||
|
shifted = True
|
||||||
|
|
||||||
if shift_x or shift_y:
|
if shifted:
|
||||||
canvas.move(group_tag, shift_x, shift_y)
|
app_state.drag_data["moved"] = True
|
||||||
# Wires are not part of the dragged group, so they are re-routed against the
|
# Wires are not part of the dragged groups, so they are re-routed against the
|
||||||
# new positions.
|
# new positions.
|
||||||
wiresheet.redraw_links()
|
wiresheet.redraw_links()
|
||||||
|
|
||||||
|
|
||||||
def on_canvas_release(event):
|
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"]:
|
if not app_state.drag_data["tag"]:
|
||||||
return
|
return
|
||||||
|
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
step = app_state.meta_scale * app_state.zoom
|
step = app_state.meta_scale * app_state.zoom
|
||||||
group_tag = app_state.drag_data["tag"]
|
margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom
|
||||||
origin = wiresheet.get_box_origin(group_tag)
|
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
|
current_x, current_y = origin
|
||||||
|
|
||||||
margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom
|
snapped_x = min(limit, max(margin, round(current_x / step) * step))
|
||||||
snapped_x = max(margin, round(current_x / step) * step)
|
snapped_y = min(limit, max(margin, round(current_y / step) * step))
|
||||||
snapped_y = max(margin, round(current_y / step) * step)
|
|
||||||
|
|
||||||
shift_x = snapped_x - current_x
|
shift_x = snapped_x - current_x
|
||||||
shift_y = snapped_y - current_y
|
shift_y = snapped_y - current_y
|
||||||
@@ -2151,43 +2199,91 @@ def on_canvas_release(event):
|
|||||||
raw_y = min(wiresheet.META_COORD_MAX, int(round(snapped_y / step)))
|
raw_y = min(wiresheet.META_COORD_MAX, int(round(snapped_y / step)))
|
||||||
|
|
||||||
comp_element = app_state.canvas_comp_map.get(group_tag)
|
comp_element = app_state.canvas_comp_map.get(group_tag)
|
||||||
if comp_element is not None:
|
if comp_element is None:
|
||||||
meta_str = None
|
continue
|
||||||
orig_prop = comp_element.find("prop[@name='meta']")
|
meta_str = None
|
||||||
if orig_prop is not None:
|
orig_prop = comp_element.find("prop[@name='meta']")
|
||||||
meta_str = orig_prop.get("val")
|
if orig_prop is not None:
|
||||||
|
meta_str = orig_prop.get("val")
|
||||||
|
|
||||||
orig_x, orig_y, _, _, _, _, _, _ = read_meta_property(meta_str)
|
orig_x, orig_y, _, _, _, _, _, _ = read_meta_property(meta_str)
|
||||||
if orig_x != raw_x or orig_y != raw_y:
|
if orig_x == raw_x and orig_y == raw_y:
|
||||||
meta_before = app_state.drag_data.get("meta_before", meta_str)
|
continue
|
||||||
update_meta_property(comp_element, meta_str, raw_x, raw_y)
|
meta_before = entry.get("meta_before") or meta_str
|
||||||
if app_state.live_mode:
|
update_meta_property(comp_element, meta_str, raw_x, raw_y)
|
||||||
new_meta = comp_element.find("prop[@name='meta']")
|
if app_state.live_mode:
|
||||||
if new_meta is None or not live_hooks.live_set_prop_value(
|
new_meta = comp_element.find("prop[@name='meta']")
|
||||||
comp_element, "meta", new_meta.get("val", "0")):
|
if new_meta is None or not live_hooks.live_set_prop_value(
|
||||||
# Back where the device still has it, so the sheet tells the truth.
|
comp_element, "meta", new_meta.get("val", "0")):
|
||||||
if meta_str is None:
|
# Back where the device still has it, so the sheet tells the truth.
|
||||||
remove_prop(comp_element, "meta")
|
if meta_str is None:
|
||||||
else:
|
remove_prop(comp_element, "meta")
|
||||||
set_prop_value(comp_element, "meta", meta_str)
|
else:
|
||||||
app_state.drag_data["tag"] = None
|
set_prop_value(comp_element, "meta", meta_str)
|
||||||
wiresheet.render_current_sheet()
|
failed.append(comp_element.get("name", "?"))
|
||||||
live_hooks.warn_failure(
|
continue
|
||||||
"Move", f"{comp_element.get('name', 'The component')} was not "
|
changes.append((comp_element, meta_before, raw_x, raw_y))
|
||||||
"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()
|
|
||||||
|
|
||||||
app_state.drag_data["tag"] = None
|
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()
|
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):
|
def fit_tree_column(tree):
|
||||||
"""Sizes a Treeview's tree column to its widest visible row.
|
"""Sizes a Treeview's tree column to its widest visible row.
|
||||||
|
|
||||||
@@ -3261,6 +3357,8 @@ SHORTCUTS = [
|
|||||||
("9", do_zoom_fit),
|
("9", do_zoom_fit),
|
||||||
("KP_9", do_zoom_fit),
|
("KP_9", do_zoom_fit),
|
||||||
("p", wiresheet.print_sheet),
|
("p", wiresheet.print_sheet),
|
||||||
|
("a", select_all_boxes),
|
||||||
|
("A", select_all_boxes),
|
||||||
]
|
]
|
||||||
for key, handler in SHORTCUTS:
|
for key, handler in SHORTCUTS:
|
||||||
window.bind_all(f"<Command-{key}>", handler)
|
window.bind_all(f"<Command-{key}>", handler)
|
||||||
@@ -3755,11 +3853,11 @@ canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X)
|
|||||||
|
|
||||||
canvas = tk.Canvas(
|
canvas = tk.Canvas(
|
||||||
frame_editor,
|
frame_editor,
|
||||||
bg="white",
|
bg=app_state.OFF_SHEET_TINT,
|
||||||
highlightthickness=0,
|
highlightthickness=0,
|
||||||
xscrollcommand=canvas_hscroll.set,
|
xscrollcommand=canvas_hscroll.set,
|
||||||
yscrollcommand=canvas_vscroll.set,
|
yscrollcommand=canvas_vscroll.set,
|
||||||
scrollregion=(0, 0, 2000, 2000),
|
scrollregion=(0, 0, *wiresheet.sheet_extent()),
|
||||||
)
|
)
|
||||||
app_state.canvas = canvas
|
app_state.canvas = canvas
|
||||||
canvas.pack(fill=tk.BOTH, expand=True)
|
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.
|
# selects and moves boxes and a plain right click is the menu, as before.
|
||||||
link_drag.install(canvas, on_canvas_right_click,
|
link_drag.install(canvas, on_canvas_right_click,
|
||||||
on_canvas_click, on_canvas_drag, on_canvas_release,
|
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)
|
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)
|
canvas.bind("<Double-1>", on_canvas_double_click)
|
||||||
|
|
||||||
# Wheel zoom. Windows and macOS report a delta; X11 sends button 4 and 5 instead.
|
# Wheel zoom. Windows and macOS report a delta; X11 sends button 4 and 5 instead.
|
||||||
|
|||||||
+2
-2
@@ -180,7 +180,7 @@ def reset_to_startup_state():
|
|||||||
|
|
||||||
if app_state.canvas is not None:
|
if app_state.canvas is not None:
|
||||||
app_state.canvas.delete("all")
|
app_state.canvas.delete("all")
|
||||||
wiresheet.draw_grid()
|
wiresheet.fit_sheet_region()
|
||||||
|
|
||||||
if app_state.window is not None:
|
if app_state.window is not None:
|
||||||
app_state.window.title(app_state.base_window_title())
|
app_state.window.title(app_state.base_window_title())
|
||||||
@@ -223,7 +223,7 @@ def open_file():
|
|||||||
for item in tree.get_children():
|
for item in tree.get_children():
|
||||||
tree.delete(item)
|
tree.delete(item)
|
||||||
canvas.delete("all")
|
canvas.delete("all")
|
||||||
wiresheet.draw_grid()
|
wiresheet.fit_sheet_region()
|
||||||
|
|
||||||
xml_tree = ET.parse(file_path)
|
xml_tree = ET.parse(file_path)
|
||||||
app_state.xml_root_element = xml_tree.getroot()
|
app_state.xml_root_element = xml_tree.getroot()
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ class SoxMsg:
|
|||||||
|
|
||||||
def write_i4(self, v):
|
def write_i4(self, v):
|
||||||
self._grow(4)
|
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
|
self._pos += 4
|
||||||
|
|
||||||
def write_i8(self, v):
|
def write_i8(self, v):
|
||||||
|
|||||||
+548
-80
@@ -122,6 +122,7 @@ FIT_PADDING = 20
|
|||||||
# Below this a grid line is closer to its neighbour than the line is wide, which reads
|
# 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.
|
# as a grey wash rather than a grid, so the grid is left off instead.
|
||||||
MIN_GRID_STEP = 3
|
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):
|
def snap_up(value, grid_size):
|
||||||
@@ -238,28 +239,42 @@ def measure_box(comp, fonts, linked_slot_index):
|
|||||||
|
|
||||||
|
|
||||||
def draw_grid():
|
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
|
Drawn at the sheet's zoom, so a box still lands on a line at every scale.
|
||||||
covered is the same patch of sheet whatever the zoom, which is why the span is
|
|
||||||
scaled along with the step.
|
|
||||||
"""
|
"""
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
canvas.delete("grid")
|
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
|
step = app_state.GRID_SIZE * app_state.zoom
|
||||||
if step < MIN_GRID_STEP:
|
if step >= MIN_GRID_STEP:
|
||||||
return
|
draw_grid_lines(canvas, step, x1, y1, x2, y2)
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
canvas.tag_lower("grid")
|
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):
|
def create_round_rectangle(canvas, x1, y1, x2, y2, radius=4, **kwargs):
|
||||||
"""Draws a visually smooth rounded rectangle using native canvas arcs and polygons."""
|
"""Draws a visually smooth rounded rectangle using native canvas arcs and polygons."""
|
||||||
points = [
|
points = [
|
||||||
@@ -337,18 +352,32 @@ def segment_hits_boxes(x1, y1, x2, y2, boxes):
|
|||||||
def path_is_clear(points, boxes, clear):
|
def path_is_clear(points, boxes, clear):
|
||||||
"""True if no part of the path runs under a box or crowds one.
|
"""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`
|
Every run keeps `clear` from each box: `end` above and below, `side` left and
|
||||||
above and below, `side` left and right. The two runs pinned to the slots are only
|
right. Only the two stubs pinned to the slots -- `side` out of the source and
|
||||||
held out from under the boxes, since they have to reach their pins wherever the
|
`arrive` into the target -- are held merely out from under the boxes, since they
|
||||||
rows happen to be.
|
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)
|
points = wire_style.simplify(points)
|
||||||
padded = [(x1 - clear.side, y1 - clear.end, x2 + clear.side, y2 + clear.end)
|
padded = [(x1 - clear.side, y1 - clear.end, x2 + clear.side, y2 + clear.end)
|
||||||
for x1, y1, x2, y2 in boxes]
|
for x1, y1, x2, y2 in boxes]
|
||||||
last = len(points) - 2
|
last = len(points) - 2
|
||||||
for idx, ((x1, y1), (x2, y2)) in enumerate(zip(points, points[1:])):
|
for idx, ((x1, y1), (x2, y2)) in enumerate(zip(points, points[1:])):
|
||||||
pinned = idx in (0, last)
|
level = abs(y1 - y2) < LINK_EPS
|
||||||
if segment_hits_boxes(x1, y1, x2, y2, boxes if pinned else padded):
|
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 False
|
||||||
return True
|
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,
|
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.
|
"""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
|
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
|
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
|
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
|
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 step in range(0, max_steps):
|
||||||
for sign in ((0,) if step == 0 else (1, -1)):
|
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
|
continue
|
||||||
if vertical_run_clearance(lane, run_lo, run_hi, boxes) < clear.side - LINK_EPS:
|
if vertical_run_clearance(lane, run_lo, run_hi, boxes) < clear.side - LINK_EPS:
|
||||||
continue
|
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 lane
|
||||||
return None
|
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,
|
def hug_lane(edge_x, side, run_lo, run_hi, boxes, claimed, clear, max_offset=0,
|
||||||
min_offset=0, pins=None):
|
min_offset=0, pins=None):
|
||||||
"""Lane for a run pinned to one box edge, starting `clear.side` out from it.
|
"""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)
|
return any(row_is_stacked(y, x1, x2, claimed_rows, pins, clear.row)
|
||||||
for y, x1, x2, _ in rows_of(candidate))
|
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.
|
"""A single vertical channel in the gap between the two boxes.
|
||||||
|
|
||||||
The two horizontal runs are pinned to their slots, so unless `allow_stack`
|
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
|
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)
|
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:
|
for cx in candidates:
|
||||||
lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, clear,
|
lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, clear,
|
||||||
max_steps, pins)
|
max_steps, pins, accept)
|
||||||
if lane is None:
|
if lane is None:
|
||||||
continue
|
continue
|
||||||
candidate = [start, leave, (lane, src_y), (lane, dst_y), arrive, end]
|
candidate = path_through(lane)
|
||||||
if not path_is_clear(candidate, boxes, clear):
|
if not path_is_clear(candidate, boxes, clear):
|
||||||
continue
|
continue
|
||||||
if not allow_stack and stacked(candidate):
|
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
|
# 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
|
# 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.
|
# 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:
|
else:
|
||||||
standoff = max(WIRE_STANDOFF_MAX, 3 * clear.side)
|
standoff = max(WIRE_STANDOFF_MAX, 3 * clear.side)
|
||||||
attempts = (
|
attempts = (
|
||||||
|
lambda: straight_through(8, uncrossed=True), # own lane, crossing nothing
|
||||||
lambda: straight_through(1), # hugging, own lane
|
lambda: straight_through(1), # hugging, own lane
|
||||||
lambda: around_boxes(clear.hug), # hugging, routed around
|
lambda: around_boxes(clear.hug), # hugging, routed around
|
||||||
lambda: straight_through(8), # lane nudged out of the band
|
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)
|
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]
|
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.
|
# Links between nearer columns claim lanes first, and straight ones before bent ones.
|
||||||
jobs.sort(key=lambda j: (abs(j.dst_box[0] - j.src_box[2]), abs(j.dst_y - j.src_y)))
|
# 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.
|
# 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
|
# 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.
|
# placed closer than the grid rule allows still get a wire, just a tighter one.
|
||||||
roomy = grid_clearance()
|
roomy = grid_clearance()
|
||||||
ledger = {"cols": [], "rows": [row for job in jobs for row in job.stub_rows(roomy)]}
|
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:
|
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)
|
job.route(boxes, ledger, roomy, direct_only=True)
|
||||||
|
if job.points is None:
|
||||||
|
deferred.add(group)
|
||||||
for clear in (roomy, tight_clearance()):
|
for clear in (roomy, tight_clearance()):
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
if job.points is None:
|
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."""
|
"""Handles parsing and visual painting of given component listings on canvas with slot attributes."""
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
app_state.active_components_list = components_to_render
|
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_tag = None
|
||||||
app_state.selected_canvas_tags.clear()
|
app_state.selected_canvas_tags.clear()
|
||||||
|
|
||||||
@@ -1160,9 +1259,6 @@ def render_components(components_to_render):
|
|||||||
meta_scale = sheet_meta_scale(components_to_render)
|
meta_scale = sheet_meta_scale(components_to_render)
|
||||||
app_state.meta_scale = meta_scale
|
app_state.meta_scale = meta_scale
|
||||||
|
|
||||||
max_reached_x = 1000
|
|
||||||
max_reached_y = 1000
|
|
||||||
|
|
||||||
for comp in components_to_render:
|
for comp in components_to_render:
|
||||||
meta_prop = comp.find("prop[@name='meta']")
|
meta_prop = comp.find("prop[@name='meta']")
|
||||||
meta_str = meta_prop.get("val") if meta_prop is not None else None
|
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_comp_map[comp_tag] = comp
|
||||||
app_state.canvas_tag_by_element[comp] = comp_tag
|
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,
|
canvas, comp, coord_x, coord_y, fonts, linked_slot_index,
|
||||||
("component", comp_tag), outline_tags=("block_outline",)
|
("component", comp_tag), outline_tags=("block_outline",)
|
||||||
)
|
)
|
||||||
app_state.component_slot_offsets[comp_tag] = slot_offsets
|
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()
|
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.
|
"""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
|
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)
|
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()
|
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):
|
def scale_knob_ends(zoom):
|
||||||
"""Brings the cached knob-to-knob ends onto the painted scale.
|
"""Brings the cached knob-to-knob ends onto the painted scale.
|
||||||
|
|
||||||
@@ -1237,6 +1373,7 @@ def redraw_links():
|
|||||||
zoom = app_state.zoom
|
zoom = app_state.zoom
|
||||||
if zoom == 1.0:
|
if zoom == 1.0:
|
||||||
draw_links()
|
draw_links()
|
||||||
|
fit_sheet_region() # a box dragged over the edge brings the grey past it into view
|
||||||
return
|
return
|
||||||
|
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
@@ -1245,6 +1382,7 @@ def redraw_links():
|
|||||||
canvas.scale("component", 0, 0, zoom, zoom)
|
canvas.scale("component", 0, 0, zoom, zoom)
|
||||||
canvas.scale("link", 0, 0, zoom, zoom)
|
canvas.scale("link", 0, 0, zoom, zoom)
|
||||||
scale_knob_ends(zoom)
|
scale_knob_ends(zoom)
|
||||||
|
fit_sheet_region()
|
||||||
|
|
||||||
|
|
||||||
def zoom_step(direction):
|
def zoom_step(direction):
|
||||||
@@ -1307,14 +1445,8 @@ def fit_zoom_level():
|
|||||||
if bounds is None:
|
if bounds is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# A canvas cannot scroll left of or above its origin, so anything out there cannot
|
content_width = bounds[2] - bounds[0]
|
||||||
# be brought into view however far the sheet is zoomed out. The fit is measured from
|
content_height = bounds[3] - bounds[1]
|
||||||
# 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
|
|
||||||
view_width = canvas.winfo_width() - 2 * FIT_PADDING
|
view_width = canvas.winfo_width() - 2 * FIT_PADDING
|
||||||
view_height = canvas.winfo_height() - 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)
|
+ 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.
|
"""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
|
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
|
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.
|
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)
|
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 = []
|
placements = []
|
||||||
clamped = 0
|
clamped = 0
|
||||||
cursor_x = margin # the left-most column clears the canvas edge by the margin
|
for index, column in enumerate(order):
|
||||||
|
|
||||||
for index, grid_x in enumerate(column_keys):
|
|
||||||
column = sorted(columns[grid_x], key=lambda i: i["grid_y"])
|
|
||||||
cursor_y = margin
|
|
||||||
|
|
||||||
for item in column:
|
for item in column:
|
||||||
step_x = min(META_COORD_MAX, round(cursor_x / scale))
|
if item.get("wire"):
|
||||||
step_y = min(META_COORD_MAX, round(cursor_y / scale))
|
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:
|
if step_x == META_COORD_MAX or step_y == META_COORD_MAX:
|
||||||
clamped += 1
|
clamped += 1
|
||||||
placements.append((item, step_x, step_y))
|
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
|
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):
|
def build_layout_items(components):
|
||||||
"""Column-grouped layout items for a sheet, measured rather than read off the canvas.
|
"""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
|
Returns (columns, column_keys, demand, wires), each column listed top to bottom.
|
||||||
scale before anything is drawn -- and by tidy, so the two agree.
|
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()
|
fonts = sheet_fonts()
|
||||||
linked_slot_index = build_linked_slot_index()
|
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:
|
for comp in components:
|
||||||
meta_prop = comp.find("prop[@name='meta']")
|
meta_prop = comp.find("prop[@name='meta']")
|
||||||
meta_val = meta_prop.get("val") if meta_prop is not None else None
|
meta_val = meta_prop.get("val") if meta_prop is not None else None
|
||||||
grid_x, grid_y = read_meta_property(meta_val)[:2]
|
grid_x, grid_y = read_meta_property(meta_val)[:2]
|
||||||
width, height = measure_box(comp, fonts, linked_slot_index)[:2]
|
width, height, slot_rows = measure_box(comp, fonts, linked_slot_index)[:3]
|
||||||
columns.setdefault(grid_x, []).append({
|
item = {
|
||||||
"comp": comp, "meta": meta_val,
|
"comp": comp, "meta": meta_val,
|
||||||
"grid_x": grid_x, "grid_y": grid_y,
|
"grid_x": grid_x, "grid_y": grid_y,
|
||||||
"width": width, "height": height,
|
"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_keys = sorted(columns)
|
||||||
column_of_comp = {
|
column_of_comp = {
|
||||||
@@ -1471,7 +1902,42 @@ def build_layout_items(components):
|
|||||||
for index, key in enumerate(column_keys)
|
for index, key in enumerate(column_keys)
|
||||||
for item in columns[key]
|
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):
|
def sheet_meta_scale(components):
|
||||||
@@ -1485,22 +1951,24 @@ def sheet_meta_scale(components):
|
|||||||
if not components:
|
if not components:
|
||||||
return grid_size
|
return grid_size
|
||||||
|
|
||||||
columns, column_keys, demand = build_layout_items(components)
|
columns, column_keys, demand, wires = build_layout_items(components)
|
||||||
if not column_keys:
|
if not column_keys:
|
||||||
return grid_size
|
return grid_size
|
||||||
|
|
||||||
for multiplier in range(1, META_SCALE_MAX + 1):
|
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:
|
if not clamped:
|
||||||
return grid_size * multiplier
|
return grid_size * multiplier
|
||||||
return grid_size * META_SCALE_MAX
|
return grid_size * META_SCALE_MAX
|
||||||
|
|
||||||
|
|
||||||
def tidy_layout():
|
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
|
Columns follow the wiring (see flow_columns), using as few as the longest chain of
|
||||||
rebuilt, from the sizes actually painted. Every placement lands on a whole meta step,
|
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
|
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
|
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
|
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
|
return 0, 0
|
||||||
|
|
||||||
components = list(app_state.canvas_comp_map.values())
|
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:
|
if not column_keys:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
|
|
||||||
scale = sheet_meta_scale(components)
|
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 = [
|
changes = [
|
||||||
(item["comp"], item["meta"], step_x, step_y)
|
(item["comp"], item["meta"], step_x, step_y)
|
||||||
|
|||||||
Reference in New Issue
Block a user