Compare commits

...
24 Commits
Author SHA1 Message Date
ArdaandClaude Opus 5.5 95be6613f3 Live mode: load sheets off the UI thread, go offline cleanly on a lost device
Opening a sheet ran its Sox requests on the UI thread, so a device that had
gone away (a closed simulator) froze the window until DASP's 30 s receive
timeout. The load now runs on a background thread and the sheet is painted
when it finishes; a load the user has clicked away from is dropped.

When the session closes, the editor goes offline with "Connection lost:
<cause>" instead of printing a traceback. A send waiting for room in the
send window now fails when the session closes rather than going out after
the CLOSE. The tree-expand error callback no longer reads the exception
after its except block has ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:24:27 +03:00
ArdaandClaude Opus 5.5 6dd01b13b9 DASP trace: color send/receive, decode header fields, hex+ASCII dump
Sent lines are yellow and received lines green when stdout is a console;
pipes and files stay plain. Each message lists the header fields it
carries by name and dumps header and payload separately, 16 bytes a row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:38:52 +03:00
ArdaandClaude Opus 5.5 0dddea53f8 Wiresheet: keep off-sheet input knobs on the sheet
A box with a link arriving from another sheet draws a knob on its left
edge. Tidy Layout moves the first column one grid step in when any of its
boxes has one, and dragging and arrow nudges stop such a box one step
inside the margin, so the knob never sits out in the grey.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:34:27 +03:00
ArdaandClaude Opus 5.5 0d5e5115d9 Wiresheet: arrow nudges keep the selection and the sheet's focus
Ctrl+A hands the sheet the keyboard, and repainting the same sheet keeps
whatever of the selection is still on it.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:15:22 +03:00
ArdaandClaude Opus 5.5 160f65c95e Preferences: toggle slot numbers on wiresheet, off by default
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:39:01 +03:00
Arda b8af483890 WIP: auto-sync from AA-ZENBOOK 2026-10-15 10:14 2026-10-15 10:14:33 +03:00
Arda 6365d1f74d WIP: auto-sync from AA-LENOVOHOME 2026-10-11 14:21 2026-10-11 14:21:00 +03:00
ArdaandClaude Opus 5.5 71effdc160 Merge test/sab-seed-benchmark: optional app.sab read on connect
Preference 'Lazy loading on connect?' (on by default). Off reads the whole app
from app.sab when connecting: 8 s instead of 116 s on a P-ION, 0.9 s instead of
16 s on an Orion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:14:42 +03:00
ArdaandClaude Opus 5.5 d905d25145 DASP trace: sent lines yellow, received green on a console
Plain text when stdout is a pipe or file. Also remembers localhost as the last device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:14:41 +03:00
ArdaandClaude Opus 5.5 7ff2479736 Preferences: lazy loading on connect, on by default
Off reads the whole app from app.sab when connecting. Read at each connect, so a
change takes effect on the next one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:13:41 +03:00
ArdaandClaude Opus 5.5 cb0196caa7 Connect: read the whole app from app.sab instead of component by component
Saves the app on the device, reads app.sab and fills the Sox cache with the tree,
config and links in one go: 8 s instead of 116 s for 230 components on a P-ION,
0.9 s instead of 16 s for 194 on an Orion. Sox keeps the cache current from
there as before. On any failure, or when app.sab does not match the running
tree, it falls back to lazy loading. A sheet's first open reads only runtime
values; a tree expand with nothing to load no longer rebuilds the DOM.

bench_sab_seed.py times both ways against a device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:01:32 +03:00
ArdaandClaude Opus 5.5 db7b6632af Restart from the tree no longer hangs for 30 s on disconnect
With a wiresheet open, disconnecting first unsubscribed its components and
waited for the reply, which a restarting device never sends, so the editor
froze until the request timed out. After restart, reboot, quit or hibernate
the session is now closed without asking the device anything; a plain
Disconnect still unsubscribes first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:53:22 +03:00
ArdaandClaude Opus 5.5 f2b18923b7 Disconnect at once after restart, reboot, quit or hibernate
The device goes down without answering, so the editor waited out the 30 s
Sox timeout before dropping the session. The invoke now waits 3 s (time for
DASP to resend it once) and the editor disconnects either way; an error
reply from the device is still reported and keeps the connection. Covers the
App right-click actions and the restart Put with Files offers.

- sox: request/requests/invoke take a timeout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:42:36 +03:00
ArdaandClaude Opus 5.5 e070bd0f1e Put with Files: choose kits, as Manage Kits does, and rebuild kits.scode
A kits step after the zip is chosen, laid out as the Manage Kits panel:
installed kits (what the app needs, greyed), installed but not used, kits
available on the computer, not supported; the Action cell offers Keep at /
Upgrade to / Downgrade to / Add at / REMOVE; Upgrade all kits to latest
version. Kits not in the backup can be added, with their dependencies.

The ticked kits make app.sab's schema (encode_sax takes the kit list, as
Ontrol's PutApp rewrites it). When they are not the device's kits, or
Rebuild kits.scode is ticked, kits.scode is built for them with Ontrol's
sedonac from the Niagara install, after checking the natives the kits call
against the device's platform manifest (PutApp.checkNativeCompatibility).

- kit_build.py: platform manifest lookup, native check, sedonac run
- palette.kit_versions: a checksum released more than once stands for its
  highest version (ontrolPlatform 1.0.1, not 1.0.0)
- palette.unsupported_kits takes the kit list to judge against
- sab_app: a type from a kit the app does not list is a clear error

Tested on a P-ION: a rebuild for its own kits is byte-identical to the one
it runs; adding and then removing the logic kit both come back running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:37:08 +03:00
ArdaandClaude Opus 5.5 08b4dec532 Tree: Reorder... for a component's children
Right-click a component (or the app) with two or more children to move them
up and down; OK applies the new order, which is the order they run in. On a
device the order goes there first via Sox reorder, checked against the
device's children, and the file is left alone if it is refused. Undoable.

Also: device host in editor.properties.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:39:06 +03:00
ArdaandClaude Opus 5.5 232eea99f4 App > Put with Files: restore a backup zip onto the connected device
Follows Ontrol's Workbench tool (BPutWithFilesTool / PutApp): the backup's
app.sax is encoded for the device's kits and put as app.sab.writing, renamed
to app.sab.stage; the platform service's deleteFile clears fonts/* and
images/*, then images, fonts (from Sedona home's bank, in the device's cut)
and the boot logo go. The zip is checked against the device first: same
platform (Orion, Rion or P-ION, from the platform id), and kits the device
can load. Falls back to the backup's app.sab, with its kits.scode if needed.

- sab_app.encode_sax: .sax -> .sab, ported from Py_sab2sax with Java's value
  parsing (fromLiteral, nullbool/nullfloat, Integer.decode); byte-identical
  to sedonac on the saved backups
- app_backup.native_image: port of OntOxImageUtil's png -> native .bin
- sox.put_file / rename_file
- Get with Files: Orion's boot logo is system/logo.jpg
- A restart that goes unanswered is taken as the device going down

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:37:45 +03:00
ArdaandClaude Opus 5.5 1deb31f3b3 Bump version to 0.0.0.164
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:29:13 +03:00
ArdaandClaude Opus 5.5 1f3b841dc0 Rename App > Get with File to Get with Files
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:28:31 +03:00
18 changed files with 3327 additions and 325 deletions
+663 -16
View File
@@ -1,4 +1,5 @@
"""App > Get with File: a full backup of the connected device's app as one zip. """App > Get with Files / Put with Files: a full backup of the device's app as one
zip, and putting such a zip back onto a device.
The zip follows the layout Ontrol's tools read back on a restore: The zip follows the layout Ontrol's tools read back on a restore:
@@ -6,7 +7,7 @@ The zip follows the layout Ontrol's tools read back on a restore:
device/kits.scode device/kits.scode
device/images/<name>.bin device/images/<name>.bin
device/fonts/<font file> device/fonts/<font file>
device/system/logo.bin device/system/logo.jpg Orion's boot logo; Rion's is system/logo.bin
app.sax app.sab decoded, as sedonac would write it app.sax app.sab decoded, as sedonac would write it
imageBank[XL]/<name>.png the sources of the device's images and fonts, taken from imageBank[XL]/<name>.png the sources of the device's images and fonts, taken from
fontBank[XL]/<font file> Sedona home's banks (XL for Orion's 800x480 screen) fontBank[XL]/<font file> Sedona home's banks (XL for Orion's 800x480 screen)
@@ -14,6 +15,9 @@ The zip follows the layout Ontrol's tools read back on a restore:
The device cannot list its files, so which images and fonts to fetch is read from the The device cannot list its files, so which images and fonts to fetch is read from the
app itself: every image name and font its Ox widgets and pages name. app itself: every image name and font its Ox widgets and pages name.
A put stages app.sab and kits.scode for the VM to take up on its next start; see
put_with_files for the order it goes in.
""" """
import datetime import datetime
@@ -22,12 +26,15 @@ import re
import zipfile import zipfile
import app_state import app_state
import kit_build
import palette
import sab_app import sab_app
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, RION_KIT import schema
import sox
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, ORION_PLATFORM_TYPE, RION_KIT
APP_SAB = "app.sab" APP_SAB = "app.sab"
KITS_SCODE = "kits.scode" KITS_SCODE = "kits.scode"
LOGO_URI = "system/logo.bin"
_IMAGE_SLOTS = ("imageFileName", "trueImageFileName", "falseImageFileName") _IMAGE_SLOTS = ("imageFileName", "trueImageFileName", "falseImageFileName")
_SOURCE_IMAGE_EXTENSIONS = tuple(e for e in IMAGE_EXTENSIONS if e != ".bin") _SOURCE_IMAGE_EXTENSIONS = tuple(e for e in IMAGE_EXTENSIONS if e != ".bin")
@@ -35,25 +42,41 @@ _UNSAFE_FILE_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
class _Platform: class _Platform:
def __init__(self, name, image_bank, font_bank, font_extensions): def __init__(self, name, kit, service_type, image_bank=None, font_bank=None,
font_extensions=(), logo_uri=None, screen=None):
self.name = name self.name = name
self.kit = kit # the kit only this platform's apps hold
self.service_type = service_type # its platform service
self.platform_id = "ontrol-" + name.lower() # the device's id starts so
self.image_bank = image_bank self.image_bank = image_bank
self.font_bank = font_bank self.font_bank = font_bank
self.font_extensions = font_extensions self.font_extensions = font_extensions
self.logo_uri = logo_uri
self.screen = screen # (width, height) in landscape; None: none
# Orion keeps a landscape (.oxfl) and a portrait (.oxfp) cut of each font. # Orion keeps a landscape (.oxfl) and a portrait (.oxfp) cut of each font, and its boot
_ORION = _Platform("ORION", "imageBankXL", "fontBankXL", (".oxfl", ".oxfp")) # logo as a jpg; Rion's is a native image (Ontrol's BGetWithFilesTool). A P-ION has no
_RION = _Platform("RION", "imageBank", "fontBank", (".bin",)) # screen: nothing but the app goes to it.
_ORION = _Platform("ORION", ORION_KIT, ORION_PLATFORM_TYPE, "imageBankXL", "fontBankXL",
(".oxfl", ".oxfp"), "system/logo.jpg", (800, 480))
_RION = _Platform("RION", RION_KIT, "ontrolRion::RionPlatformService", "imageBank",
"fontBank", (".bin",), "system/logo.bin", (320, 240))
_PION = _Platform("PION", "ontrolPion", "ontrolPion::PionPlatformService")
_PLATFORMS = (_ORION, _RION, _PION)
def platform_of(app): def platform_of(app):
kits = app.kit_names() return platform_of_kits(app.kit_names())
if ORION_KIT in kits:
return _ORION
if RION_KIT in kits: def platform_of_kits(kit_names):
return _RION kit_names = set(kit_names)
return None return next((p for p in _PLATFORMS if p.kit in kit_names), None)
def platform_label(platform):
return platform.name if platform else "no platform this editor knows"
def backup_folder(): def backup_folder():
@@ -98,6 +121,8 @@ def _font_stem(app, comp):
def media_files(app, platform): def media_files(app, platform):
"""The images and fonts the app names, each with its device path and bank source.""" """The images and fonts the app names, each with its device path and bank source."""
files, seen = [], set() files, seen = [], set()
if platform.screen is None:
return files
def add(uri, bank_folder, bank_name, group, required=True): def add(uri, bank_folder, bank_name, group, required=True):
if uri not in seen: if uri not in seen:
@@ -134,7 +159,7 @@ def media_files(app, platform):
add_font(f"bold {stem}", group, True) add_font(f"bold {stem}", group, True)
add_font(stem, group, not bold) add_font(stem, group, not bold)
add(LOGO_URI, platform.image_bank, "logo", "logo", required=False) add(platform.logo_uri, platform.image_bank, "logo", "logo", required=False)
return files return files
@@ -154,7 +179,7 @@ def _bank_source(bank_folder, bank_name, is_image):
def _source_entry(media, source_path): def _source_entry(media, source_path):
"""Where the bank file goes in the zip: next to its bank's name, the logo in system/.""" """Where the bank file goes in the zip: next to its bank's name, the logo in system/."""
file_name = os.path.basename(source_path) file_name = os.path.basename(source_path)
if media.device_uri == LOGO_URI: if media.device_uri.startswith("system/"):
return "system/logo" + os.path.splitext(file_name)[1] return "system/logo" + os.path.splitext(file_name)[1]
return f"{media.bank_folder}/{file_name}" return f"{media.bank_folder}/{file_name}"
@@ -259,3 +284,625 @@ def get_with_file(client, path, status=None):
result.entries = [name for name, _ in entries] result.entries = [name for name, _ in entries]
report("Done", 1.0) report("Done", 1.0)
return result return result
# --- Put with Files ---------------------------------------------------------------------
#
# Follows Ontrol's Workbench tool (ontrolSedonaOx BPutWithFilesTool, ontrolSedonanet
# PutApp): the backup's app.sax is encoded for the chosen kit builds, kits.scode built
# for them with sedonac when they are not the device's (kit_build); both go as
# '.writing' renamed to '.stage'; the platform service's deleteFile clears fonts/* and
# images/*; then the images, the fonts and the boot logo go. Fonts come from Sedona
# home's bank in the cut the device draws with, images in the form its screen takes.
_FONT_FOLDERS = ("device/fonts/", "fonts/", "fontBank/", "fontBankXL/")
_JPG = (".jpg", ".jpeg")
def _checksum(value):
"""A kit checksum as an unsigned int, from a signed Sox int or a .sab hex string."""
return int(value, 16) if isinstance(value, str) else value & 0xFFFFFFFF
def kits_fit(app_kits, device_kits):
"""Whether the device's kits.scode can load an app.sab of `app_kits`.
App.loadSchema's rule: the app's kits must appear among the device's, in the same
order, each with the same checksum; the device may hold more.
"""
device = iter((n, _checksum(c)) for n, c in device_kits)
return all(any(d == (n, _checksum(c)) for d in device) for n, c in app_kits)
class DeviceTarget:
"""What a put needs to know of the connected device."""
def __init__(self, platform, kits, name=None, portrait=False,
platform_comp_id=None, delete_slot_id=None, platform_id=""):
self.platform = platform # one of _PLATFORMS, or None
self.platform_id = platform_id # e.g. "ontrol-pion-b427a5b9"
self.kits = kits # [(name, checksum)] in kit-id order
self.name = name # the app's deviceName
self.portrait = portrait # Orion's screenOrientation
self.platform_comp_id = platform_comp_id
self.delete_slot_id = delete_slot_id
@property
def font_extension(self):
if self.platform is _ORION:
return ".oxfp" if self.portrait else ".oxfl"
return ".bin"
@property
def screen(self):
"""(width, height) as the device is set up, as Ontrol's ScreenInfo has it."""
w, h = self.platform.screen
return (h, w) if self.portrait else (w, h)
def device_target(ds):
"""Reads the platform, kits, deviceName and platform service off the device.
The platform comes from the platform id, as Ontrol's tools read it, the kits
standing in for a device that does not give one.
"""
client = ds.sox_client
kits = [(p.name, p.checksum) for p in client.read_schema()]
kit_names = {n for n, _ in kits}
platform_id = (client.read_version().platform_id or "").lower()
platform = (next((p for p in _PLATFORMS if platform_id.startswith(p.platform_id)), None)
or next((p for p in _PLATFORMS if p.kit in kit_names), None))
target = DeviceTarget(platform, kits, platform_id=client.read_version().platform_id)
comp = client.cache_get(0)
if comp is not None:
client.update([comp], sox.CONFIG)
target.name = comp.values.get("deviceName") or None
if platform is not None and platform.screen is not None:
plat = ds.find_component_of_type(platform.service_type, "screenOrientation")
if plat is not None:
target.platform_comp_id = plat.id
target.portrait = (platform is _ORION and
str(plat.values.get("screenOrientation")).lower() == "true")
slots = client.slot_registry.get((plat.kit_id, plat.type_id)) or []
target.delete_slot_id = next((s["id"] for s in slots if s["name"] == "deleteFile"
and s.get("is_action")), None)
return target
# --- images in the device's form --------------------------------------------------------
def native_image(data, name, target):
"""A png (or any image PIL reads) as the device's native .bin.
Ported from OntOxImageUtil.convertImageToNativeFormat: a 32-byte name, a 32-byte
header (flags, width, height), then the pixels. Orion draws BGR(A), turned for a
portrait screen, with alpha unless the image fills the screen; Rion RGB565, with
alpha only for a translucent image smaller than the screen.
"""
import io
from PIL import Image
img = Image.open(io.BytesIO(data))
img.load()
width, height = img.size
translucent = img.mode in ("RGBA", "LA", "PA") or (
img.mode == "P" and "transparency" in img.info and
any(0 < a < 255 for a in img.convert("RGBA").getchannel("A").getdata()))
background = (width, height) == target.screen
orion = target.platform is _ORION
rgb_mode = use_alpha = orion and not background
if not orion:
use_alpha = translucent and not background
rotate = orion and target.portrait
rgba = img.convert("RGBA")
if rotate: # the Java walks columns right to left: a quarter turn anticlockwise
rgba = rgba.transpose(Image.Transpose.ROTATE_90)
w, h = rgba.size
out = bytearray(name.encode("ascii", errors="replace"))
out += bytes(max(0, 32 - len(out)))
out += bytes([(4 if rotate else 0) + (2 if rgb_mode else 0) + (1 if use_alpha else 0),
w & 0xFF, w >> 8, h & 0xFF, h >> 8]) + bytes(27)
pixels = rgba.tobytes()
if rgb_mode:
for i in range(0, len(pixels), 4):
r, g, b, a = pixels[i:i + 4]
out += bytes((b, g, r, a)) if use_alpha else bytes((b, g, r))
else:
for i in range(0, len(pixels), 4):
r, g, b, a = pixels[i:i + 4]
rgb565 = (r >> 3) << 11 | (g >> 2) << 5 | (b >> 3)
if use_alpha:
out.append(a)
out += bytes((rgb565 & 0xFF, rgb565 >> 8))
return bytes(out)
def device_jpg(data, target):
"""A jpg as the device takes it: turned a quarter anticlockwise for a portrait Orion
(OntOxImageUtil.rotateJpg), otherwise as it is."""
if not (target.platform is _ORION and target.portrait):
return data
import io
from PIL import Image
img = Image.open(io.BytesIO(data))
buf = io.BytesIO()
img.transpose(Image.Transpose.ROTATE_90).save(buf, "JPEG", quality=75)
return buf.getvalue()
# --- the plan ---------------------------------------------------------------------------
class PutFile:
def __init__(self, device_uri, data, source):
self.device_uri = device_uri
self.data = data
self.source = source # where the bytes come from, for the summary
class PutPlan:
"""What a put of the zip would do, and whether the zip fits the device."""
def __init__(self, path):
self.path = path
self.problems = [] # any of these stops the put
self.warnings = [] # worth confirming, but the put can go on
self.app_source = "" # how app.sab was come by, for the summary
self.kit_changes = [] # each chosen kit build that is not the device's
self.scode_source = "" # where kits.scode comes from, when it goes
self.app_files = [] # PutFile to '<name>.writing', renamed to '.stage'
self.media = [] # PutFile: images, fonts, logo
self.converted = 0 # images made from the backup's image bank
self.images_missing = [] # images neither in device form nor as a source
self.fonts_from_zip = [] # fonts the bank lacks; the backup's copy goes
self.fonts_missing = [] # fonts the app names that neither has
self.staged = False # set once the device holds app.sab.stage
self.has_screen = True # False for a P-ION: the app is all that goes
@property
def ok(self):
return not self.problems
def _app_portrait(app):
comp = next((c for c in app.comps.values() if c["type"] == ORION_PLATFORM_TYPE), None)
return bool(comp and app.prop(comp, "screenOrientation", False))
class KitRow:
"""One kit in the kits step of a put: the device's build, the backup's, and what to put.
`included` rows are what goes: into app.sab's schema and, when it is rebuilt, into
kits.scode. A `required` row -- a kit the app's components name, or one an included
kit depends on -- is always included.
"""
def __init__(self, name, device, backup, builds, used=False, locked=False,
unsupported=False):
self.name = name
self.device = device # checksum hex the device runs, or None
self.backup = backup # checksum hex the backup's schema names, or None
self.builds = builds # [(version, checksum hex)] Sedona home has, newest first
self.used = used # the app's components name a type of it
self.locked = locked # the backup holds only app.sab: nothing can change
self.unsupported = unsupported # cannot run on the device's platform
self.required = used
self.included = used or (device is not None and not unsupported) or \
(backup is not None and not unsupported)
self.use = self._default() # the chosen checksum hex
def _default(self):
"""The device's build, when Sedona home can describe it; then the backup's; then
the newest."""
known = {c for _, c in self.builds}
if self.locked:
return self.backup
for checksum in (self.device, self.backup):
if checksum in known:
return checksum
return self.builds[0][1] if self.builds else self.device or self.backup
@property
def latest(self):
return self.builds[0][1] if self.builds else None
def _hex(checksum):
return f"{_checksum(checksum):08x}"
def _app_source(z, names):
"""(sax entry or None, sab entry or None) of the backup's app."""
sax = "app.sax" if "app.sax" in names else next(
(n for n in sorted(names) if n.endswith(".sax") and "/" not in n), None)
sab = "device/" + APP_SAB
return sax, sab if sab in names else None
def _backup_kits(path):
"""([(kit, checksum or None)] the backup's schema names, {kits its components use},
whether it holds only app.sab). Raises ValueError when the zip holds no app."""
import xml.etree.ElementTree as ET
with zipfile.ZipFile(path) as z:
names = {n for n in z.namelist() if not n.endswith("/")}
sax_name, sab_name = _app_source(z, names)
if sax_name:
root = ET.fromstring(z.read(sax_name))
schema_elem = root.find("schema")
kits = [(k.get("name"), k.get("checksum"))
for k in (schema_elem.findall("kit") if schema_elem is not None else ())]
used = {c.get("type", "").split("::")[0] for c in root.iter("comp")}
return kits, used | {"sys"}, False
if sab_name:
app = sab_app.SabApp(z.read(sab_name))
used = {c["type"].split("::")[0] for c in app.comps.values()}
return app.kit_parts, used, True
raise ValueError("The zip holds no app: neither app.sax nor device/app.sab.")
def kit_rows(path, target):
"""Every kit the kits step lists, in Manage Kits' groups (see KitRow.group).
The kits the backup's app needs, those its schema or the device holds without the
app needing them, and every other kit Sedona home has. Each defaults to the device's
build. Raises ValueError when the zip holds no readable app.
"""
kits, used, locked = _backup_kits(path)
device = {n: _hex(c) for n, c in target.kits}
backup = {n: _hex(c) if c else None for n, c in kits}
names = list(dict.fromkeys([n for n, _ in kits] + list(device) + palette.list_kits()))
unsupported = palette.unsupported_kits(set(backup) | set(device))
rows = []
for name in names:
builds = [(v, _hex(c)) for v, c in palette.kit_builds(name)]
if locked and name not in backup:
continue # nothing can be added to a bare app.sab
rows.append(KitRow(name, device.get(name), backup.get(name), builds,
used=name in used, locked=locked,
unsupported=name in unsupported and name not in used))
settle_required(rows)
return rows
def settle_required(rows):
"""Marks every kit an included kit depends on as required -- and so included, at its
default build -- all the way down, as Manage Kits counts dependencies."""
by_name = {r.name: r for r in rows}
for row in rows:
row.required = row.used
pending = [r for r in rows if r.included or r.used]
while pending:
row = pending.pop()
for dep in schema.read_kit_dependencies(row.name, row.use):
dep_row = by_name.get(dep)
if dep_row is not None and not dep_row.required:
dep_row.required = dep_row.included = True
pending.append(dep_row)
for row in rows:
if row.used:
row.included = True
KIT_GROUPS = ("Installed kits", "Installed but not used kits",
"Kits available on the computer", "Not supported")
def group(row):
"""The Manage Kits section a row is listed under: the app needs it; the device or
the backup holds it without the app needing it; Sedona home has it; or it cannot run
on the device."""
if row.unsupported:
return KIT_GROUPS[3]
if row.required:
return KIT_GROUPS[0]
if row.device or row.backup:
return KIT_GROUPS[1]
return KIT_GROUPS[2]
def chosen_kits(rows):
"""[(name, checksum hex)] of the included rows."""
return [(r.name, r.use) for r in rows if r.included]
def plan_put(path, target, rows=None, rebuild=False):
"""Reads the zip at `path` and checks it against the device `target`.
rows are kit_rows() as the kits step left them; None takes the defaults. The
included kits make app.sab's schema; kits.scode is built for them when they are not
the device's kits, or when `rebuild` asks for it anyway.
"""
plan = PutPlan(path)
try:
z = zipfile.ZipFile(path)
except (OSError, zipfile.BadZipFile) as e:
plan.problems.append(f"Not a readable zip: {e}")
return plan
if rows is None:
try:
rows = kit_rows(path, target)
except Exception as e:
plan.problems.append(str(e))
return plan
with z:
names = {n for n in z.namelist() if not n.endswith("/")}
app, backup_portrait = _plan_app(plan, z, names, target, rows, rebuild)
if app is None:
return plan
platform = platform_of(app)
if platform is None or platform is not target.platform:
plan.problems.append(f"The backup is from {platform_label(platform)}; the device "
f"is {platform_label(target.platform)}.")
return plan
zip_name = app.prop(app.app, "deviceName", "") or ""
if target.name and zip_name and zip_name != target.name:
plan.warnings.append(f"The backup was taken from '{zip_name}'; this device is "
f"'{target.name}'.")
if platform.screen is None:
plan.has_screen = False # no images or fonts to put
return plan
if target.delete_slot_id is None:
plan.warnings.append("The device's platform service has no deleteFile action, "
"so its old fonts and images stay.")
same_screen = platform is _RION or backup_portrait == target.portrait
if not same_screen:
plan.warnings.append(
f"The backup is from a {'portrait' if backup_portrait else 'landscape'} "
f"Orion; this one is {'portrait' if target.portrait else 'landscape'}. Its "
"images are made again from the backup's image bank.")
_plan_images(plan, z, names, platform, target, same_screen)
_plan_fonts(plan, z, names, app, platform, target.font_extension)
_plan_logo(plan, z, names, platform, target, same_screen)
return plan
def _plan_app(plan, z, names, target, rows, rebuild):
"""Settles app.sab and kits.scode, as Ontrol's PutApp does.
The backup's app.sax is encoded for the chosen kit builds (a backup with only
device/app.sab goes as it is). When the device's kits.scode cannot load that app, or
a rebuild is asked for, kits.scode is built for the chosen builds with sedonac after
a check of their natives against the device's VM; without sedonac, the backup's own
kits.scode can stand in when the chosen builds are the backup's. Returns (decoded
app, whether the backup came from a portrait screen).
"""
sax_name, sab_name = _app_source(z, names)
chosen = chosen_kits(rows)
original = None
if sab_name:
try:
original = sab_app.SabApp(z.read(sab_name))
except Exception as e:
if not sax_name:
plan.problems.append(f"{sab_name} cannot be read: {e}")
return None, False
if sax_name:
try:
sab = sab_app.encode_sax(z.read(sax_name), kits=chosen)
except Exception as e:
plan.problems.append(f"{sax_name} cannot be encoded for the chosen kits: {e}")
return None, False
plan.app_source = f"{sax_name}, encoded for the chosen kits"
source = sax_name
else:
sab = z.read(sab_name)
plan.app_source = sab_name
source = sab_name
app = sab_app.SabApp(sab)
app_kits = app.kit_parts
plan.kit_changes = [line for line in map(kit_change, rows) if line]
device_kits = {(n, _checksum(c)) for n, c in target.kits}
if sax_name:
same = {(n, _checksum(c)) for n, c in app_kits} == device_kits
else:
same = kits_fit(app_kits, target.kits)
if rebuild or not same:
scode = _plan_scode(plan, z, names, target, app_kits)
if scode is None:
return None, False
plan.app_files.append(scode)
plan.app_files.append(PutFile(APP_SAB, sab, source))
return app, _app_portrait(original or app)
def kit_change(row):
"""How a row changes the device's kits, or None."""
def label(checksum):
version = palette.version_for_checksum(row.name, checksum)
return f"{version} ({checksum})" if version else checksum
if row.device is None:
return f"{row.name}: added at {label(row.use)}" if row.included else None
if not row.included:
return f"{row.name}: removed"
if row.use != row.device:
return f"{row.name}: {label(row.device)} -> {label(row.use)}"
return None
def _plan_scode(plan, z, names, target, app_kits):
"""kits.scode for the app's kits: built by sedonac, or the backup's own."""
manifest = kit_build.platform_manifest(target.platform_id)
backup_scode = "device/" + KITS_SCODE
backup_kits = None
if "device/" + APP_SAB in names:
try:
backup_kits = sab_app.SabApp(z.read("device/" + APP_SAB)).kit_parts
except Exception:
pass
why = None
if manifest is None:
why = (f"Sedona home has no platform manifest for {target.platform_id or 'the device'}"
" (platforms/db), so kits.scode cannot be built or checked for it.")
else:
problems = kit_build.native_problems(app_kits, manifest)
if problems:
plan.problems.append("The chosen kits call native methods the device's VM "
"does not have:")
plan.problems += [" " + p for p in problems]
return None
try:
data = kit_build.build_kits_scode(app_kits, manifest)
except kit_build.BuildError as e:
why = str(e)
else:
plan.scode_source = "built by sedonac for the chosen kits"
return PutFile(KITS_SCODE, data, "sedonac")
if backup_kits is not None and backup_scode in names and \
[(n, _checksum(c)) for n, c in backup_kits] == \
[(n, _checksum(c)) for n, c in app_kits]:
plan.warnings.append(why + " The backup's own kits.scode goes instead.")
plan.scode_source = "the backup's, unchecked against the device's VM"
return PutFile(KITS_SCODE, z.read(backup_scode), backup_scode)
plan.problems.append(why)
return None
def _plan_images(plan, z, names, platform, target, same_screen):
"""Every image the backup holds, in the form the device's screen takes.
The backup's device copy goes when it was taken off the same kind of screen;
otherwise the image bank's source is made over, as Ontrol's tool always does.
"""
native, source = {}, {}
for name in names:
folder, _, file_name = name.rpartition("/")
stem, ext = os.path.splitext(file_name)
if folder == "device/images":
native[stem] = name
elif folder == platform.image_bank and ext.lower() in _SOURCE_IMAGE_EXTENSIONS:
source[stem] = name
for stem in sorted(set(native) | set(source), key=str.lower):
if same_screen and stem in native:
name = native[stem]
plan.media.append(PutFile("images/" + name.rpartition("/")[2], z.read(name),
"backup"))
elif stem in source:
name = source[stem]
try:
if name.lower().endswith(_JPG):
plan.media.append(PutFile(f"images/{stem}.jpg",
device_jpg(z.read(name), target), name))
else:
plan.media.append(PutFile(f"images/{stem}.bin",
native_image(z.read(name), stem, target), name))
plan.converted += 1
except Exception as e:
plan.images_missing.append(f"{stem} ({name}: {e})")
else:
plan.images_missing.append(stem)
def _plan_logo(plan, z, names, platform, target, same_screen):
"""The boot logo: the device's copy, or the backup's source made over (Ontrol's
tool sends it into images/ -- system/ is where the device looks for it)."""
copy = "device/" + platform.logo_uri
if same_screen and copy in names:
plan.media.append(PutFile(platform.logo_uri, z.read(copy), "backup"))
return
source = next((n for n in sorted(names) if n.rpartition("/")[0] == "system" and
os.path.splitext(n)[0].endswith("logo")), None)
if source is None:
return
try:
data = z.read(source)
if platform is _ORION:
if not source.lower().endswith(_JPG):
return
plan.media.append(PutFile(platform.logo_uri, device_jpg(data, target), source))
else:
plan.media.append(PutFile(platform.logo_uri, native_image(data, "logo", target),
source))
except Exception as e:
plan.warnings.append(f"The boot logo could not be made from {source}: {e}")
def _plan_fonts(plan, z, names, app, platform, ext):
"""Every font the backup holds or the app names, in the device's cut `ext`.
As Ontrol's tool does, the bank's file goes rather than the backup's; the backup's
goes only for a font the bank lacks.
"""
in_zip = {} # stem -> {extension: entry}
for name in names:
folder = next((f for f in _FONT_FOLDERS if name.startswith(f)), None)
if folder and "/" not in name[len(folder):]:
stem, file_ext = os.path.splitext(name[len(folder):])
in_zip.setdefault(stem, {}).setdefault(file_ext.lower(), name)
required = {}
for m in media_files(app, platform):
if m.device_uri.startswith("fonts/"):
stem = os.path.splitext(m.device_uri[len("fonts/"):])[0]
required[stem] = required.get(stem, False) or m.required
for stem in sorted(set(in_zip) | set(required)):
bank = _bank_source(platform.font_bank, stem + ext, False)
if bank:
with open(bank, "rb") as f:
plan.media.append(PutFile("fonts/" + stem + ext, f.read(),
f"{platform.font_bank}/{stem}{ext}"))
elif ext in in_zip.get(stem, {}):
plan.media.append(PutFile("fonts/" + stem + ext,
z.read(in_zip[stem][ext]), "backup"))
plan.fonts_from_zip.append(stem + ext)
elif required.get(stem) or stem in in_zip:
plan.fonts_missing.append(stem + ext)
def put_with_files(client, plan, target, status=None):
"""Puts the plan on the device; the device takes the app up when it restarts.
Runs off the main thread; status(text, fraction) reports progress. A failure
raises, naming the step. Once app.sab.stage is in place the app is committed: a
later failure leaves the new app staged with some of its media missing.
"""
def report(text, fraction=None):
if status:
status(text, fraction)
total = sum(len(f.data) for f in plan.app_files + plan.media) or 1
done = 0
def put(uri, f):
nonlocal done
base = done
def progress(sent, size):
report(f"Writing {uri} ...", (base + sent) / total)
report(f"Writing {uri} ...", base / total)
try:
client.put_file(uri, f.data, progress)
except Exception as e:
raise RuntimeError(f"Writing {uri}: {e}") from e
done += len(f.data)
for f in plan.app_files:
put(f.device_uri + ".writing", f)
for f in plan.app_files:
writing, stage = f.device_uri + ".writing", f.device_uri + ".stage"
report(f"Renaming {writing} to {stage} ...", done / total)
try:
client.rename_file(writing, stage)
except Exception as e:
raise RuntimeError(f"Renaming {writing} to {stage}: {e}") from e
plan.staged = True
if target.delete_slot_id is not None:
for pattern in ("fonts/*", "images/*"):
report(f"Deleting {pattern} on the device ...", done / total)
try:
client.invoke(target.platform_comp_id, target.delete_slot_id,
sox.STR_ID, pattern) # an asStr Buf goes as a Str
except Exception as e:
raise RuntimeError(f"Deleting {pattern}: {e}") from e
for f in plan.media:
put(f.device_uri, f)
report("Done", 1.0)
+35 -7
View File
@@ -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.163" VERSION = "0.0.0.183"
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
@@ -118,6 +121,8 @@ def load_preferences():
left_pane_width = DEFAULT_LEFT_PANE_WIDTH left_pane_width = DEFAULT_LEFT_PANE_WIDTH
sedona_home = "" sedona_home = ""
trace_dasp = False trace_dasp = False
lazy_loading = True
show_slot_numbers = False
if os.path.exists(PROPERTIES_FILE): if os.path.exists(PROPERTIES_FILE):
try: try:
@@ -130,6 +135,8 @@ def load_preferences():
) )
sedona_home = config.get("Editor", "sedona.home", fallback="").strip() sedona_home = config.get("Editor", "sedona.home", fallback="").strip()
trace_dasp = config.getboolean("Editor", "trace_dasp", fallback=False) trace_dasp = config.getboolean("Editor", "trace_dasp", fallback=False)
lazy_loading = config.getboolean("Editor", "lazy_loading", fallback=True)
show_slot_numbers = config.getboolean("Editor", "show_slot_numbers", fallback=False)
except Exception: except Exception:
pass pass
else: else:
@@ -150,7 +157,8 @@ def load_preferences():
left_pane_width = max( left_pane_width = max(
MIN_ALLOWED_LEFT_PANE_WIDTH, min(MAX_ALLOWED_LEFT_PANE_WIDTH, left_pane_width) MIN_ALLOWED_LEFT_PANE_WIDTH, min(MAX_ALLOWED_LEFT_PANE_WIDTH, left_pane_width)
) )
return sedona_home, grid_size, add_timestamp, box_width, left_pane_width, trace_dasp return (sedona_home, grid_size, add_timestamp, box_width, left_pane_width, trace_dasp,
lazy_loading, show_slot_numbers)
def save_preferences(): def save_preferences():
@@ -170,6 +178,8 @@ def save_preferences():
"box_width": str(BOX_WIDTH), "box_width": str(BOX_WIDTH),
"left_pane_width": str(LEFT_PANE_WIDTH), "left_pane_width": str(LEFT_PANE_WIDTH),
"trace_dasp": str(TRACE_DASP), "trace_dasp": str(TRACE_DASP),
"lazy_loading": str(LAZY_LOADING),
"show_slot_numbers": str(SHOW_SLOT_NUMBERS),
} }
with open(PROPERTIES_FILE, "w") as configfile: with open(PROPERTIES_FILE, "w") as configfile:
config.write(configfile) config.write(configfile)
@@ -232,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()
@@ -241,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
@@ -259,5 +283,9 @@ def base_window_title_live():
return f"Sedona Live Editor [v{VERSION}] — {device_host}:{device_port}" return f"Sedona Live Editor [v{VERSION}] — {device_host}:{device_port}"
SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH, LEFT_PANE_WIDTH, TRACE_DASP = load_preferences() # LAZY_LOADING: on connect, read components as folders are opened (True) or the whole
# app from app.sab first (False). Read at each connect, so a change takes effect on the next.
# SHOW_SLOT_NUMBERS: prefix wiresheet slot rows with their slot id ("03. out").
(SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH, LEFT_PANE_WIDTH, TRACE_DASP,
LAZY_LOADING, SHOW_SLOT_NUMBERS) = load_preferences()
LAST_PALETTE_KIT = load_last_palette_kit() LAST_PALETTE_KIT = load_last_palette_kit()
+162
View File
@@ -0,0 +1,162 @@
"""Benchmark: lazy Sox loading vs seeding the cache from app.sab.
python bench_sab_seed.py HOST [--port 1876] [--user admin] [--password PW]
Each method runs on a fresh connection, so it starts from an empty cache.
lazy-seq every component's tree, one request at a time (as used_kit_names does)
lazy-tree the tree folder by folder, each folder's children in one window
lazy-full lazy-tree plus config and links of every component: what app.sab holds
lazy-full-seq lazy-seq plus config and links, one request at a time
sab App.save, get app.sab, decode it
"""
import argparse
import time
import app_state # noqa: F401 loads Sedona home
import schema
from device_session import DeviceSession
from sox import CONFIG
import app_backup
import sab_app
class Counter:
"""Counts Sox requests sent through the client."""
def __init__(self, client):
self.n = 0
orig_requests = client.requests
def requests(reqs, *a, **k):
self.n += len(reqs)
return orig_requests(reqs, *a, **k)
client.requests = requests
def connect(args):
ds = DeviceSession()
# DeviceSession.connect also loads the root's children and their values; time
# only the bare session here, so every method starts from the same place.
from sox import SoxClient
ds.sox_client = SoxClient(args.host, args.port, args.user, args.password)
ds.sox_client.checksum_resolver = schema.resolve_truncated_checksum
ds.sox_client.connect()
parts = ds.sox_client.read_schema()
ds.sox_client.read_version()
ds._load_slot_registry(parts)
return ds
def lazy_seq(ds):
client = ds.sox_client
pending, n = [0], 0
while pending:
comp = client.load_ids([pending.pop()], checked=False)[0]
if comp is not None:
n += 1
pending.extend(comp.children)
return n
def lazy_tree(ds):
client = ds.sox_client
client.load(0)
pending, comps = [0], []
while pending:
cid = pending.pop()
comps.append(client.cache_get(cid))
for child in ds.load_children(cid):
if child.children:
pending.append(child.id)
else:
comps.append(child)
return [c for c in comps if c is not None]
def lazy_full(ds):
client = ds.sox_client
comps = lazy_tree(ds)
# In folder-sized windows, as open_wiresheet sends them.
for i in range(0, len(comps), 16):
client.update(comps[i:i + 16], CONFIG)
for c in comps:
c.links = client.read_links(c.id)
return len(comps)
def lazy_full_seq(ds):
"""lazy-seq plus config and links, every request alone: a P-ION's pace."""
client = ds.sox_client
pending, n = [0], 0
while pending:
comp = client.load_ids([pending.pop()], checked=False)[0]
if comp is None:
continue
n += 1
pending.extend(comp.children)
client.update([comp], CONFIG)
comp.links = client.read_links(comp.id)
return n
def sab(ds, timing):
client = ds.sox_client
t = time.perf_counter()
app_backup.save_app(client)
timing["save"] = time.perf_counter() - t
t = time.perf_counter()
data = client.get_file(app_backup.APP_SAB)
timing["get"] = time.perf_counter() - t
timing["bytes"] = len(data)
t = time.perf_counter()
app = sab_app.SabApp(data)
timing["decode"] = time.perf_counter() - t
timing["links"] = len(app.links)
return len(app.comps)
def run(name, args, fn):
try:
ds = connect(args)
except Exception as e:
print(f"{name:10s} connect failed: {e}")
return
counter = Counter(ds.sox_client)
timing = {}
t = time.perf_counter()
try:
result = fn(ds, timing) if name == "sab" else fn(ds)
elapsed = time.perf_counter() - t
n = result if isinstance(result, int) else len(result)
extra = " ".join(f"{k}={v:.2f}s" if isinstance(v, float) else f"{k}={v}"
for k, v in timing.items())
print(f"{name:10s} {elapsed:7.2f}s comps={n:4d} requests={counter.n:5d} {extra}")
except Exception as e:
print(f"{name:10s} FAILED after {time.perf_counter() - t:.2f}s "
f"({counter.n} requests): {e}")
finally:
ds.disconnect()
time.sleep(1.0)
def main():
p = argparse.ArgumentParser()
p.add_argument("host")
p.add_argument("--port", type=int, default=1876)
p.add_argument("--user", default="admin")
p.add_argument("--password", default="")
p.add_argument("--only", nargs="*",
default=["lazy-seq", "lazy-tree", "lazy-full", "sab"])
args = p.parse_args()
fns = {"lazy-seq": lazy_seq, "lazy-tree": lazy_tree,
"lazy-full": lazy_full, "lazy-full-seq": lazy_full_seq, "sab": sab}
print(f"== {args.host}:{args.port}")
for name in args.only:
run(name, args, fns[name])
if __name__ == "__main__":
main()
+83 -7
View File
@@ -92,13 +92,87 @@ def _trace(direction, msg, datagram=None):
import datetime import datetime
ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3] ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
name = _MSG_TYPE_NAMES.get(msg.msg_type, f"?{msg.msg_type}") name = _MSG_TYPE_NAMES.get(msg.msg_type, f"?{msg.msg_type}")
seq = f" seq={msg.seq_num}" if msg.seq_num >= 0 else "" arrow = {"S": ">> SEND", "R": "<< RECV"}.get(direction, direction)
ack = f" ack={msg.ack}" if msg.ack >= 0 else "" payload = msg.payload or b""
plen = len(msg.payload) if msg.payload else 0 size = f" ({len(datagram)} bytes)" if datagram else ""
hexdump = "" lines = [f"[DASP] {ts} {arrow} {name:<12} {_trace_fields(msg)}{size}"]
if datagram: if datagram:
hexdump = " [" + datagram.hex(" ") + "]" hdr_len = len(datagram) - len(payload)
_trace_out(f"[DASP] {ts} {direction} {name}{seq}{ack} payload={plen}{hexdump}") lines.extend(_hexdump(datagram[:hdr_len], f"header {hdr_len}"))
if payload:
lines.extend(_hexdump(payload, f"payload {len(payload)}"))
color = _TRACE_COLORS.get(direction)
if color and _console_colors():
lines = [f"{color}{ln}\033[0m" for ln in lines]
_trace_out("\n".join(lines))
def _trace_fields(msg):
"""The message's header fields as name=value pairs, only those it carries."""
f = []
if msg.session_id >= 0: f.append(f"session=0x{msg.session_id:04x}")
if msg.seq_num >= 0: f.append(f"seq={msg.seq_num}")
if msg.ack >= 0: f.append(f"ack={msg.ack}")
if msg.ack_more: f.append(f"ackMore={msg.ack_more.hex()}")
if msg.version >= 0: f.append(f"version=0x{msg.version:04x}")
if msg.remote_id >= 0: f.append(f"remoteId=0x{msg.remote_id:04x}")
if msg.username: f.append(f"user={msg.username}")
if msg.digest_algorithm: f.append(f"digestAlg={msg.digest_algorithm}")
if msg.nonce: f.append(f"nonce=<{len(msg.nonce)} bytes>")
if msg.digest: f.append(f"digest=<{len(msg.digest)} bytes>")
if msg.ideal_max >= 0: f.append(f"idealMax={msg.ideal_max}")
if msg.abs_max >= 0: f.append(f"absMax={msg.abs_max}")
if msg.receive_max >= 0: f.append(f"receiveMax={msg.receive_max}")
if msg.receive_timeout >= 0: f.append(f"receiveTimeout={msg.receive_timeout // 1000}s")
if msg.platform_id: f.append(f"platform={msg.platform_id}")
if msg.error_code >= 0:
hint = _ERROR_HINTS.get(msg.error_code, "")
f.append(f"error=0x{msg.error_code:02x}{hint}")
return " ".join(f)
def _hexdump(data, label):
"""Hex and ASCII, 16 bytes a row, the first row headed by label."""
out = []
for off in range(0, len(data), 16):
chunk = data[off:off + 16]
hexs = chunk.hex(" ").ljust(47)
text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
head = label if off == 0 else ""
out.append(f" {head:<11} {off:04x} {hexs} |{text}|")
return out
# Sent lines yellow, received lines green, so the two directions read apart at a glance.
_TRACE_COLORS = {"S": "\033[93m", "R": "\033[92m"}
_colors_ok = None
def _console_colors():
"""True when stdout is a console that understands ANSI colour codes.
Windows consoles need virtual-terminal processing switched on first; a pipe or
file gets plain text so captured logs stay free of escape codes.
"""
global _colors_ok
if _colors_ok is None:
import sys
stream = sys.__stdout__
try:
_colors_ok = stream is not None and stream.isatty()
except (OSError, ValueError):
_colors_ok = False
if _colors_ok and os.name == "nt":
try:
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
mode = ctypes.c_uint32()
_colors_ok = bool(kernel32.GetConsoleMode(handle, ctypes.byref(mode))
and kernel32.SetConsoleMode(handle, mode.value | 0x0004))
except (OSError, AttributeError):
_colors_ok = False
return _colors_ok
# Trace lines are printed by a thread of their own. The send and receive threads must # Trace lines are printed by a thread of their own. The send and receive threads must
@@ -524,7 +598,9 @@ class SendWindow:
while self._is_alive and (self._full() or self._exceeds_ack_more_limit(p)): while self._is_alive and (self._full() or self._exceeds_ack_more_limit(p)):
self._cond.wait() self._cond.wait()
if not self._is_alive: if not self._is_alive:
return # Waiting for room when the session closed: it must not go out after
# the CLOSE, and the caller must not take it as sent.
raise DaspException(f"Session closed: {self._session.close_cause}")
if self._tail is None: if self._tail is None:
self._head = self._tail = p self._head = self._tail = p
else: else:
+115 -8
View File
@@ -31,6 +31,18 @@ def _slot_precision(type_name, slot_name):
return 2 return 2
def _as_sox_value(slot, val):
"""An app.sab config value as a Sox config read leaves it in the cache."""
if "s" in slot.flags:
return val
if slot.type in ("sys::Buf", "buf"):
# Sox reads every Buf as a Str: its bytes but the last, as text.
return val[:-1].decode("utf-8", errors="replace") if val else ""
if slot.type == "bool":
return val is not False # Sox reads a bool as u1 != 0, so null is true
return val
class DeviceSession: class DeviceSession:
"""High-level session manager for a live Sedona device connection.""" """High-level session manager for a live Sedona device connection."""
@@ -38,6 +50,10 @@ class DeviceSession:
self.sox_client = None self.sox_client = None
self._event_queue = queue.Queue() self._event_queue = queue.Queue()
self._current_subs = set() # comp_ids we subscribed for the open wiresheet self._current_subs = set() # comp_ids we subscribed for the open wiresheet
# Components whose config and links came from app.sab and have not been read
# since: their first sheet open skips reading those again.
self._seeded = set()
self.seed_info = "" # how the cache was filled, for the status bar
# ── Connect / Disconnect ──────────────────────────────────── # ── Connect / Disconnect ────────────────────────────────────
@@ -53,18 +69,30 @@ class DeviceSession:
self._load_slot_registry(parts) self._load_slot_registry(parts)
root_comp = self.sox_client.load(0) self.sox_client.load(0)
if app_state.LAZY_LOADING:
self.seed_info = "lazy loading"
else:
try:
count = self.seed_from_sab()
self.seed_info = f"app read from app.sab: {count} components"
except Exception as e:
self.seed_info = f"app.sab not used: {e}"
root_comp = self.sox_client.cache_get(0)
if root_comp and root_comp.children: if root_comp and root_comp.children:
children = self.load_children(root_comp.id) children = self.load_children(root_comp.id)
if children: if children:
self.sox_client.update(children, CONFIG | RUNTIME) self.sox_client.update(children, self._values_to_read(children))
return root_comp return root_comp
def disconnect(self): def disconnect(self, unsubscribe=True):
"""Closes the session. unsubscribe=False skips telling the device first -- for a
device going down, which would leave the request to time out."""
if self.sox_client is None: if self.sox_client is None:
return return
try: try:
if self._current_subs: if unsubscribe and self._current_subs:
comps = [self.sox_client.cache_get(cid) comps = [self.sox_client.cache_get(cid)
for cid in self._current_subs] for cid in self._current_subs]
comps = [c for c in comps if c is not None] comps = [c for c in comps if c is not None]
@@ -83,6 +111,10 @@ class DeviceSession:
def is_connected(self): def is_connected(self):
return self.sox_client is not None and not self.sox_client.is_closed return self.sox_client is not None and not self.sox_client.is_closed
@property
def close_cause(self):
return self.sox_client.close_cause if self.sox_client else "disconnected"
# ── Schema → Slot Registry ────────────────────────────────── # ── Schema → Slot Registry ──────────────────────────────────
def _load_slot_registry(self, parts): def _load_slot_registry(self, parts):
@@ -159,6 +191,74 @@ class DeviceSession:
if c is not None and c.kit_id == kit_id and c.type_id == type_id_val: if c is not None and c.kit_id == kit_id and c.type_id == type_id_val:
c.type_name = full_name c.type_name = full_name
# ── Seeding from app.sab ────────────────────────────────────
def seed_from_sab(self):
"""Fills the cache with the whole app — tree, config and links — from app.sab.
Reading component by component costs a round trip each, and a device answers one
request per pass of its loop: 230 components took 116 s on a P-ION, where saving
the app and reading app.sab took 8 s. The app is saved first, so app.sab holds
what is running. From here on Sox keeps the cache current, as it does for a
lazily loaded one; runtime values are still read when a sheet opens.
Nothing goes into the cache unless the whole file decodes and its tree matches the
device's, so on any failure the caller falls back to lazy loading. Returns the
number of components.
"""
import app_backup
import sab_app
from sox import Link
client = self.sox_client
root = client.cache_get(0) or client.load(0)
app_backup.save_app(client)
app = sab_app.SabApp(client.get_file(app_backup.APP_SAB))
# app.sab numbers kits by the app's schema and Sox by kits.scode's; the two can
# differ, so types are matched by name, and a kit only when its checksum agrees.
device_kits = {p.name: f"{p.checksum & 0xFFFFFFFF:08x}" for p in client.schema_parts}
for name, checksum in app.kit_parts:
if device_kits.get(name) != checksum:
raise ValueError(f"kit {name} in app.sab is not the device's")
type_key = {qname: key for key, qname in client.type_name_map.items()}
if [k["id"] for k in app.app["kids"]] != root.children:
raise ValueError("app.sab does not match the running app")
comps = {}
for c in app.comps.values():
key = type_key.get(c["type"])
if key is None:
raise ValueError(f"no manifest for {c['type']}")
comp = SoxComponent(c["id"], key[0], key[1], c["type"])
comp.name = c["name"]
comp.parent = c["parentId"]
comp.children = [k["id"] for k in c["kids"]]
comp.permissions = root.permissions
for slot, val in c["props"]:
comp.values[slot.name] = _as_sox_value(slot, val)
comps[comp.id] = comp
comps[0].subscription = root.subscription
# Sox reads a component's links both ways, into it and out of it.
for from_c, from_slot, to_c, to_slot in app.links:
link = Link(from_c["id"], from_slot, to_c["id"], to_slot)
comps[from_c["id"]].links.append(link)
if to_c["id"] != from_c["id"]:
comps[to_c["id"]].links.append(link)
for comp in comps.values():
client.cache_add(comp)
self._seeded = set(comps)
return len(comps)
def _values_to_read(self, comps):
"""What to read for `comps` on a sheet open: config too, unless app.sab gave it."""
if all(c.id in self._seeded for c in comps):
return RUNTIME
return CONFIG | RUNTIME
# ── Lazy Loading ──────────────────────────────────────────── # ── Lazy Loading ────────────────────────────────────────────
def load_children(self, comp_id): def load_children(self, comp_id):
@@ -251,9 +351,9 @@ class DeviceSession:
all_link_remote_ids = set() all_link_remote_ids = set()
for child in children: for child in children:
links = self.sox_client.read_links(child.id) if child.id not in self._seeded:
child.links = links child.links = self.sox_client.read_links(child.id)
for lnk in links: for lnk in child.links:
all_link_remote_ids.add(lnk.from_comp_id) all_link_remote_ids.add(lnk.from_comp_id)
all_link_remote_ids.add(lnk.to_comp_id) all_link_remote_ids.add(lnk.to_comp_id)
@@ -265,7 +365,14 @@ class DeviceSession:
if remote_ids: if remote_ids:
self.sox_client.load_ids(remote_ids, checked=False) self.sox_client.load_ids(remote_ids, checked=False)
self.sox_client.update(children, CONFIG | RUNTIME) seeded = [c for c in children if c.id in self._seeded]
others = [c for c in children if c.id not in self._seeded]
if seeded:
self.sox_client.update(seeded, RUNTIME)
if others:
self.sox_client.update(others, CONFIG | RUNTIME)
# Subscribed from here on, so Sox keeps them current; a later open reads again.
self._seeded -= child_ids
folder_comp = self.sox_client.cache_get(folder_comp_id) folder_comp = self.sox_client.cache_get(folder_comp_id)
to_subscribe = [folder_comp] + children if folder_comp else children to_subscribe = [folder_comp] + children if folder_comp else children
+3 -1
View File
@@ -5,9 +5,11 @@ add_timestamp = True
box_width = 250 box_width = 250
left_pane_width = 230 left_pane_width = 230
trace_dasp = True trace_dasp = True
lazy_loading = True
show_slot_numbers = False
[Device] [Device]
host = 192.168.1.29 host = 192.168.0.26
port = 1876 port = 1876
user = admin user = admin
+190
View File
@@ -0,0 +1,190 @@
"""Building a device's kits.scode, as Ontrol's PutApp does it with sedonac.
The editor carries no Sedona compiler: Ontrol's own sedonac is run through Java, from
the Niagara install next to Sedona home (Niagara 4's ontrolSedonanet jars, or Niagara
AX's sedona/sedonac jars -- both build the same image). Its input is a kits.xml naming
each kit at its version, with the target platform's endian, block and ref sizes taken
from the platform manifest in Sedona home's platform db.
Before a build the kits' calls into native code are checked against the platform's
SVM, as PutApp.checkNativeCompatibility does: an scode calling a native the device's
VM lacks would not start.
"""
import glob
import os
import re
import shutil
import subprocess
import tempfile
import xml.etree.ElementTree as ET
import zipfile
import app_state
import palette
class BuildError(Exception):
pass
def _home():
return app_state.SEDONA_HOME
# --- kits -------------------------------------------------------------------------------
def kit_file(name, checksum):
"""(path, version) of the .kit for a kit build; the highest version when one checksum
was released more than once. None when Sedona home has no such .kit."""
want = int(checksum, 16) if isinstance(checksum, str) else checksum & 0xFFFFFFFF
pattern = re.compile(rf"^{re.escape(name)}-([0-9a-fA-F]+)-(.+)\.kit$")
found = []
for path in glob.glob(os.path.join(_home(), "kits", name, f"{name}-*.kit")):
match = pattern.match(os.path.basename(path))
if match and int(match.group(1), 16) == want:
found.append((palette.version_sort_key(match.group(2)), path, match.group(2)))
if not found:
return None
_, path, version = max(found)
return path, version
# --- the target platform ----------------------------------------------------------------
def platform_manifest(platform_id):
"""The platformManifest.xml in Sedona home's platform db for a platform id, or None."""
if not platform_id:
return None
# os.walk, not glob: the manifests sit in hidden .par folders glob skips.
for folder, _, files in os.walk(os.path.join(_home(), "platforms", "db")):
if "platformManifest.xml" not in files:
continue
path = os.path.join(folder, "platformManifest.xml")
try:
if ET.parse(path).getroot().get("platformId") == platform_id:
return path
except ET.ParseError:
continue
return None
_CLASS = re.compile(r"^\s*(?:[a-z]+\s+)*class\s+(\w+)", re.M)
_NATIVE = re.compile(r"\bnative\b[^=\n]*?\b(\w+)\s*\([^)]*\)\s*=\s*(\d+::\d+)")
_CALL = re.compile(r"\bCallNative\w*\s+(\w+::\w+\.\w+)")
def native_problems(kits, manifest_path):
"""Lines naming each native the kits call that the platform's SVM does not provide.
kits is [(name, checksum)]. Read from the kits' .ir files: a native's id from its
declaration, the natives in use from the CallNative ops.
"""
svm = {e.get("id"): e.get("qname")
for e in ET.parse(manifest_path).getroot().iter("nativeMethod")}
declared, called = {}, set()
for name, checksum in kits:
found = kit_file(name, checksum)
if found is None:
continue # reported by the build
with zipfile.ZipFile(found[0]) as z:
for entry in z.namelist():
if not entry.endswith(".ir"):
continue
text = z.read(entry).decode("utf-8", "replace")
classes = [(m.start(), m.group(1)) for m in _CLASS.finditer(text)]
for m in _NATIVE.finditer(text):
owner = [c for pos, c in classes if pos < m.start()]
if owner:
declared[f"{name}::{owner[-1]}.{m.group(1)}"] = m.group(2)
called.update(_CALL.findall(text))
problems = []
for qname in sorted(called):
nid = declared.get(qname)
if nid is None or nid not in svm:
problems.append(f"{qname} ({nid or 'not declared'}) is not in the device's VM")
elif svm[nid] != qname:
problems.append(f"{qname} ({nid}) is {svm[nid]} in the device's VM")
return problems
# --- the compiler -----------------------------------------------------------------------
def _java():
found = shutil.which("java")
if found:
return found
candidates = []
if os.environ.get("JAVA_HOME"):
candidates.append(os.path.join(os.environ["JAVA_HOME"], "bin", "java.exe"))
niagara_root = os.path.dirname(os.path.abspath(_home()))
candidates += sorted(glob.glob(os.path.join(niagara_root, "Niagara-*", "jre", "bin",
"java.exe")), reverse=True)
for root in (os.environ.get("ProgramFiles", r"C:\Program Files"),):
candidates += sorted(glob.glob(os.path.join(root, "*", "*", "bin", "java.exe")),
reverse=True)
return next((c for c in candidates if os.path.isfile(c)), None)
def _classpath():
"""sedonac and the sedona runtime from the Niagara install beside Sedona home."""
niagara_root = os.path.dirname(os.path.abspath(_home()))
for niagara in sorted(glob.glob(os.path.join(niagara_root, "Niagara-*")), reverse=True):
modules = os.path.join(niagara, "modules")
for jars in (("ontrolSedonanet-rt.jar", "ontrolSedonanet-wb.jar"),
("sedona.jar", "sedonac.jar")):
paths = [os.path.join(modules, j) for j in jars]
if all(os.path.isfile(p) for p in paths):
return os.pathsep.join(paths)
return None
def toolchain():
"""(java, classpath) for running sedonac; raises BuildError saying what is missing."""
java, classpath = _java(), _classpath()
if not java:
raise BuildError("No Java runtime was found (java on the PATH, JAVA_HOME, or "
"Niagara's jre).")
if not classpath:
raise BuildError("sedonac was not found: no Niagara install beside Sedona home "
f"({os.path.dirname(os.path.abspath(_home()))}) has Ontrol's "
"ontrolSedonanet jars or sedona.jar + sedonac.jar.")
return java, classpath
def build_kits_scode(kits, manifest_path):
"""Compiles kits.scode for [(name, checksum)] on the platform; returns its bytes."""
plat = ET.parse(manifest_path).getroot()
lines = ["<sedonaCode"]
for attr in ("endian", "blockSize", "refSize", "debug", "test", "armDouble"):
lines.append(f' {attr}="{plat.get(attr)}"')
lines += [' main="sys::Sys.main"', ">"]
missing = []
for name, checksum in kits:
found = kit_file(name, checksum)
if found is None:
missing.append(f"{name} {checksum}")
else:
lines.append(f' <depend on="{name} {found[1]}=" />')
if missing:
raise BuildError("Sedona home has no .kit file for " + ", ".join(missing))
lines.append("</sedonaCode>")
java, classpath = toolchain()
with tempfile.TemporaryDirectory(prefix="sedona_editor_scode_") as work:
with open(os.path.join(work, "kits.xml"), "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
try:
run = subprocess.run(
[java, f"-Dsedona.home={_home()}", "-cp", classpath, "sedonac.Main",
"kits.xml"],
cwd=work, capture_output=True, text=True, timeout=180,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
except (OSError, subprocess.TimeoutExpired) as e:
raise BuildError(f"sedonac could not run: {e}") from e
scode = os.path.join(work, "kits.scode")
if run.returncode != 0 or not os.path.isfile(scode):
tail = "\n".join((run.stdout + run.stderr).strip().splitlines()[-12:])
raise BuildError(f"sedonac failed:\n{tail}")
with open(scode, "rb") as f:
return f.read()
+96 -7
View File
@@ -11,7 +11,9 @@ either end. While dragging, the cursor points along the flow of the link over a
would take it, and turns into a no-entry sign over one that would not, so a bad drop is 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="+")
+37
View File
@@ -155,6 +155,43 @@ def live_rename_component(element, new_name):
return False return False
# ── Reorder Children ────────────────────────────────────────────
def live_reorder_children(parent_element, ordered_children):
"""In live mode, sends a Sox reorder to the device. Returns True on success.
The device takes the order only when it names every child it has, no more and no
fewer, so the list is checked against what the device last said before it is sent.
"""
ds = _session()
if ds is None:
return False
parent_id = _comp_id(parent_element)
if parent_id is None:
_fail(f"'{parent_element.get('name', '?')}' has no component id.")
return False
child_ids = []
for child in ordered_children:
cid = _comp_id(child)
if cid is None:
_fail(f"'{child.get('name', '?')}' has no component id.")
return False
child_ids.append(cid)
client = ds.sox_client
cached = client.cache_get(parent_id)
if cached is not None and set(cached.children) != set(child_ids):
_fail("The editor's list of children no longer matches the device's. "
"Reconnect to read the device again.")
return False
try:
client.reorder(parent_id, child_ids)
return True
except Exception as e:
_fail(f"The device rejected the reorder: {e}")
return False
# ── Link / Unlink ─────────────────────────────────────────────── # ── Link / Unlink ───────────────────────────────────────────────
def _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name): def _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name):
+696 -173
View File
File diff suppressed because it is too large Load Diff
+16 -7
View File
@@ -67,7 +67,12 @@ def kit_versions(kit_name):
for entry in os.listdir(kit_dir): for entry in os.listdir(kit_dir):
match = pattern.match(entry) match = pattern.match(entry)
if match: if match:
versions[_norm_checksum(match.group(1))] = match.group(2) # A checksum released more than once (ontrolPlatform 1.0.0 and 1.0.1)
# stands for its highest version, as the devices report it.
key, version = _norm_checksum(match.group(1)), match.group(2)
if (key not in versions or
version_sort_key(version) > version_sort_key(versions[key])):
versions[key] = version
_versions_cache[kit_name] = versions _versions_cache[kit_name] = versions
return versions return versions
@@ -269,7 +274,7 @@ def _platforms_db():
return os.path.join(app_state.SEDONA_HOME, "platforms", "db") return os.path.join(app_state.SEDONA_HOME, "platforms", "db")
def _platform_native_kits(): def _platform_native_kits(sax_names=None):
"""Return (matching, all_platforms) — two sets of kit names. """Return (matching, all_platforms) — two sets of kit names.
*matching* is the union of nativeKit names from platform builds that include *matching* is the union of nativeKit names from platform builds that include
@@ -278,8 +283,10 @@ def _platform_native_kits():
A kit that appears in *all_platforms* but not in *matching* has native code A kit that appears in *all_platforms* but not in *matching* has native code
compiled for a different device and cannot work with this SAX. compiled for a different device and cannot work with this SAX.
sax_names stands in for the open file's kits when given.
""" """
sax_names = set(app_state.active_schema_kits.keys()) sax_names = set(app_state.active_schema_kits.keys() if sax_names is None else sax_names)
db = _platforms_db() db = _platforms_db()
if not os.path.isdir(db): if not os.path.isdir(db):
return None, None return None, None
@@ -335,8 +342,9 @@ def _platform_native_kits():
return (matching if found else None), all_platforms return (matching if found else None), all_platforms
def unsupported_kits(): def unsupported_kits(sax_names=None):
"""Kit names that cannot work with the open file's platform. """Kit names that cannot work with the open file's platform -- or with the platform
of the kits in `sax_names`, when given.
A kit is unsupported when: A kit is unsupported when:
- it has native code (hasNatives or listed as nativeKit on some platform) - it has native code (hasNatives or listed as nativeKit on some platform)
@@ -345,11 +353,12 @@ def unsupported_kits():
Kits that depend on an unsupported kit are also flagged. Kits that depend on an unsupported kit are also flagged.
""" """
sax_names = frozenset(app_state.active_schema_kits.keys()) sax_names = frozenset(app_state.active_schema_kits.keys() if sax_names is None
else sax_names)
if sax_names in _unsupported_cache: if sax_names in _unsupported_cache:
return _unsupported_cache[sax_names] return _unsupported_cache[sax_names]
matching_natives, all_natives = _platform_native_kits() matching_natives, all_natives = _platform_native_kits(sax_names)
if matching_natives is None: if matching_natives is None:
_unsupported_cache[sax_names] = set() _unsupported_cache[sax_names] = set()
return set() return set()
+242
View File
@@ -0,0 +1,242 @@
"""The kits step of App > Put with Files, laid out as the Manage Kits panel is.
The same groups (kits the app needs, kits declared without being used, kits available,
kits not supported), the same tick column and the same Action cell: "Keep at",
"Upgrade to", "Downgrade to", "Add at", "REMOVE". Installed is what the device runs;
Backup is what the backup's app was saved with.
What is ticked goes: into app.sab's schema and, when that is not the device's kits,
into a kits.scode built for them (Ontrol's PutApp does the same). "Rebuild kits.scode"
forces the build when nothing changed.
"""
import tkinter as tk
from tkinter import messagebox, ttk
import app_backup
import manage_kits
from manage_kits import NOT_DECLARED, SECTION_TAG, column_label, make_checkbox_image
REMOVE_ACTION = "REMOVE from device"
def _options(row):
"""The Action choices for a row, as (label, checksum) pairs."""
if row.device:
return [(label, checksum or row.device) for label, checksum in
manage_kits.action_options(row.name, row.device, True, row.builds)]
return [(f"Add at {manage_kits.build_label(v, c)}", c) for v, c in row.builds]
def _build_text(row, checksum):
if not checksum:
return NOT_DECLARED
version = next((v for v, c in row.builds if c == checksum), "")
return column_label(version, checksum)
class _Dialog:
def __init__(self, parent, rows, title):
self.rows = rows
self.result = None
self.original = [(r.included, r.use) for r in rows]
self.top = top = tk.Toplevel(parent)
top.title(f"{title} - Kits")
top.transient(parent)
top.grab_set()
top.geometry(f"+{parent.winfo_x() + 60}+{parent.winfo_y() + 40}")
frame = ttk.Frame(top, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
columns = ("name", "installed", "backup", "latest", "action")
table_frame = ttk.Frame(frame)
table_frame.pack(fill=tk.BOTH, expand=True)
self.table = ttk.Treeview(table_frame, columns=columns, show="tree headings",
selectmode="browse", height=20)
self.table.heading("#0", text="")
self.table.column("#0", width=32, minwidth=32, stretch=False, anchor="center")
for column, heading, width in [("name", "Kit name", 190),
("installed", "Installed", 105),
("backup", "Backup", 105),
("latest", "Latest", 105),
("action", "Action", 215)]:
self.table.heading(column, text=heading, anchor="w")
self.table.column(column, width=width, minwidth=width, anchor="w", stretch=False)
vscroll = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.table.yview)
self.table.configure(yscrollcommand=vscroll.set)
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
self.table.pack(fill=tk.BOTH, expand=True)
self.table.tag_configure(SECTION_TAG, background="#dcdcdc")
self.boxes = {(checked, enabled): make_checkbox_image(checked, enabled)
for checked in (True, False) for enabled in (True, False)}
self.cross_box = make_checkbox_image(False, False, cross=True)
locked = any(r.locked for r in rows)
hint = ("The backup holds no app.sax, so its app goes with the kits it was saved "
"with." if locked else
"A tick means the kit goes to the device. Grey ticks are needed by the app "
"and cannot be removed; untick another to remove it, tick one to add it. "
"Click the Action cell to choose a build. Installed is the device's build. "
"Kits other than the device's mean a new kits.scode.")
ttk.Label(frame, text=hint, foreground="#666666", wraplength=780
).pack(anchor="w", pady=(8, 0))
self.rebuild = tk.BooleanVar(value=False)
self.rebuild_check = ttk.Checkbutton(frame, text="Rebuild kits.scode",
variable=self.rebuild)
self.rebuild_check.pack(anchor="w", pady=(6, 0))
button_row = ttk.Frame(frame)
button_row.pack(fill=tk.X, pady=(10, 0))
upgrade = ttk.Button(button_row, text="Upgrade all kits to latest version",
command=self._upgrade_all)
upgrade.pack(side=tk.LEFT)
if locked:
upgrade.state(["disabled"])
ttk.Button(button_row, text="Cancel", command=lambda: self._close(False)
).pack(side=tk.RIGHT)
ttk.Button(button_row, text="Next", command=lambda: self._close(True)
).pack(side=tk.RIGHT, padx=6)
self.row_by_item = {}
self._editor = None
self.table.bind("<Button-1>", self._on_click)
self.table.bind("<MouseWheel>", lambda _e: self._close_editor(), add="+")
top.protocol("WM_DELETE_WINDOW", lambda: self._close(False))
self._rebuild_table()
# --- the table ---
def _action_text(self, row):
if row.unsupported:
return "Not supported"
if not row.included:
return REMOVE_ACTION if row.device else NOT_DECLARED
return next((label for label, c in _options(row) if c == row.use),
_build_text(row, row.use))
def _values(self, row):
latest = row.builds[0] if row.builds else None
return (row.name, _build_text(row, row.device), _build_text(row, row.backup),
column_label(*latest) if latest else NOT_DECLARED, self._action_text(row))
def _image(self, row):
if row.unsupported:
return self.cross_box
return self.boxes[(row.included, not (row.required or row.locked))]
def _rebuild_table(self):
self._close_editor()
top = self.table.yview()[0]
self.table.delete(*self.table.get_children())
self.row_by_item.clear()
for title in app_backup.KIT_GROUPS:
members = [r for r in self.rows if app_backup.group(r) == title]
if not members:
continue
self.table.insert("", "end", values=(title, "", "", "", ""), tags=(SECTION_TAG,))
for row in members:
item = self.table.insert("", "end", image=self._image(row),
values=self._values(row))
self.row_by_item[item] = row
self.table.yview_moveto(top)
# Kits other than the device's cannot run on its kits.scode.
changed = any(app_backup.kit_change(r) for r in self.rows)
if changed:
self.rebuild.set(True)
self.rebuild_check.state(["disabled"])
else:
self.rebuild_check.state(["!disabled"])
def _changed(self):
app_backup.settle_required(self.rows)
self._rebuild_table()
# --- editing ---
def _close_editor(self):
if self._editor is not None:
self._editor.destroy()
self._editor = None
def _on_click(self, event):
self._close_editor()
if self.table.identify_region(event.x, event.y) not in ("tree", "cell"):
return
item = self.table.identify_row(event.y)
row = self.row_by_item.get(item)
if row is None or row.locked or row.unsupported:
return
column = self.table.identify_column(event.x)
if column == "#0":
self._toggle(row)
elif column == "#5":
self._edit_action(item, row)
def _toggle(self, row):
if row.required:
return
if not row.included and not row.builds:
messagebox.showwarning("Put with Files", f"Sedona home holds no build of "
f"'{row.name}' to add it at.", parent=self.top)
return
row.included = not row.included
if row.included and not row.use:
row.use = row.latest
self._changed()
def _edit_action(self, item, row):
options = _options(row)
if not options:
return
cell = self.table.bbox(item, "action")
if not cell:
return
labels = [label for label, _ in options]
variable = tk.StringVar(value=self._action_text(row))
combo = ttk.Combobox(self.table, textvariable=variable, values=labels,
state="readonly")
combo.place(x=cell[0], y=cell[1], width=cell[2], height=cell[3])
self._editor = combo
def commit(_event=None):
if variable.get() in labels:
row.use = options[labels.index(variable.get())][1]
row.included = True
self._close_editor()
self._changed()
combo.bind("<<ComboboxSelected>>", commit)
combo.bind("<Escape>", lambda _e: self._close_editor())
combo.focus_set()
combo.after_idle(lambda: combo.event_generate("<Down>"))
def _upgrade_all(self):
for row in self.rows:
if row.included and row.latest and not row.locked:
row.use = row.latest
self._changed()
def _close(self, ok):
self._close_editor()
if ok:
self.result = bool(self.rebuild.get())
else:
for row, (included, use) in zip(self.rows, self.original):
row.included, row.use = included, use
app_backup.settle_required(self.rows)
self.top.grab_release()
self.top.destroy()
def ask(parent, rows, title="Put with Files"):
"""Shows the kits step; returns whether to rebuild kits.scode, or None when cancelled.
The rows are updated in place with what was chosen.
"""
dialog = _Dialog(parent, rows, title)
dialog.top.wait_window()
return dialog.result
+220 -1
View File
@@ -1,4 +1,5 @@
"""Reads a device's app.sab and writes it back out as app.sax. """Reads a device's app.sab and writes it back out as app.sax, and encodes an app.sax
into app.sab (encode_sax).
Ported from Py_sab2sax (sax.py/sab.py), itself a port of sedona.offline.OfflineApp Ported from Py_sab2sax (sax.py/sab.py), itself a port of sedona.offline.OfflineApp
(decodeAppBinary + encodeAppXml); its output matches `sedonac app.sab` byte for byte, (decodeAppBinary + encodeAppXml); its output matches `sedonac app.sab` byte for byte,
@@ -79,6 +80,9 @@ def _resolve_slots(qname, types_by_q, cache):
"""The type's slots, base chain first, indexed by slot id.""" """The type's slots, base chain first, indexed by slot id."""
if qname in cache: if qname in cache:
return cache[qname] return cache[qname]
if qname not in types_by_q:
raise SabError(f"type {qname} is not in the app's kits -- a kit build that "
f"needs kit '{qname.split('::')[0]}', which the app does not list?")
t = types_by_q[qname] t = types_by_q[qname]
base = t.get("base") base = t.get("base")
out = list(_resolve_slots(base, types_by_q, cache)) if base else [] out = list(_resolve_slots(base, types_by_q, cache)) if base else []
@@ -408,3 +412,218 @@ def _path_of(c):
parts.append(c["name"]) parts.append(c["name"])
c = c["parent"] c = c["parent"]
return "/" + "/".join(reversed(parts)) if parts else "/" return "/" + "/".join(reversed(parts)) if parts else "/"
# --- encoding app.sax to app.sab ------------------------------------------------------
#
# Ported from Py_sab2sax's sab.py (sedona.offline.OfflineApp decodeAppXml +
# encodeAppBinary), with the value parsing brought in line with the Java Value classes:
# asStr text is unescaped as TextUtil.fromLiteral does, "nullbool"/"nullfloat" are
# read, and integers take Integer.decode's '#' hex and leading-zero octal.
def _decode_int(raw):
"""Integer.decode."""
if raw in (None, "", "null"):
return 0
s = raw.strip()
neg = s.startswith("-")
if s[:1] in "+-":
s = s[1:]
if s[:2].lower() == "0x":
v = int(s[2:], 16)
elif s.startswith("#"):
v = int(s[1:], 16)
elif len(s) > 1 and s.startswith("0"):
v = int(s[1:], 8)
else:
v = int(s)
return -v if neg else v
def _from_literal(s):
"""TextUtil.fromLiteral."""
if "\\" not in s:
return s
escapes = {"0": "\0", "n": "\n", "r": "\r", "t": "\t", "\\": "\\", '"': '"', "$": "$"}
out, i = [], 0
while i < len(s):
c = s[i]
if c == "\\":
i += 1
if i >= len(s) or s[i] not in escapes:
raise SabError(f"unknown escape sequence in {s!r}")
c = escapes[s[i]]
out.append(c)
i += 1
return "".join(out)
class _Writer:
def __init__(self):
self.b = bytearray()
def u1(self, v): self.b.append(v & 0xFF)
def u2(self, v): self.b += struct.pack(">H", v & 0xFFFF)
def i4(self, v): self.b += struct.pack(">i", ((v + 2**31) % 2**32) - 2**31)
def i8(self, v): self.b += struct.pack(">q", v)
def f4(self, v): self.b += struct.pack(">f", _to_f32(v))
def f8(self, v): self.b += struct.pack(">d", v)
def str_(self, s):
self.b += s.encode("ascii")
self.u1(0)
def _encode_value(out, slot, raw):
"""One config prop, from its sax text (or the manifest default when absent)."""
vtype, flags = slot.type, slot.flags
if "s" in flags: # asStr Buf: u2 len (incl NUL)
data = _from_literal(raw or "").encode("ascii")
out.u2(len(data) + 1)
out.b += data
out.u1(0)
elif vtype == "bool":
if raw in ("null", "nullbool"):
out.u1(2)
elif raw in (None, "", "false", "true"):
out.u1(1 if raw == "true" else 0)
else:
raise SabError(f"invalid bool {raw!r}")
elif vtype == "byte":
out.u1(_decode_int(raw))
elif vtype == "short":
out.u2(_decode_int(raw))
elif vtype == "int":
out.i4(_decode_int(raw))
elif vtype == "long":
out.i8(_decode_int(raw))
elif vtype in ("float", "double"):
v = float("nan") if raw in ("null", "nullfloat", "nulldouble") else _flt(raw)
(out.f4 if vtype == "float" else out.f8)(v)
elif vtype in ("sys::Buf", "buf"):
data = base64.b64decode(raw) if raw else b""
out.u2(len(data))
out.b += data
else:
raise SabError(f"unhandled slot type {vtype}")
def encode_sax(sax, checksums=None, kits=None):
"""app.sax (text or bytes) -> app.sab bytes, as `sedonac app.sax` writes it.
checksums, {kit name: checksum hex}, stands in for the sax's own -- how an app goes
to a device whose kits are other builds than the ones it was saved with. kits,
[(name, checksum hex)], replaces the schema altogether, as Ontrol's PutApp rewrites
it for the kits chosen to put. Every kit's manifest must be in Sedona home under
the checksum used.
"""
root = ET.fromstring(sax)
schema_elem, app_elem = root.find("schema"), root.find("app")
if schema_elem is None or app_elem is None:
raise SabError("not a Sedona app: no <schema> or <app>")
if kits is None:
kits = [(k.get("name"), (checksums or {}).get(k.get("name")) or k.get("checksum"))
for k in schema_elem.findall("kit")]
parts = []
for name, cks in kits:
if not cks:
raise SabError(f"no checksum for kit '{name}'")
parts.append((name, f"{int(cks, 16):08x}"))
parts.sort(key=lambda p: ("" if p[0] == "sys" else "\x01" + p[0])) # sortKits
for name, cks in parts:
path = schema.find_manifest_path(name, cks)
if path is None or not path.endswith(f"-{cks}.xml"):
raise SabError(f"no manifest for kit '{name}' {cks} in Sedona home")
kits, types_by_q = _load_kits(parts)
type_ids = {}
for ki, ((kname, _), tlist) in enumerate(zip(parts, kits)):
for t in tlist:
type_ids[f"{kname}::{t.get('name')}"] = (ki, int(t.get("id")))
cache = {}
comps = []
def walk(elem, qtype, parent):
if qtype not in type_ids:
raise SabError(f"unknown type '{qtype}' ({elem.get('name')})")
c = {"elem": elem, "type": qtype, "parent": parent, "kids": [],
"id": int(elem.get("id")) if elem.get("id") else None}
comps.append(c)
if parent is not None:
parent["kids"].append(c)
for kid in elem.findall("comp"):
walk(kid, kid.get("type"), c)
return c
app = walk(app_elem, "sys::App", None)
app["id"] = 0
used = {c["id"] for c in comps if c["id"] is not None}
next_id = 1
for c in comps: # ids for comps the sax gives none
if c["id"] is None:
while next_id in used:
next_id += 1
c["id"] = next_id
used.add(next_id)
by_id = {}
for c in comps:
if c["id"] in by_id:
raise SabError(f"duplicate id: {c['id']}")
by_id[c["id"]] = c
out = _Writer()
out.i4(0x73617070) # "sapp"
out.i4(0x0003) # version 0.3
out.u1(len(parts))
for name, cks in parts:
out.str_(name)
out.i4(int(cks, 16))
out.u2(max(by_id))
for cid in sorted(by_id):
c = by_id[cid]
ki, ti = type_ids[c["type"]]
out.u2(cid)
out.u1(ki)
out.u1(ti)
out.str_(c["elem"].get("name", "app") if cid else "app")
out.u2(c["parent"]["id"] if c["parent"] else 0xFFFF)
out.u2(c["kids"][0]["id"] if c["kids"] else 0xFFFF)
sibling = 0xFFFF
if c["parent"]:
kids = c["parent"]["kids"]
i = kids.index(c)
if i + 1 < len(kids):
sibling = kids[i + 1]["id"]
out.u2(sibling)
props = {p.get("name"): p.get("val") for p in c["elem"].findall("prop")}
for slot in _resolve_slots(c["type"], types_by_q, cache):
if "a" in slot.flags or "c" not in slot.flags:
continue # config props only
_encode_value(out, slot, props.get(slot.name, slot.default))
out.u1(ord(";"))
out.u2(0xFFFF)
def ref(text):
path, slot_name = text.rsplit(".", 1)
cur = app
for part in (p for p in path.split("/") if p):
cur = next((k for k in cur["kids"] if k["elem"].get("name") == part), None)
if cur is None:
raise SabError(f"link to a missing component: {text}")
names = [s.name for s in _resolve_slots(cur["type"], types_by_q, cache)]
if slot_name not in names:
raise SabError(f"link to a missing slot: {text}")
return cur["id"], names.index(slot_name)
links = root.find("links")
for link in (links.findall("link") if links is not None else ()):
fc, fs = ref(link.get("from"))
tc, ts = ref(link.get("to"))
out.u2(fc)
out.u1(fs)
out.u2(tc)
out.u1(ts)
out.u2(0xFFFF)
out.u1(ord("."))
return bytes(out.b)
+39 -2
View File
@@ -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()
@@ -483,6 +483,43 @@ def rename_component(comp_element, new_name):
return True return True
def reorder_children(parent, new_order):
"""Gives `parent`'s child components a new order, which is the order they run in.
Returns True when the order changed. On a device the new order goes there first,
and the file is left alone if it does not.
"""
root = app_state.xml_root_element
if root is None:
return False
old_order = [child for child in parent if child.tag == "comp"]
new_order = list(new_order)
if new_order == old_order:
return False
if (len(new_order) != len(old_order)
or {id(c) for c in new_order} != {id(c) for c in old_order}):
return False
name = parent.get("name", "app")
if app_state.live_mode:
if not live_hooks.live_reorder_children(parent, new_order):
live_hooks.warn_failure("Reorder", f"{name} was not reordered on the device")
return False
undo.apply_child_order(parent, new_order)
command_class = (undo.LiveReorderChildrenCommand if app_state.live_mode
else undo.ReorderChildrenCommand)
undo.push(command_class(parent, old_order, new_order, label=f"reorder {name}"))
app_state.mark_dirty()
refresh_treeview_from_dom(root)
wiresheet.render_current_sheet()
where = " on the device" if app_state.live_mode else ""
app_state.set_status(f"{name} reordered{where}.")
return True
def allocate_component_id(): def allocate_component_id():
"""Lowest unused id, matching how this app's ids are dense with no gaps.""" """Lowest unused id, matching how this app's ids are dense with no gaps."""
used = set() used = set()
+5 -3
View File
@@ -353,9 +353,11 @@ def get_slots_for_type(type_str, linked_slots=()):
if "c" in slot_data["flags"]: if "c" in slot_data["flags"]:
continue continue
# Format to double digits (e.g., 00, 01, 02) # Format to double digits (e.g., 00, 01, 02), unless turned off in Preferences
padded_id = f"{slot_data['slot_id']:02d}" if app_state.SHOW_SLOT_NUMBERS:
display_label = f"{padded_id}. {slot_name}" display_label = f"{slot_data['slot_id']:02d}. {slot_name}"
else:
display_label = slot_name
# Keep structural name unchanged for XML lookup inside comp, but pass display label # Keep structural name unchanged for XML lookup inside comp, but pass display label
slots_list.append((slot_name, display_label)) slots_list.append((slot_name, display_label))
+100 -13
View File
@@ -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):
@@ -384,7 +386,7 @@ class SoxClient:
# file transfer: one at a time; its chunks arrive unasked, so the receiver # file transfer: one at a time; its chunks arrive unasked, so the receiver
# hands them to whichever transfer is open # hands them to whichever transfer is open
self._xfer_lock = threading.Lock() self._xfer_lock = threading.Lock()
self._xfer = None # _FileGet while a get is open self._xfer = None # _FileGet or _FilePut while a transfer is open
# ── Connect / Disconnect ──────────────────────────────────── # ── Connect / Disconnect ────────────────────────────────────
@@ -419,6 +421,11 @@ class SoxClient:
def is_closed(self): def is_closed(self):
return self._session is None or self._session.is_closed return self._session is None or self._session.is_closed
@property
def close_cause(self):
"""Why the session ended, e.g. "receive timeout"."""
return self._session.close_cause if self._session else "closed"
# ── Read Schema ───────────────────────────────────────────── # ── Read Schema ─────────────────────────────────────────────
def read_schema(self): def read_schema(self):
@@ -667,13 +674,13 @@ class SoxClient:
# ── Invoke Action ─────────────────────────────────────────── # ── Invoke Action ───────────────────────────────────────────
def invoke(self, comp_id, slot_id, type_id=None, arg=None): def invoke(self, comp_id, slot_id, type_id=None, arg=None, timeout=30.0):
req = _prepare_request(ord('i')) req = _prepare_request(ord('i'))
req.write_u2(comp_id) req.write_u2(comp_id)
req.write_u1(slot_id) req.write_u1(slot_id)
if arg is not None and type_id is not None: if arg is not None and type_id is not None:
encode_value(req, type_id, arg) encode_value(req, type_id, arg)
res = self.request(req) res = self.request(req, timeout)
res.check_response('I') res.check_response('I')
# ── Add Component ─────────────────────────────────────────── # ── Add Component ───────────────────────────────────────────
@@ -989,19 +996,90 @@ class SoxClient:
progress(file_size, file_size) progress(file_size, file_size)
return bytes(data) return bytes(data)
def put_file(self, uri, data, progress=None):
"""Writes `data` to the device as `uri`.
Ported from FileTransfer.putFile, as Ontrol's BSoxFilePut does it: an 'f'
request opens the transfer, the chunks go out as 'k' messages -- the DASP send
window paces them -- and the device answers the last with an unsolicited 'z'
once the file is closed. progress(done_bytes, total_bytes) is called from this
thread.
"""
if self.is_closed:
raise SoxException("SoxClient closed")
with self._xfer_lock:
# 21, not FileTransfer's 18: room for Orion's message-length header too.
chunk_size = max(16, self._session.ideal_max - 21)
req = _prepare_request(ord('f'))
req.write_str("p")
req.write_str(uri)
req.write_i4(len(data))
req.write_u2(chunk_size)
req.write_u1(0) # end of headers
xfer = _FilePut()
self._xfer = xfer
opened = False
try:
res = self.request(req)
res.check_response('F')
opened = True
file_size = res.i4()
chunk_size = res.u2()
if file_size != len(data) or chunk_size <= 0:
raise SoxException(
f"The device opened {uri} for {file_size} bytes in chunks of "
f"{chunk_size}; {len(data)} bytes were offered")
num_chunks = max(1, -(-file_size // chunk_size))
for num in range(num_chunks):
if self.is_closed:
raise SoxException(f"Session closed while writing {uri}")
chunk = data[num * chunk_size:(num + 1) * chunk_size]
msg = _prepare_request(ord('k'), 0)
msg.write_u2(num)
msg.write_u2(len(chunk))
msg.write_bytes(chunk)
self._session.send(msg.to_bytes())
if progress:
progress(min(file_size, (num + 1) * chunk_size), file_size)
timeout = getattr(self._session, "receive_timeout", 30.0) or 30.0
deadline = time.monotonic() + timeout
while not xfer.closed.wait(0.5):
if self.is_closed:
raise SoxException(f"Session closed while writing {uri}")
if time.monotonic() > deadline:
raise SoxException(f"Timed out waiting for the device to close {uri}")
opened = False
finally:
self._xfer = None
# Left open, the transfer keeps the device answering "busy" to the next.
if opened and not self.is_closed:
try:
self.request(_prepare_request(ord('z')))
except Exception:
pass
def rename_file(self, from_uri, to_uri):
"""Renames a file on the device ('b'), as SoxClient.renameFile."""
req = _prepare_request(ord('b'))
req.write_str(from_uri)
req.write_str(to_uri)
self.request(req).check_response('B')
# ── Networking ────────────────────────────────────────────── # ── Networking ──────────────────────────────────────────────
def request(self, req): def request(self, req, timeout=30.0):
results = self.requests([req]) results = self.requests([req], timeout)
return results[0] return results[0]
def requests(self, reqs): def requests(self, reqs, timeout=30.0):
"""Sends the batch and waits for every reply.""" """Sends the batch and waits for every reply."""
if self.is_closed: if self.is_closed:
raise SoxException("SoxClient closed") raise SoxException("SoxClient closed")
if len(reqs) > 255: if len(reqs) > 255:
return self._chunk_requests(reqs) return self._chunk_requests(reqs, timeout)
with self._lock: with self._lock:
for i, req in enumerate(reqs): for i, req in enumerate(reqs):
@@ -1014,7 +1092,7 @@ class SoxClient:
for req in reqs: for req in reqs:
self._session.send(req.to_bytes()) self._session.send(req.to_bytes())
deadline = time.monotonic() + 30.0 deadline = time.monotonic() + timeout
while True: while True:
remaining = deadline - time.monotonic() remaining = deadline - time.monotonic()
if remaining <= 0: if remaining <= 0:
@@ -1036,11 +1114,11 @@ class SoxClient:
self._responses = None self._responses = None
raise SoxException("Request timeout") raise SoxException("Request timeout")
def _chunk_requests(self, reqs): def _chunk_requests(self, reqs, timeout=30.0):
results = [] results = []
for i in range(0, len(reqs), 255): for i in range(0, len(reqs), 255):
chunk = reqs[i:i + 255] chunk = reqs[i:i + 255]
results.extend(self.requests(chunk)) results.extend(self.requests(chunk, timeout))
return results return results
# called by receiver thread # called by receiver thread
@@ -1111,6 +1189,13 @@ class _FileGet:
# SoxReceiver — daemon thread # SoxReceiver — daemon thread
# ──────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────
class _FilePut:
"""The put in progress; `closed` is set when the device's 'z' arrives."""
def __init__(self):
self.closed = threading.Event()
class _SoxReceiver(threading.Thread): class _SoxReceiver(threading.Thread):
def __init__(self, client): def __init__(self, client):
super().__init__(name="SoxReceiver", daemon=True) super().__init__(name="SoxReceiver", daemon=True)
@@ -1136,10 +1221,12 @@ class _SoxReceiver(threading.Thread):
client._receive_event(msg) client._receive_event(msg)
elif cmd == ord('k'): elif cmd == ord('k'):
xfer = client._xfer xfer = client._xfer
if xfer is not None: if isinstance(xfer, _FileGet):
xfer.receive_chunk(msg) xfer.receive_chunk(msg)
elif cmd == ord('z'): elif cmd == ord('z'):
pass # close of a put -- the editor only gets files xfer = client._xfer # the device closing a put
if isinstance(xfer, _FilePut):
xfer.closed.set()
else: else:
client._receive_response(msg) client._receive_response(msg)
except DaspException: except DaspException:
+41
View File
@@ -378,3 +378,44 @@ class LiveRenameCommand(RenameComponentCommand):
raise LiveUndoError(f"{self.old_name} was not renamed to {self.new_name} " raise LiveUndoError(f"{self.old_name} was not renamed to {self.new_name} "
"on the device") "on the device")
super().redo() super().redo()
def apply_child_order(parent, ordered_children):
"""Puts `parent`'s <comp> children in the given order; its other children stay put."""
children = list(parent)
comp_slots = [i for i, child in enumerate(children) if child.tag == "comp"]
for slot, child in zip(comp_slots, ordered_children):
children[slot] = child
parent[:] = children
class ReorderChildrenCommand:
"""A new order for a component's children."""
def __init__(self, parent, old_order, new_order, label="reorder"):
self.parent = parent
self.old_order = list(old_order)
self.new_order = list(new_order)
self.label = label
def undo(self):
apply_child_order(self.parent, self.old_order)
def redo(self):
apply_child_order(self.parent, self.new_order)
class LiveReorderChildrenCommand(ReorderChildrenCommand):
"""A reorder on the device: the old order goes back there before the DOM takes it."""
def undo(self):
if not live_hooks.live_reorder_children(self.parent, self.old_order):
raise LiveUndoError(f"The old order of {self.parent.get('name', 'app')} "
"could not be put back on the device")
super().undo()
def redo(self):
if not live_hooks.live_reorder_children(self.parent, self.new_order):
raise LiveUndoError(f"{self.parent.get('name', 'app')} was not reordered "
"on the device")
super().redo()
+584 -80
View File
@@ -122,6 +122,7 @@ FIT_PADDING = 20
# Below this a grid line is closer to its neighbour than the line is wide, which reads # 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):
@@ -153,6 +154,36 @@ def suggest_box_width_for_grid(width, grid_size):
return proposed return proposed
def offsheet_input_comps(components):
"""The components with a link arriving from off this sheet, drawn as a knob on
their left edge."""
root = app_state.xml_root_element
links_elem = root.find("links") if root is not None else None
if links_elem is None:
return set()
comp_by_path = {get_component_path(comp, root): comp for comp in components}
found = set()
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
dst = comp_by_path.get(to_ref.rsplit(".", 1)[0])
if dst is not None and from_ref.rsplit(".", 1)[0] not in comp_by_path:
found.add(dst)
return found
def box_left_limits(tags, margin, step):
"""Left-most x each box on the open sheet may be moved to, keyed by canvas tag.
The margin, or one step further in for a box with an off-sheet input, so its knob
stays on the sheet rather than out in the grey margin.
"""
knobbed = offsheet_input_comps(list(app_state.canvas_comp_map.values()))
return {tag: margin + step if app_state.canvas_comp_map.get(tag) in knobbed
else margin for tag in tags}
def layout_margin(grid_size=None): def layout_margin(grid_size=None):
"""The top/left edge offset a box may not encroach on, snapped onto the grid.""" """The top/left edge offset a box may not encroach on, snapped onto the grid."""
if grid_size is None: if grid_size is None:
@@ -238,28 +269,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 +382,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 +492,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 +509,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 +637,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 +754,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 +984,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 +1003,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 +1264,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 +1289,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 +1301,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 +1342,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 +1403,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 +1412,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 +1475,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 +1573,364 @@ 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
if count and any(item.get("knob_in") for item in columns[column_keys[0]]):
# One step further in, so a knob on a first-column box's left edge stays on
# the sheet instead of out in the margin.
cursor_x = snap_up(margin + app_state.GRID_SIZE, scale)
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)}
knobbed = offsheet_input_comps(components)
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,
}) "knob_in": comp in knobbed,
# 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 +1938,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 +1987,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 +2015,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)