Compare commits

..
19 Commits
Author SHA1 Message Date
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
16 changed files with 2125 additions and 349 deletions
+266 -84
View File
@@ -26,7 +26,10 @@ import re
import zipfile
import app_state
import kit_build
import palette
import sab_app
import schema
import sox
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, ORION_PLATFORM_TYPE, RION_KIT
@@ -64,8 +67,16 @@ _PLATFORMS = (_ORION, _RION, _PION)
def platform_of(app):
kits = app.kit_names()
return next((p for p in _PLATFORMS if p.kit in kits), None)
return platform_of_kits(app.kit_names())
def platform_of_kits(kit_names):
kit_names = set(kit_names)
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():
@@ -278,7 +289,8 @@ def get_with_file(client, path, status=None):
# --- Put with Files ---------------------------------------------------------------------
#
# Follows Ontrol's Workbench tool (ontrolSedonaOx BPutWithFilesTool, ontrolSedonanet
# PutApp): the backup's app.sax is encoded against the device's kits; app.sab goes as
# 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.
@@ -302,25 +314,13 @@ def kits_fit(app_kits, device_kits):
return all(any(d == (n, _checksum(c)) for d in device) for n, c in app_kits)
def kit_differences(app_kits, device_kits):
"""Lines telling why an app's kits do not fit the device's."""
on_device = {n: _checksum(c) for n, c in device_kits}
lines = []
for name, cks in app_kits:
cks = _checksum(cks)
if name not in on_device:
lines.append(f"{name}: not on the device")
elif on_device[name] != cks:
lines.append(f"{name}: {on_device[name]:08x} on the device, {cks:08x} in the backup")
return lines or ["The same kits, in a different order"]
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_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
@@ -353,7 +353,7 @@ def device_target(ds):
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)
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)
@@ -450,8 +450,8 @@ class PutPlan:
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.reencoded = [] # kits app.sax was encoded against other builds of
self.kit_changes = [] # set when the backup's kits.scode has to go too
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
@@ -466,39 +466,176 @@ class PutPlan:
return not self.problems
def _label(platform):
return platform.name if platform else "no platform this editor knows"
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))
def _sax_kits(sax):
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
schema_elem = ET.fromstring(sax).find("schema")
return [] if schema_elem is None else [k.get("name") for k in schema_elem.findall("kit")]
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 plan_put(path, target):
"""Reads the zip at `path` and checks it against the device `target`."""
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)
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 {_label(platform)}; the device is "
f"{_label(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 ""
@@ -524,63 +661,108 @@ def plan_put(path, target):
return plan
def _plan_app(plan, z, names, target):
"""Settles app.sab, Ontrol's way first: the backup's app.sax encoded against the
device's kits. Failing that, the backup's own app.sab when the device's kits can
load it, or with the backup's kits.scode. Returns (decoded app, its portrait)."""
sax_name = "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_name, scode_name = "device/" + APP_SAB, "device/" + KITS_SCODE
device_cks = {n: f"{_checksum(c):08x}" for n, c in target.kits}
sax_problem = None
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:
sax = z.read(sax_name)
try:
missing = [k for k in _sax_kits(sax) if k not in device_cks]
if missing:
raise sab_app.SabError("the device lacks the kits " + ", ".join(missing))
sab = sab_app.encode_sax(sax, device_cks)
app = sab_app.SabApp(sab)
sab = sab_app.encode_sax(z.read(sax_name), kits=chosen)
except Exception as e:
sax_problem = f"{sax_name} cannot be encoded for this device: {e}"
else:
original = sab_app.SabApp(z.read(sab_name)) if sab_name in names else None
if original is not None:
plan.reencoded = [line for line in kit_differences(original.kit_parts,
target.kits)
if "on the device," in line]
plan.app_source = f"{sax_name}, encoded for the device's kits"
plan.app_files.append(PutFile(APP_SAB, sab, sax_name))
return app, _app_portrait(original or app)
if sab_name not in names:
plan.problems.append(sax_problem or "The zip holds no app: neither app.sax nor "
f"{sab_name}.")
return None, False
sab = z.read(sab_name)
try:
app = sab_app.SabApp(sab)
except Exception as e:
plan.problems += [p for p in (sax_problem,) if p]
plan.problems.append(f"{sab_name} cannot be read: {e}")
return None, False
if kits_fit(app.kit_parts, target.kits):
plan.app_source = sab_name
elif scode_name in names:
plan.kit_changes = kit_differences(app.kit_parts, target.kits)
plan.app_source = f"{sab_name}, with the backup's {KITS_SCODE}"
plan.app_files.append(PutFile(KITS_SCODE, z.read(scode_name), scode_name))
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:
plan.problems += [p for p in (sax_problem,) if p]
plan.problems.append(f"The device's kits cannot load {sab_name}, and the backup "
f"has no {scode_name}:")
plan.problems += [" " + line for line in kit_differences(app.kit_parts, target.kits)]
return None, False
if sax_problem:
plan.warnings.append(sax_problem)
plan.app_files.append(PutFile(APP_SAB, sab, sab_name))
return app, _app_portrait(app)
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):
+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 os
VERSION = "0.0.0.166"
VERSION = "0.0.0.182"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
@@ -41,6 +41,9 @@ status_label = None # the window's bottom status bar
# colour wherever the change was made.
DIRTY_TINT = "#ffe6e6"
CLEAN_TINT = "white"
# The wiresheet canvas around the sheet itself, clean and with unsaved changes.
OFF_SHEET_TINT = "#f3f3f3"
OFF_SHEET_DIRTY_TINT = "#f0dcdc"
# The file name on the tree's root row turns this colour while anything is unwritten,
# which is the one place a file name appears on screen -- a Tk window title cannot be
@@ -118,6 +121,8 @@ def load_preferences():
left_pane_width = DEFAULT_LEFT_PANE_WIDTH
sedona_home = ""
trace_dasp = False
lazy_loading = True
show_slot_numbers = False
if os.path.exists(PROPERTIES_FILE):
try:
@@ -130,6 +135,8 @@ def load_preferences():
)
sedona_home = config.get("Editor", "sedona.home", fallback="").strip()
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:
pass
else:
@@ -150,7 +157,8 @@ def load_preferences():
left_pane_width = max(
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():
@@ -170,6 +178,8 @@ def save_preferences():
"box_width": str(BOX_WIDTH),
"left_pane_width": str(LEFT_PANE_WIDTH),
"trace_dasp": str(TRACE_DASP),
"lazy_loading": str(LAZY_LOADING),
"show_slot_numbers": str(SHOW_SLOT_NUMBERS),
}
with open(PROPERTIES_FILE, "w") as configfile:
config.write(configfile)
@@ -232,8 +242,7 @@ def mark_dirty():
global is_dirty
if not is_dirty:
is_dirty = True
if canvas is not None:
canvas.config(bg=DIRTY_TINT)
paint_sheet_tint()
refresh_dirty_display()
@@ -241,11 +250,26 @@ def mark_clean():
"""Clears the unsaved-state marker after a successful save or a fresh load."""
global is_dirty
is_dirty = False
if canvas is not None:
canvas.config(bg=CLEAN_TINT)
paint_sheet_tint()
refresh_dirty_display()
def sheet_tints():
"""(sheet, off-sheet) colours for the wiresheet in the current saved/unsaved state."""
if is_dirty:
return DIRTY_TINT, OFF_SHEET_DIRTY_TINT
return CLEAN_TINT, OFF_SHEET_TINT
def paint_sheet_tint():
"""Recolours the sheet (the "sheet_bg" rectangle) and the canvas around it."""
if canvas is None:
return
sheet, off_sheet = sheet_tints()
canvas.config(bg=off_sheet)
canvas.itemconfig("sheet_bg", fill=sheet)
# ── Live device connection state ────────────────────────────────
live_mode = False
device_session = None # device_session.DeviceSession instance
@@ -259,5 +283,9 @@ def base_window_title_live():
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()
+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()
+80 -6
View File
@@ -92,13 +92,87 @@ def _trace(direction, msg, datagram=None):
import datetime
ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
name = _MSG_TYPE_NAMES.get(msg.msg_type, f"?{msg.msg_type}")
seq = f" seq={msg.seq_num}" if msg.seq_num >= 0 else ""
ack = f" ack={msg.ack}" if msg.ack >= 0 else ""
plen = len(msg.payload) if msg.payload else 0
hexdump = ""
arrow = {"S": ">> SEND", "R": "<< RECV"}.get(direction, direction)
payload = msg.payload or b""
size = f" ({len(datagram)} bytes)" if datagram else ""
lines = [f"[DASP] {ts} {arrow} {name:<12} {_trace_fields(msg)}{size}"]
if datagram:
hexdump = " [" + datagram.hex(" ") + "]"
_trace_out(f"[DASP] {ts} {direction} {name}{seq}{ack} payload={plen}{hexdump}")
hdr_len = len(datagram) - len(payload)
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
+111 -8
View File
@@ -31,6 +31,18 @@ def _slot_precision(type_name, slot_name):
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:
"""High-level session manager for a live Sedona device connection."""
@@ -38,6 +50,10 @@ class DeviceSession:
self.sox_client = None
self._event_queue = queue.Queue()
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 ────────────────────────────────────
@@ -53,18 +69,30 @@ class DeviceSession:
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:
children = self.load_children(root_comp.id)
if children:
self.sox_client.update(children, CONFIG | RUNTIME)
self.sox_client.update(children, self._values_to_read(children))
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:
return
try:
if self._current_subs:
if unsubscribe and self._current_subs:
comps = [self.sox_client.cache_get(cid)
for cid in self._current_subs]
comps = [c for c in comps if c is not None]
@@ -159,6 +187,74 @@ class DeviceSession:
if c is not None and c.kit_id == kit_id and c.type_id == type_id_val:
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 ────────────────────────────────────────────
def load_children(self, comp_id):
@@ -251,9 +347,9 @@ class DeviceSession:
all_link_remote_ids = set()
for child in children:
links = self.sox_client.read_links(child.id)
child.links = links
for lnk in links:
if child.id not in self._seeded:
child.links = self.sox_client.read_links(child.id)
for lnk in child.links:
all_link_remote_ids.add(lnk.from_comp_id)
all_link_remote_ids.add(lnk.to_comp_id)
@@ -265,7 +361,14 @@ class DeviceSession:
if remote_ids:
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)
to_subscribe = [folder_comp] + children if folder_comp else children
+3 -1
View File
@@ -5,9 +5,11 @@ add_timestamp = True
box_width = 250
left_pane_width = 230
trace_dasp = True
lazy_loading = True
show_slot_numbers = False
[Device]
host = 192.168.1.251
host = 192.168.0.26
port = 1876
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
visible before the button is released.
A right press that never moves still opens the component's context menu. A left press
A right press that never moves still opens the component's context menu. A left drag
that starts on bare sheet draws a selection rectangle, and selects the boxes wholly
inside it on release; with Ctrl held they join the current selection. A left press
anywhere on a box selects it, but only a press on its title bar moves it; on a slot row
it draws a link instead, as in Workbench.
"""
@@ -33,11 +35,15 @@ HOVER_COLOR = "#1f4e79"
DROP_OK_COLOR = "#2e9d3a"
DROP_BAD_COLOR = "#d62828"
RUBBER_COLOR = "#2f6fba"
MARQUEE_COLOR = "#2f6fba"
CTRL_MASK = 0x0004
_cursors = {} # role -> Tk cursor spec, built on first use
_press = None # pending right press: {"hit", "x", "y", "event"}
_drag = None # live link drag: {"hit", "anchor"}
_marquee = None # left press on bare sheet: {"x", "y", "cx", "cy", "state", "active"}
_menu_callback = None # opens the ordinary right-click menu for an event
_select_boxes = None # makes a list of comp tags the box selection
_link_dialog = None # opens the Link dialog: (source, target, src_slot, tgt_slot)
@@ -301,14 +307,16 @@ def _anchor(hit):
def clear():
"""Drops any hover ring, rubber band and drag state; called on every repaint."""
global _press, _drag
global _press, _drag, _marquee
canvas = app_state.canvas
if canvas is None:
return
canvas.delete("slot_hover")
canvas.delete("link_rubber")
canvas.delete("marquee")
_press = None
_drag = None
_marquee = None
_set_cursor(None)
@@ -442,6 +450,67 @@ def on_right_release(event):
_menu_callback(event)
# Left button on bare sheet: dragging pulls out a selection rectangle that selects every
# box wholly inside it on release (Ctrl adds them to the current selection).
def _on_bare_sheet(cx, cy):
"""True if no box or link lies under a canvas point."""
canvas = app_state.canvas
for item in canvas.find_overlapping(cx - 3, cy - 3, cx + 3, cy + 3):
tags = canvas.gettags(item)
if "component" in tags or "link" in tags:
return False
return True
def _marquee_motion(event):
"""Draws the selection rectangle once the pointer has moved far enough."""
global _marquee
if _marquee is None:
return
if not _marquee["active"]:
if abs(event.x - _marquee["x"]) < DRAG_THRESHOLD and \
abs(event.y - _marquee["y"]) < DRAG_THRESHOLD:
return
_marquee["active"] = True
app_state.canvas.delete("slot_hover")
canvas = app_state.canvas
canvas.delete("marquee")
canvas.create_rectangle(_marquee["cx"], _marquee["cy"],
canvas.canvasx(event.x), canvas.canvasy(event.y),
outline=MARQUEE_COLOR, width=1, dash=(4, 2),
tags=("marquee",))
canvas.tag_raise("marquee")
def _marquee_finish(event):
"""Ends a rectangle drag. Returns True if it selected, False if it was a click."""
global _marquee
marquee, _marquee = _marquee, None
canvas = app_state.canvas
canvas.delete("marquee")
if marquee is None or not marquee["active"]:
return False
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
left, right = sorted((marquee["cx"], cx))
top, bottom = sorted((marquee["cy"], cy))
inside = []
for comp_tag in app_state.canvas_comp_map:
box = wiresheet.get_box_bounds(comp_tag)
if box and left <= box[0] and top <= box[1] and box[2] <= right and box[3] <= bottom:
inside.append(comp_tag)
adding = (marquee["state"] | event.state) & CTRL_MASK
wiresheet.deselect_link() # links and boxes are never selected together
if adding:
current = [t for t in app_state.selected_canvas_tags if t not in inside]
_select_boxes(current + inside)
else:
_select_boxes(inside)
count = len(app_state.selected_canvas_tags)
app_state.set_status(f"{count} component{'s' if count != 1 else ''} selected.")
return True
# Left button: anywhere on a box selects it. Only the title bar moves it; a slot row
# drags out a link, and the rest of the box does nothing more than select.
@@ -451,6 +520,19 @@ def _on_title_bar(comp_tag, cy):
def on_left_press(event):
global _marquee
_marquee = None
canvas = app_state.canvas
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
if _on_bare_sheet(cx, cy):
app_state.drag_data["tag"] = None
if not event.state & CTRL_MASK:
# A plain click on bare sheet still clears the selection, drag or not.
wiresheet.deselect_link()
_select_boxes([])
_marquee = {"x": event.x, "y": event.y, "cx": cx, "cy": cy,
"state": event.state, "active": False}
return
_box_handlers["press"](event) # selection, and arms a box move
if _start_press(event) is not None:
app_state.canvas.delete("slot_hover")
@@ -460,30 +542,37 @@ def on_left_press(event):
def on_left_motion(event):
if _press is not None or _drag is not None:
if _marquee is not None:
_marquee_motion(event)
elif _press is not None or _drag is not None:
_drag_motion(event)
else:
_box_handlers["motion"](event)
def on_left_release(event):
if _finish(event) is None:
if _marquee is not None:
_marquee_finish(event)
elif _finish(event) in (None, "click"):
_box_handlers["release"](event)
_box_handlers = {}
def install(canvas, menu_callback, box_press, box_motion, box_release, link_dialog):
def install(canvas, menu_callback, box_press, box_motion, box_release, link_dialog,
select_boxes):
"""Binds hover, link dragging on both buttons, and the box handlers it wraps.
menu_callback opens the context menu; box_* are the sheet's own left-button
select-and-move handlers, which still run whenever a press is not on a slot row;
link_dialog opens the Link dialog when a link is dropped on a title bar.
link_dialog opens the Link dialog when a link is dropped on a title bar;
select_boxes makes a list of comp tags the selection after a rectangle drag.
"""
global _menu_callback, _link_dialog
global _menu_callback, _link_dialog, _select_boxes
_menu_callback = menu_callback
_link_dialog = link_dialog
_select_boxes = select_boxes
_box_handlers.update(press=box_press, motion=box_motion, release=box_release)
canvas.bind("<Motion>", on_motion)
canvas.bind("<Leave>", on_leave, add="+")
+309 -128
View File
@@ -17,6 +17,7 @@ import live_hooks
import manage_kits
import palette
import properties
import put_kits_dialog
import sax_file
import schema
import sox
@@ -616,27 +617,35 @@ def _invoke_app_action(slot_name):
if slot is None:
app_state.set_status(f"Action slot '{slot_name}' not found on app", warn=True)
return False
goes_down = slot_name in ("hibernate", "quit", "restart", "reboot")
goes_down = slot_name in _GOES_DOWN
try:
ds.sox_client.invoke(0, slot["id"])
app_state.set_status(f"Invoked '{slot_name}' on device")
# A device going down often does so before it answers: give the request a few
# seconds (time for DASP to resend it once), then disconnect either way rather
# than wait for the session to drop.
ds.sox_client.invoke(0, slot["id"], timeout=_GOES_DOWN_WAIT if goes_down else 30.0)
except sox.SoxException as e:
# A P-ION restarts before it answers: no reply is what a restart looks like.
if not (goes_down and "timeout" in str(e).lower()):
refused = "timeout" not in str(e).lower() and "closed" not in str(e).lower()
if not goes_down or refused:
app_state.set_status(f"Invoke '{slot_name}' failed: {e}", warn=True)
return False
app_state.set_status(f"Invoked '{slot_name}'; the device went down without replying")
except Exception as e:
app_state.set_status(f"Invoke '{slot_name}' failed: {e}", warn=True)
return False
if slot_name == "save":
app_state.mark_clean()
app_state.set_status(f"Invoked '{slot_name}' on device")
elif goes_down:
do_device_disconnect()
host = app_state.device_host
do_device_disconnect(device_down=True)
app_state.set_status(f"Sent '{slot_name}' to {host} and disconnected")
else:
app_state.set_status(f"Invoked '{slot_name}' on device")
return True
_APP_ACTIONS = ["save", "hibernate", "quit", "restart", "reboot"]
_GOES_DOWN = ("hibernate", "quit", "restart", "reboot") # the device stops answering
_GOES_DOWN_WAIT = 3.0 # seconds to wait for its reply before disconnecting
def do_get_with_file():
@@ -781,10 +790,11 @@ def _report_backup(result):
def do_put_with_files():
"""App > Put with Files: puts a Get with Files zip back onto the connected device.
The zip is checked against the device first: the same platform, and an app the
device's kits can run -- the backup's app.sax encoded for them, as Ontrol's tool
does, or its app.sab. The app goes staged, so the device takes it up when
restarted, which is offered once the files are in.
As Ontrol's tool does: the kits the app needs are listed to choose builds for (the
device's by default), the backup's app.sax is encoded for them, and kits.scode is
rebuilt with sedonac when they are not the device's. The zip is checked against the
device on the way. The app goes staged, so the device takes it up when restarted,
which is offered once the files are in.
"""
ds = app_state.device_session
if not app_state.live_mode or ds is None or not ds.is_connected:
@@ -808,19 +818,51 @@ def do_put_with_files():
except Exception as e:
messagebox.showerror("Put with Files", f"Could not read the device:{NL}{NL}{e}")
return
plan = app_backup.plan_put(path, target)
if not plan.ok:
app_state.set_status(f"{os.path.basename(path)} does not match the device", warn=True)
try:
rows = app_backup.kit_rows(path, target)
except Exception as e:
messagebox.showerror("Put with Files", f"{os.path.basename(path)}:{NL}{NL}{e}")
return
backup_platform = app_backup.platform_of_kits(r.name for r in rows if r.backup or r.used)
if backup_platform is None or backup_platform is not target.platform:
messagebox.showerror(
"Put with Files",
f"{os.path.basename(path)} cannot be put on this device:{NL}{NL}" +
NL.join(plan.problems))
f"{os.path.basename(path)} cannot be put on this device:{NL}{NL}"
f"The backup is from {app_backup.platform_label(backup_platform)}; the device "
f"is {app_backup.platform_label(target.platform)}.")
return
if not messagebox.askokcancel("Put with Files", _describe_put(plan),
icon=messagebox.WARNING, default=messagebox.CANCEL):
rebuild = put_kits_dialog.ask(app_state.window, rows)
if rebuild is None:
return
def finish(_result, error):
def planned(plan, error):
if error is not None:
messagebox.showerror("Put with Files", f"Preparing the put failed:{NL}{NL}{error}")
return
if not plan.ok:
app_state.set_status(f"{os.path.basename(path)} cannot be put on the device",
warn=True)
messagebox.showerror(
"Put with Files",
f"{os.path.basename(path)} cannot be put on this device:{NL}{NL}" +
NL.join(plan.problems))
return
if messagebox.askokcancel("Put with Files", _describe_put(plan),
icon=messagebox.WARNING, default=messagebox.CANCEL):
_put(plan)
def prepare(status):
status("Preparing the app" + (" and building kits.scode" if rebuild or any(
map(app_backup.kit_change, rows)) else "") + " ...", None)
return app_backup.plan_put(path, target, rows, rebuild)
def _put(plan):
_run_with_progress("Put with Files",
lambda status: app_backup.put_with_files(ds.sox_client, plan,
target, status),
lambda result, error: finish(plan, result, error))
def finish(plan, _result, error):
if error is not None:
app_state.set_status(f"Put with Files failed: {error}", warn=True)
messagebox.showerror(
@@ -837,12 +879,10 @@ def do_put_with_files():
f"{len(plan.app_files) + len(plan.media)} files written to the device."
f"{NL}{NL}Restart the device now to complete provisioning?"):
if _invoke_app_action("restart"):
app_state.set_status(f"Put {os.path.basename(path)}; the device is restarting")
app_state.set_status(f"Put {os.path.basename(path)}; the device is "
"restarting, disconnected")
_run_with_progress("Put with Files",
lambda status: app_backup.put_with_files(ds.sox_client, plan, target,
status),
finish)
_run_with_progress("Put with Files", prepare, planned)
def _describe_put(plan):
@@ -854,12 +894,9 @@ def _describe_put(plan):
(" Its fonts and images are deleted and replaced by the backup's."
if plan.has_screen else ""), "",
f" app.sab, from {plan.app_source}"]
lines += [f" {line}" for line in plan.reencoded]
if plan.kit_changes:
lines += [" kits.scode, since the device's kits cannot load the app:"]
if plan.scode_source:
lines.append(f" kits.scode, {plan.scode_source}")
lines += [f" {line}" for line in plan.kit_changes]
lines += [" The device's firmware must support these kits, or the app will not "
"start."]
if not plan.has_screen:
return NL.join(lines + ([""] + plan.warnings if plan.warnings else []))
lines.append(f" {images} images" +
@@ -896,7 +933,7 @@ def do_tidy_layout(event=None):
multiplier = app_state.meta_scale // app_state.GRID_SIZE
if moved == 0:
messagebox.showinfo("Tidy Layout", "Nothing to move — no boxes overlap.")
messagebox.showinfo("Tidy Layout", "Nothing to move — the sheet is already tidy.")
elif clamped:
messagebox.showwarning(
"Tidy Layout",
@@ -954,14 +991,8 @@ def zoom_to(level, anchor=None):
if after == before:
return
# Scroll the repainted sheet back under the held point. The scrollregion starts at
# the origin, so its far corner is the span the view fractions are measured against.
region = str(canvas.cget("scrollregion")).split()
if len(region) == 4:
span_x = float(region[2]) or 1.0
span_y = float(region[3]) or 1.0
canvas.xview_moveto(max(0.0, (sheet_x * after - anchor[0]) / span_x))
canvas.yview_moveto(max(0.0, (sheet_y * after - anchor[1]) / span_y))
# Scroll the repainted sheet back under the held point.
wiresheet.scroll_view_to(sheet_x * after - anchor[0], sheet_y * after - anchor[1])
def do_zoom_in(event=None):
@@ -1017,12 +1048,8 @@ def do_zoom_fit(event=None):
applied = wiresheet.set_zoom(level)
update_zoom_label()
region = str(canvas.cget("scrollregion")).split()
if len(region) == 4:
span_x = float(region[2]) or 1.0
span_y = float(region[3]) or 1.0
canvas.xview_moveto(max(0.0, (bounds[0] * applied - wiresheet.FIT_PADDING) / span_x))
canvas.yview_moveto(max(0.0, (bounds[1] * applied - wiresheet.FIT_PADDING) / span_y))
wiresheet.scroll_view_to(bounds[0] * applied - wiresheet.FIT_PADDING,
bounds[1] * applied - wiresheet.FIT_PADDING)
return "break"
@@ -1219,16 +1246,11 @@ def scroll_canvas_to(tag):
"""Scrolls the sheet so the tagged items sit in the middle of the view."""
canvas = app_state.canvas
box = canvas.bbox(tag)
region = str(canvas.cget("scrollregion")).split()
if box is None or len(region) != 4:
if box is None:
return
span_x = float(region[2]) or 1.0
span_y = float(region[3]) or 1.0
middle_x = (box[0] + box[2]) / 2 - canvas.winfo_width() / 2
middle_y = (box[1] + box[3]) / 2 - canvas.winfo_height() / 2
canvas.xview_moveto(max(0.0, middle_x / span_x))
canvas.yview_moveto(max(0.0, middle_y / span_y))
wiresheet.scroll_view_to((box[0] + box[2]) / 2 - canvas.winfo_width() / 2,
(box[1] + box[3]) / 2 - canvas.winfo_height() / 2)
def select_link_element(link_elem):
@@ -1322,6 +1344,26 @@ def select_link_on_open_sheet(link_elem):
app_state.set_status("That link is not painted on this sheet.", warn=True)
def select_all_boxes(event=None):
"""Ctrl+A: selects every box on the open wiresheet."""
# Bound application-wide like Del, so a text field keeps its own select-all.
if editor_panel_showing():
return None
focused = app_state.window.focus_get()
if focused is not None:
if focused.winfo_toplevel() is not app_state.window:
return None
if focused.winfo_class() in TEXT_ENTRY_CLASSES:
return None
wiresheet.deselect_link() # links and boxes are never selected together
select_canvas_boxes(list(app_state.canvas_comp_map))
# Hand the sheet the keyboard, so the arrows move the boxes rather than the tree.
app_state.canvas.focus_set()
count = len(app_state.selected_canvas_tags)
app_state.set_status(f"{count} component{'s' if count != 1 else ''} selected.")
return "break"
def delete_selected_component(event=None):
"""Deletes the highlighted canvas component (or selected link) and all its descendants recursively."""
# Del is bound application-wide, so it also reaches a Properties field being typed in
@@ -1510,7 +1552,16 @@ def on_canvas_click(event):
_toggle_canvas_box(group_tag)
return
select_canvas_boxes([])
# Pressing one of several selected boxes keeps them all selected, so a drag moves
# them together; a press that never moves narrows the selection to it on release.
keep_group = (group_tag and not wire_tag and group_tag in app_state.selected_canvas_tags
and len(app_state.selected_canvas_tags) > 1)
app_state.drag_data["group"] = []
app_state.drag_data["moved"] = False
app_state.drag_data["collapse_to"] = group_tag if keep_group else None
if not keep_group:
select_canvas_boxes([])
if wire_tag:
wiresheet.select_link(wire_tag)
return
@@ -1519,20 +1570,26 @@ def on_canvas_click(event):
app_state.drag_data["tag"] = group_tag
app_state.drag_data["x"] = cx
app_state.drag_data["y"] = cy
select_canvas_boxes([group_tag])
if keep_group:
# Del and the menus still act on the box pressed last.
app_state.selected_canvas_tag = group_tag
else:
select_canvas_boxes([group_tag])
# Where the box starts out. The drag is measured as an offset from this pair
# Where each moving box starts out. The drag is measured as an offset from these
# rather than accumulated per motion event, so repeated snapping cannot drift.
origin = wiresheet.get_box_origin(group_tag)
app_state.drag_data["box_x"] = origin[0] if origin else None
app_state.drag_data["box_y"] = origin[1] if origin else None
# Snapshot the pre-drag meta so the move can be pushed as one undoable step.
dragged = app_state.canvas_comp_map.get(group_tag)
meta_prop = dragged.find("prop[@name='meta']") if dragged is not None else None
app_state.drag_data["meta_before"] = meta_prop.get("val") if meta_prop is not None else None
# Bring the clicked box clear of any neighbour it happens to sit under.
# Each entry also snapshots the pre-drag meta, so the move is one undoable step.
moving = list(app_state.selected_canvas_tags) if keep_group else [group_tag]
for tag in moving:
origin = wiresheet.get_box_origin(tag)
comp = app_state.canvas_comp_map.get(tag)
meta_prop = comp.find("prop[@name='meta']") if comp is not None else None
app_state.drag_data["group"].append({
"tag": tag, "origin": origin,
"meta_before": meta_prop.get("val") if meta_prop is not None else None,
})
# Bring the moving boxes clear of any neighbour they happen to sit under.
canvas.tag_raise(tag)
canvas.tag_raise(group_tag)
wiresheet.redraw_links()
@@ -2049,18 +2106,17 @@ def on_canvas_drag(event):
return
canvas = app_state.canvas
group_tag = app_state.drag_data["tag"]
cx = canvas.canvasx(event.x)
cy = canvas.canvasy(event.y)
group = app_state.drag_data.get("group") or []
box_x = app_state.drag_data.get("box_x")
origin = wiresheet.get_box_origin(group_tag)
if box_x is None or origin is None:
# Nothing to align against, so the box just follows the pointer one to one.
canvas.move(group_tag, cx - app_state.drag_data["x"], cy - app_state.drag_data["y"])
if not group or any(entry["origin"] is None for entry in group):
# Nothing to align against, so the boxes just follow the pointer one to one.
for tag in [entry["tag"] for entry in group] or [app_state.drag_data["tag"]]:
canvas.move(tag, cx - app_state.drag_data["x"], cy - app_state.drag_data["y"])
app_state.drag_data["x"] = cx
app_state.drag_data["y"] = cy
app_state.drag_data["moved"] = True
wiresheet.redraw_links()
return
@@ -2069,39 +2125,72 @@ def on_canvas_drag(event):
# Canvas coordinates carry the sheet's zoom, so the step and the margin do too.
step = app_state.meta_scale * app_state.zoom
margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom
# A box's corner also stays on the sheet, the furthest meta can store it.
limit = wiresheet.META_COORD_MAX * step
# Follow the pointer, but land the box on a step every step of the way, and keep it
# out of the margin strip along the top and left canvas edges.
wanted_x = box_x + (cx - app_state.drag_data["x"])
wanted_y = app_state.drag_data["box_y"] + (cy - app_state.drag_data["y"])
target_x = max(margin, round(wanted_x / step) * step)
target_y = max(margin, round(wanted_y / step) * step)
# Follow the pointer, but keep every moving box out of the margin strip along the
# top and left canvas edges and on the sheet -- the whole group stops together, so
# the boxes keep their places relative to one another.
dx = cx - app_state.drag_data["x"]
dy = cy - app_state.drag_data["y"]
xs = [entry["origin"][0] for entry in group]
ys = [entry["origin"][1] for entry in group]
# A box with an off-sheet input stops a step further in, keeping its knob on the sheet.
left = wiresheet.box_left_limits([entry["tag"] for entry in group], margin, step)
dx = min(limit - max(xs),
max(max(left[e["tag"]] - e["origin"][0] for e in group), dx))
dy = min(limit - max(ys), max(margin - min(ys), dy))
shift_x = target_x - origin[0]
shift_y = target_y - origin[1]
shifted = False
for entry in group:
# Land each box on a step every step of the way.
start_x, start_y = entry["origin"]
target_x = min(limit, max(left[entry["tag"]], round((start_x + dx) / step) * step))
target_y = min(limit, max(margin, round((start_y + dy) / step) * step))
origin = wiresheet.get_box_origin(entry["tag"])
if origin is None:
continue
shift_x = target_x - origin[0]
shift_y = target_y - origin[1]
if shift_x or shift_y:
canvas.move(entry["tag"], shift_x, shift_y)
shifted = True
if shift_x or shift_y:
canvas.move(group_tag, shift_x, shift_y)
# Wires are not part of the dragged group, so they are re-routed against the
if shifted:
app_state.drag_data["moved"] = True
# Wires are not part of the dragged groups, so they are re-routed against the
# new positions.
wiresheet.redraw_links()
def on_canvas_release(event):
collapse_to = app_state.drag_data.pop("collapse_to", None)
if collapse_to and not app_state.drag_data.get("moved"):
# A click that never moved on one of several selected boxes picks it alone.
select_canvas_boxes([collapse_to])
if not app_state.drag_data["tag"]:
return
canvas = app_state.canvas
step = app_state.meta_scale * app_state.zoom
group_tag = app_state.drag_data["tag"]
origin = wiresheet.get_box_origin(group_tag)
margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom
limit = wiresheet.META_COORD_MAX * step
group = app_state.drag_data.get("group") or [
{"tag": app_state.drag_data["tag"], "meta_before": None}]
if origin is not None:
changes = []
failed = []
left = wiresheet.box_left_limits([entry["tag"] for entry in group], margin, step)
for entry in group:
group_tag = entry["tag"]
origin = wiresheet.get_box_origin(group_tag)
if origin is None:
continue
current_x, current_y = origin
margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom
snapped_x = max(margin, round(current_x / step) * step)
snapped_y = max(margin, round(current_y / step) * step)
snapped_x = min(limit, max(left[group_tag], round(current_x / step) * step))
snapped_y = min(limit, max(margin, round(current_y / step) * step))
shift_x = snapped_x - current_x
shift_y = snapped_y - current_y
@@ -2114,43 +2203,93 @@ def on_canvas_release(event):
raw_y = min(wiresheet.META_COORD_MAX, int(round(snapped_y / step)))
comp_element = app_state.canvas_comp_map.get(group_tag)
if comp_element is not None:
meta_str = None
orig_prop = comp_element.find("prop[@name='meta']")
if orig_prop is not None:
meta_str = orig_prop.get("val")
if comp_element is None:
continue
meta_str = None
orig_prop = comp_element.find("prop[@name='meta']")
if orig_prop is not None:
meta_str = orig_prop.get("val")
orig_x, orig_y, _, _, _, _, _, _ = read_meta_property(meta_str)
if orig_x != raw_x or orig_y != raw_y:
meta_before = app_state.drag_data.get("meta_before", meta_str)
update_meta_property(comp_element, meta_str, raw_x, raw_y)
if app_state.live_mode:
new_meta = comp_element.find("prop[@name='meta']")
if new_meta is None or not live_hooks.live_set_prop_value(
comp_element, "meta", new_meta.get("val", "0")):
# Back where the device still has it, so the sheet tells the truth.
if meta_str is None:
remove_prop(comp_element, "meta")
else:
set_prop_value(comp_element, "meta", meta_str)
app_state.drag_data["tag"] = None
wiresheet.render_current_sheet()
live_hooks.warn_failure(
"Move", f"{comp_element.get('name', 'The component')} was not "
"moved on the device")
return
command_class = (undo.LiveMetaMoveCommand if app_state.live_mode
else undo.MetaMoveCommand)
undo.push(command_class(
[(comp_element, meta_before, raw_x, raw_y)],
label=f"move {comp_element.get('name', 'component')}"
))
app_state.mark_dirty()
orig_x, orig_y, _, _, _, _, _, _ = read_meta_property(meta_str)
if orig_x == raw_x and orig_y == raw_y:
continue
meta_before = entry.get("meta_before") or meta_str
update_meta_property(comp_element, meta_str, raw_x, raw_y)
if app_state.live_mode:
new_meta = comp_element.find("prop[@name='meta']")
if new_meta is None or not live_hooks.live_set_prop_value(
comp_element, "meta", new_meta.get("val", "0")):
# Back where the device still has it, so the sheet tells the truth.
if meta_str is None:
remove_prop(comp_element, "meta")
else:
set_prop_value(comp_element, "meta", meta_str)
failed.append(comp_element.get("name", "?"))
continue
changes.append((comp_element, meta_before, raw_x, raw_y))
app_state.drag_data["tag"] = None
app_state.drag_data["group"] = []
if changes:
command_class = (undo.LiveMetaMoveCommand if app_state.live_mode
else undo.MetaMoveCommand)
label = (f"move {changes[0][0].get('name', 'component')}" if len(changes) == 1
else f"move {len(changes)} components")
undo.push(command_class(changes, label=label))
app_state.mark_dirty()
if failed:
wiresheet.render_current_sheet()
live_hooks.warn_failure("Move", f"{', '.join(failed)} not moved on the device")
return
wiresheet.redraw_links()
def nudge_selected_boxes(dx, dy):
"""Arrow keys: moves every selected box one meta step, as one undoable move."""
canvas = app_state.canvas
tags = [t for t in app_state.selected_canvas_tags if t in app_state.canvas_comp_map]
if not tags or app_state.drag_data.get("tag"):
return "break"
step = app_state.meta_scale * app_state.zoom
margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom
limit = wiresheet.META_COORD_MAX * step
origins = {t: wiresheet.get_box_origin(t) for t in tags}
if any(o is None for o in origins.values()):
return "break"
# The group stops as one at the margin strip and the sheet's far edge; a box with an
# off-sheet input stops a step further in, keeping its knob on the sheet.
left = wiresheet.box_left_limits(tags, margin, step)
shift_x = min(limit - max(o[0] for o in origins.values()),
max(max(left[t] - o[0] for t, o in origins.items()), dx * step))
shift_y = min(limit - max(o[1] for o in origins.values()),
max(margin - min(o[1] for o in origins.values()), dy * step))
if not shift_x and not shift_y:
return "break"
# Moved on the canvas, then written back exactly as a finished mouse drag is.
group = []
for tag in tags:
comp = app_state.canvas_comp_map[tag]
meta_prop = comp.find("prop[@name='meta']")
group.append({"tag": tag, "origin": origins[tag],
"meta_before": meta_prop.get("val") if meta_prop is not None else None})
canvas.move(tag, shift_x, shift_y)
app_state.drag_data.update(tag=tags[0], group=group, moved=True, collapse_to=None)
pressed_last = app_state.selected_canvas_tag
on_canvas_release(None)
# The same boxes stay selected, and the sheet keeps the keyboard, so the next
# arrow press moves them again.
still_here = [t for t in tags if t in app_state.canvas_comp_map and t != pressed_last]
if pressed_last in app_state.canvas_comp_map:
still_here.append(pressed_last)
select_canvas_boxes(still_here)
canvas.focus_set()
return "break"
def fit_tree_column(tree):
"""Sizes a Treeview's tree column to its widest visible row.
@@ -2662,6 +2801,29 @@ def open_preferences_dialog():
)
chk_trace_dasp.grid(row=5, column=1, sticky="w", pady=(0, 15))
lbl_lazy = ttk.Label(frame_padded, text="Lazy loading on connect?", anchor="w")
lbl_lazy.grid(row=6, column=0, sticky="w", pady=(0, 15), padx=(0, 10))
# Off: the whole app is read from app.sab when connecting. Takes effect on the next.
var_lazy_loading = tk.BooleanVar(value=app_state.LAZY_LOADING)
chk_lazy_loading = ttk.Checkbutton(
frame_padded,
text="Yes",
variable=var_lazy_loading
)
chk_lazy_loading.grid(row=6, column=1, sticky="w", pady=(0, 15))
lbl_slot_numbers = ttk.Label(frame_padded, text="Slot numbers on wiresheet?", anchor="w")
lbl_slot_numbers.grid(row=7, column=0, sticky="w", pady=(0, 15), padx=(0, 10))
var_slot_numbers = tk.BooleanVar(value=app_state.SHOW_SLOT_NUMBERS)
chk_slot_numbers = ttk.Checkbutton(
frame_padded,
text="Yes",
variable=var_slot_numbers
)
chk_slot_numbers.grid(row=7, column=1, sticky="w", pady=(0, 15))
def apply_preferences():
try:
val = int(entry_grid.get().strip())
@@ -2718,6 +2880,8 @@ def open_preferences_dialog():
app_state.GRID_SIZE = val
app_state.ADD_TIMESTAMP = var_timestamp.get()
app_state.TRACE_DASP = var_trace_dasp.get()
app_state.LAZY_LOADING = var_lazy_loading.get()
app_state.SHOW_SLOT_NUMBERS = var_slot_numbers.get()
app_state.SEDONA_HOME = entry_home.get().strip()
app_state.save_preferences()
@@ -2736,7 +2900,7 @@ def open_preferences_dialog():
messagebox.showerror("Invalid Input", message, parent=dialog)
btn_save_pref = ttk.Button(frame_padded, text="Apply Changes", command=apply_preferences)
btn_save_pref.grid(row=6, column=1, sticky="e")
btn_save_pref.grid(row=8, column=1, sticky="e")
if not app_state.SEDONA_HOME:
entry_home.focus_set()
@@ -2916,7 +3080,8 @@ def _on_connected(ds, root_comp):
menu_device.entryconfig("Download to SAX...", state="normal")
menu_app_info.entryconfig("Get with Files...", state="normal")
menu_app_info.entryconfig("Put with Files...", state="normal")
app_state.set_status(f"Connected to {app_state.device_host}:{app_state.device_port}")
app_state.set_status(f"Connected to {app_state.device_host}:{app_state.device_port}"
f" ({ds.seed_info})")
sax_root = ds.build_dom_tree(root_comp)
app_state.xml_root_element = sax_root
@@ -2959,8 +3124,9 @@ def _load_live_schema(ds):
f"Missing kit manifests: {', '.join(missing)}", warn=True)
def do_device_disconnect():
"""Disconnects from the live device."""
def do_device_disconnect(device_down=False):
"""Disconnects from the live device. device_down: it was just told to restart (or
stop), so nothing is asked of it on the way out -- it would not answer."""
global _cov_poll_id
if _cov_poll_id is not None:
@@ -2969,7 +3135,7 @@ def do_device_disconnect():
ds = app_state.device_session
if ds is not None:
ds.disconnect()
ds.disconnect(unsubscribe=not device_down)
app_state.device_session = None
app_state.live_mode = False
@@ -3059,6 +3225,14 @@ def _live_tree_expand(comp_id, tree_item_id, select=True, on_loaded=None):
if ds is None or not ds.is_connected:
return
# Every child already in the tree (app.sab gave the whole app, or it was opened
# before): nothing to load, and rebuilding the DOM would only cost the undo steps.
elem = app_state.tree_element_map.get(tree_item_id)
if elem is not None and all(c.get("name") != "..." for c in elem.findall("comp")):
if on_loaded is not None:
on_loaded(tree_item_id)
return
app_state.set_status("Loading children...")
def _bg():
@@ -3189,6 +3363,8 @@ SHORTCUTS = [
("9", do_zoom_fit),
("KP_9", do_zoom_fit),
("p", wiresheet.print_sheet),
("a", select_all_boxes),
("A", select_all_boxes),
]
for key, handler in SHORTCUTS:
window.bind_all(f"<Command-{key}>", handler)
@@ -3683,11 +3859,11 @@ canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X)
canvas = tk.Canvas(
frame_editor,
bg="white",
bg=app_state.OFF_SHEET_TINT,
highlightthickness=0,
xscrollcommand=canvas_hscroll.set,
yscrollcommand=canvas_vscroll.set,
scrollregion=(0, 0, 2000, 2000),
scrollregion=(0, 0, *wiresheet.sheet_extent()),
)
app_state.canvas = canvas
canvas.pack(fill=tk.BOTH, expand=True)
@@ -3701,8 +3877,13 @@ canvas.bind("<Configure>", lambda e: wiresheet.draw_grid())
# selects and moves boxes and a plain right click is the menu, as before.
link_drag.install(canvas, on_canvas_right_click,
on_canvas_click, on_canvas_drag, on_canvas_release,
link_dialog=open_link_dialog)
link_dialog=open_link_dialog, select_boxes=select_canvas_boxes)
canvas.bind("<ButtonPress-2>", on_canvas_right_click)
# A click gives the sheet the keyboard, so the arrows nudge its selected boxes rather
# than walking whatever tree or list last had focus.
canvas.bind("<ButtonPress-1>", lambda e: canvas.focus_set(), add="+")
for _key, _dx, _dy in (("Left", -1, 0), ("Right", 1, 0), ("Up", 0, -1), ("Down", 0, 1)):
canvas.bind(f"<{_key}>", lambda e, dx=_dx, dy=_dy: nudge_selected_boxes(dx, dy))
canvas.bind("<Double-1>", on_canvas_double_click)
# Wheel zoom. Windows and macOS report a delta; X11 sends button 4 and 5 instead.
+16 -7
View File
@@ -67,7 +67,12 @@ def kit_versions(kit_name):
for entry in os.listdir(kit_dir):
match = pattern.match(entry)
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
return versions
@@ -269,7 +274,7 @@ def _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.
*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
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()
if not os.path.isdir(db):
return None, None
@@ -335,8 +342,9 @@ def _platform_native_kits():
return (matching if found else None), all_platforms
def unsupported_kits():
"""Kit names that cannot work with the open file's platform.
def unsupported_kits(sax_names=None):
"""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:
- 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.
"""
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:
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:
_unsupported_cache[sax_names] = 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
+12 -6
View File
@@ -80,6 +80,9 @@ def _resolve_slots(qname, types_by_q, cache):
"""The type's slots, base chain first, indexed by slot id."""
if qname in cache:
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]
base = t.get("base")
out = list(_resolve_slots(base, types_by_q, cache)) if base else []
@@ -505,21 +508,24 @@ def _encode_value(out, slot, raw):
raise SabError(f"unhandled slot type {vtype}")
def encode_sax(sax, checksums=None):
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. Every
kit's manifest must be in Sedona home under the checksum used.
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 k in schema_elem.findall("kit"):
name = k.get("name")
cks = (checksums or {}).get(name) or k.get("checksum")
for name, cks in kits:
if not cks:
raise SabError(f"no checksum for kit '{name}'")
parts.append((name, f"{int(cks, 16):08x}"))
+2 -2
View File
@@ -180,7 +180,7 @@ def reset_to_startup_state():
if app_state.canvas is not None:
app_state.canvas.delete("all")
wiresheet.draw_grid()
wiresheet.fit_sheet_region()
if app_state.window is not None:
app_state.window.title(app_state.base_window_title())
@@ -223,7 +223,7 @@ def open_file():
for item in tree.get_children():
tree.delete(item)
canvas.delete("all")
wiresheet.draw_grid()
wiresheet.fit_sheet_region()
xml_tree = ET.parse(file_path)
app_state.xml_root_element = xml_tree.getroot()
+5 -3
View File
@@ -353,9 +353,11 @@ def get_slots_for_type(type_str, linked_slots=()):
if "c" in slot_data["flags"]:
continue
# Format to double digits (e.g., 00, 01, 02)
padded_id = f"{slot_data['slot_id']:02d}"
display_label = f"{padded_id}. {slot_name}"
# Format to double digits (e.g., 00, 01, 02), unless turned off in Preferences
if app_state.SHOW_SLOT_NUMBERS:
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
slots_list.append((slot_name, display_label))
+12 -10
View File
@@ -134,7 +134,9 @@ class SoxMsg:
def write_i4(self, v):
self._grow(4)
struct.pack_into(">i", self._buf, self._pos, v)
# Wrapped to signed: meta packs x into the top byte, so x >= 128 reads as an
# unsigned 32-bit value although the device stores the same bits as sys::int.
struct.pack_into(">i", self._buf, self._pos, ((v + 2**31) % 2**32) - 2**31)
self._pos += 4
def write_i8(self, v):
@@ -667,13 +669,13 @@ class SoxClient:
# ── 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.write_u2(comp_id)
req.write_u1(slot_id)
if arg is not None and type_id is not None:
encode_value(req, type_id, arg)
res = self.request(req)
res = self.request(req, timeout)
res.check_response('I')
# ── Add Component ───────────────────────────────────────────
@@ -1062,17 +1064,17 @@ class SoxClient:
# ── Networking ──────────────────────────────────────────────
def request(self, req):
results = self.requests([req])
def request(self, req, timeout=30.0):
results = self.requests([req], timeout)
return results[0]
def requests(self, reqs):
def requests(self, reqs, timeout=30.0):
"""Sends the batch and waits for every reply."""
if self.is_closed:
raise SoxException("SoxClient closed")
if len(reqs) > 255:
return self._chunk_requests(reqs)
return self._chunk_requests(reqs, timeout)
with self._lock:
for i, req in enumerate(reqs):
@@ -1085,7 +1087,7 @@ class SoxClient:
for req in reqs:
self._session.send(req.to_bytes())
deadline = time.monotonic() + 30.0
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
@@ -1107,11 +1109,11 @@ class SoxClient:
self._responses = None
raise SoxException("Request timeout")
def _chunk_requests(self, reqs):
def _chunk_requests(self, reqs, timeout=30.0):
results = []
for i in range(0, len(reqs), 255):
chunk = reqs[i:i + 255]
results.extend(self.requests(chunk))
results.extend(self.requests(chunk, timeout))
return results
# called by receiver thread
+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
# as a grey wash rather than a grid, so the grid is left off instead.
MIN_GRID_STEP = 3
SHEET_MARGIN_GRIDS = 3 # grid squares of grey kept past anything that overflows the sheet
def snap_up(value, grid_size):
@@ -153,6 +154,36 @@ def suggest_box_width_for_grid(width, grid_size):
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):
"""The top/left edge offset a box may not encroach on, snapped onto the grid."""
if grid_size is None:
@@ -238,28 +269,42 @@ def measure_box(comp, fonts, linked_slot_index):
def draw_grid():
"""Draws a grid onto the Canvas based on the scrollable region.
"""Paints the sheet and grids it; the canvas around it is the off-sheet tint.
Drawn at the sheet's zoom, so a box still lands on a line at every scale. The area
covered is the same patch of sheet whatever the zoom, which is why the span is
scaled along with the step.
Drawn at the sheet's zoom, so a box still lands on a line at every scale.
"""
canvas = app_state.canvas
canvas.delete("grid")
# The sheet starts past the margin strip no box may enter, which stays grey too.
x1 = y1 = layout_margin(app_state.meta_scale) * app_state.zoom
x2, y2 = (coord * app_state.zoom for coord in sheet_extent())
canvas.create_rectangle(x1, y1, x2, y2, outline="#c8c8c8", tags=("grid", "sheet_bg"))
app_state.paint_sheet_tint() # white or pink by saved state, grey or pink around it
step = app_state.GRID_SIZE * app_state.zoom
if step < MIN_GRID_STEP:
return
span = 3000 * app_state.zoom
for index in range(int(span // step) + 1):
offset = index * step
canvas.create_line(offset, 0, offset, span, fill="#e0e0e0", tags="grid")
canvas.create_line(0, offset, span, offset, fill="#e0e0e0", tags="grid")
if step >= MIN_GRID_STEP:
draw_grid_lines(canvas, step, x1, y1, x2, y2)
canvas.tag_lower("grid")
def sheet_extent():
"""1:1 (width, height) of the sheet: the furthest position meta can store a box at.
Only a box's top-left corner has to fit; a box reaching past the edge is drawn over
the grey, and keeping it inside is left to the author.
"""
side = META_COORD_MAX * (app_state.meta_scale or app_state.GRID_SIZE)
return side, side
def draw_grid_lines(canvas, step, x1, y1, x2, y2):
for index in range(math.ceil(x1 / step - 1e-6), math.floor(x2 / step + 1e-6) + 1):
canvas.create_line(index * step, y1, index * step, y2, fill="#e0e0e0", tags="grid")
for index in range(math.ceil(y1 / step - 1e-6), math.floor(y2 / step + 1e-6) + 1):
canvas.create_line(x1, index * step, x2, index * step, fill="#e0e0e0", tags="grid")
def create_round_rectangle(canvas, x1, y1, x2, y2, radius=4, **kwargs):
"""Draws a visually smooth rounded rectangle using native canvas arcs and polygons."""
points = [
@@ -337,18 +382,32 @@ def segment_hits_boxes(x1, y1, x2, y2, boxes):
def path_is_clear(points, boxes, clear):
"""True if no part of the path runs under a box or crowds one.
Every run between the first turn and the last keeps `clear` from each box: `end`
above and below, `side` left and right. The two runs pinned to the slots are only
held out from under the boxes, since they have to reach their pins wherever the
rows happen to be.
Every run keeps `clear` from each box: `end` above and below, `side` left and
right. Only the two stubs pinned to the slots -- `side` out of the source and
`arrive` into the target -- are held merely out from under the boxes, since they
have to reach their pins wherever the rows happen to be. A run that carries on
level past its stub is still a run, so it may not graze a box edge on the way.
"""
points = wire_style.simplify(points)
padded = [(x1 - clear.side, y1 - clear.end, x2 + clear.side, y2 + clear.end)
for x1, y1, x2, y2 in boxes]
last = len(points) - 2
for idx, ((x1, y1), (x2, y2)) in enumerate(zip(points, points[1:])):
pinned = idx in (0, last)
if segment_hits_boxes(x1, y1, x2, y2, boxes if pinned else padded):
level = abs(y1 - y2) < LINK_EPS
lo, hi = x1, x2 # the part of the run that must keep its distance
if level and idx == 0:
lo = x1 + math.copysign(min(clear.side, abs(x2 - x1)), x2 - x1)
if level and idx == last:
hi = x2 - math.copysign(min(clear.arrive, abs(x2 - lo)), x2 - lo)
if idx in (0, last) and level:
if segment_hits_boxes(x1, y1, x2, y2, boxes):
return False
if abs(hi - lo) > LINK_EPS and segment_hits_boxes(lo, y1, hi, y2, padded):
return False
elif idx in (0, last):
if segment_hits_boxes(x1, y1, x2, y2, boxes):
return False
elif segment_hits_boxes(x1, y1, x2, y2, padded):
return False
return True
@@ -433,14 +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,
pins=None):
pins=None, accept=None):
"""Nudges cx outwards in `clear.lane` steps until the run has a lane of its own.
A lane is rejected while it crowds a box (closer than `clear.side`) or while it
would run alongside an already placed run that overlaps it vertically -- two runs
passing through the same gap have to stand `clear.lane` apart. Returns None when
no lane inside the gap works. `max_steps` of 1 permits no nudging at all, which is
how the caller asks for the exact hug position and nothing else.
how the caller asks for the exact hug position and nothing else. `accept`, when
given, can turn down a lane that is otherwise free, and the search carries on.
"""
for step in range(0, max_steps):
for sign in ((0,) if step == 0 else (1, -1)):
@@ -449,11 +509,38 @@ def free_lane(cx, run_lo, run_hi, boxes, taken, low, high, clear, max_steps=8,
continue
if vertical_run_clearance(lane, run_lo, run_hi, boxes) < clear.side - LINK_EPS:
continue
if not lane_is_crowded(lane, run_lo, run_hi, taken, pins, clear.lane):
if lane_is_crowded(lane, run_lo, run_hi, taken, pins, clear.lane):
continue
if accept is None or accept(lane):
return lane
return None
def crosses_wires(points, ledger, pins):
"""True if the path would cross a run another wire has already laid down.
A crossing gets a hop, so it is allowed, but a lane that avoids one is worth
looking for first. Wires sharing a pin are one signal and never count.
"""
points = wire_style.simplify(points)
for (x1, y1), (x2, y2) in zip(points, points[1:]):
if abs(x1 - x2) < LINK_EPS: # vertical: against other wires' rows
lo, hi = min(y1, y2), max(y1, y2)
for ry, rx1, rx2, row_pins in ledger["rows"]:
if (row_pins != pins and not same_signal(pins, row_pins)
and rx1 + LINK_EPS < x1 < rx2 - LINK_EPS
and lo + LINK_EPS < ry < hi - LINK_EPS):
return True
else: # horizontal: against their lanes
lo, hi = min(x1, x2), max(x1, x2)
for cx, c_lo, c_hi, col_pins in ledger["cols"]:
if (col_pins != pins and not same_signal(pins, col_pins)
and lo + LINK_EPS < cx < hi - LINK_EPS
and c_lo + LINK_EPS < y1 < c_hi - LINK_EPS):
return True
return False
def hug_lane(edge_x, side, run_lo, run_hi, boxes, claimed, clear, max_offset=0,
min_offset=0, pins=None):
"""Lane for a run pinned to one box edge, starting `clear.side` out from it.
@@ -550,20 +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)
for y, x1, x2, _ in rows_of(candidate))
def straight_through(max_steps, allow_stack=False):
def path_through(lane):
return [start, leave, (lane, src_y), (lane, dst_y), arrive, end]
def straight_through(max_steps, allow_stack=False, uncrossed=False, hug_only=False):
"""A single vertical channel in the gap between the two boxes.
The two horizontal runs are pinned to their slots, so unless `allow_stack`
they are refused when they would hide under another wire: the wire is better
off detouring, which puts it on a lane it can actually move.
off detouring, which puts it on a lane it can actually move. With `uncrossed`
only a lane that crosses no other wire will do; with `hug_only` only the lane
beside its own source is tried (beside either end for a level wire), not ones
beside the boxes in between.
"""
accept = None
if uncrossed:
accept = lambda lane: not crosses_wires(path_through(lane), ledger, pins)
candidates = channel_candidates(src_box, dst_box, boxes, low, high, clear)
if hug_only:
# A bent wire hugs its source only: turning at the far end instead would
# take the lane the wires below it need to turn outside it.
hugs = (src_box[2] + clear.hug,)
if abs(src_y - dst_y) < LINK_EPS:
hugs += (dst_box[0] - clear.arrive,)
candidates = [cx for cx in candidates if cx in hugs]
for cx in candidates:
lane = free_lane(cx, run_lo, run_hi, boxes, claimed, low, high, clear,
max_steps, pins)
max_steps, pins, accept)
if lane is None:
continue
candidate = [start, leave, (lane, src_y), (lane, dst_y), arrive, end]
candidate = path_through(lane)
if not path_is_clear(candidate, boxes, clear):
continue
if not allow_stack and stacked(candidate):
@@ -651,10 +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
# slots and cannot be moved later. Letting these claim their rows first stops a
# detouring wire from parking its movable lane on top of one of them.
attempts = (lambda: straight_through(1),)
# Only an uncrossed one, too: a wire that would cross another waits for the
# second pass, where it can look further for a lane that does not.
# A wire whose hug lane is taken waits as well, rather than jumping to a lane far
# across the gap whose long run would then shut out the wires routed after it.
attempts = (lambda: straight_through(1, uncrossed=True, hug_only=True),)
else:
standoff = max(WIRE_STANDOFF_MAX, 3 * clear.side)
attempts = (
lambda: straight_through(8, uncrossed=True), # own lane, crossing nothing
lambda: straight_through(1), # hugging, own lane
lambda: around_boxes(clear.hug), # hugging, routed around
lambda: straight_through(8), # lane nudged out of the band
@@ -876,8 +984,16 @@ def draw_links():
jobs = collect_link_jobs(links_elem, path_to_tag, want_knob)
boxes = [b for b in (get_box_bounds(t) for t in path_to_tag.values()) if b]
# Shorter, simpler links claim lanes first; ties go to the smaller vertical span.
jobs.sort(key=lambda j: (abs(j.dst_box[0] - j.src_box[2]), abs(j.dst_y - j.src_y)))
# Links between nearer columns claim lanes first, and straight ones before bent ones.
# Among bent wires leaving one column, the first routed hugs its source closest, so
# climbing wires go top source first and falling ones bottom source first: each
# later wire then turns outside the earlier ones instead of crossing them.
def routing_order(job):
bent = abs(job.dst_y - job.src_y) > LINK_EPS
climbing = job.dst_y < job.src_y
return (round(job.dst_box[0] - job.src_box[0]), bent, not climbing,
job.src_y if climbing else -job.src_y)
jobs.sort(key=routing_order)
# What the wires placed so far have claimed, shared across the whole sheet.
# Routed in two passes: a wire that can run straight has its horizontal runs pinned
@@ -887,8 +1003,16 @@ def draw_links():
# placed closer than the grid rule allows still get a wire, just a tighter one.
roomy = grid_clearance()
ledger = {"cols": [], "rows": [row for job in jobs for row in job.stub_rows(roomy)]}
# A wire that cannot go direct defers the bent wires after it across the same gap
# in the same direction too, so they still take their lanes in routing order.
deferred = set()
for job in jobs:
group = (round(job.src_box[0]), round(job.dst_box[0]), job.dst_y < job.src_y)
if group in deferred and abs(job.dst_y - job.src_y) > LINK_EPS:
continue
job.route(boxes, ledger, roomy, direct_only=True)
if job.points is None:
deferred.add(group)
for clear in (roomy, tight_clearance()):
for job in jobs:
if job.points is None:
@@ -1140,6 +1264,11 @@ def render_components(components_to_render):
"""Handles parsing and visual painting of given component listings on canvas with slot attributes."""
canvas = app_state.canvas
app_state.active_components_list = components_to_render
# Repainting the same sheet (a refresh, an undo, a device update) keeps whatever of
# the selection is still on it; another sheet starts with nothing selected.
kept = [app_state.canvas_comp_map.get(t) for t in app_state.selected_canvas_tags
if t != app_state.selected_canvas_tag]
kept.append(app_state.canvas_comp_map.get(app_state.selected_canvas_tag))
app_state.selected_canvas_tag = None
app_state.selected_canvas_tags.clear()
@@ -1160,9 +1289,6 @@ def render_components(components_to_render):
meta_scale = sheet_meta_scale(components_to_render)
app_state.meta_scale = meta_scale
max_reached_x = 1000
max_reached_y = 1000
for comp in components_to_render:
meta_prop = comp.find("prop[@name='meta']")
meta_str = meta_prop.get("val") if meta_prop is not None else None
@@ -1175,22 +1301,26 @@ def render_components(components_to_render):
app_state.canvas_comp_map[comp_tag] = comp
app_state.canvas_tag_by_element[comp] = comp_tag
box_width, box_height, slot_offsets = draw_component_box(
_, _, slot_offsets = draw_component_box(
canvas, comp, coord_x, coord_y, fonts, linked_slot_index,
("component", comp_tag), outline_tags=("block_outline",)
)
app_state.component_slot_offsets[comp_tag] = slot_offsets
if coord_x + box_width > max_reached_x:
max_reached_x = coord_x + box_width
if coord_y + box_height > max_reached_y:
max_reached_y = coord_y + box_height
draw_links()
apply_sheet_zoom(fonts, max_reached_x + 200, max_reached_y + 200)
apply_sheet_zoom(fonts)
on_sheet = set(components_to_render)
for element in kept:
if element is not None and element in on_sheet:
tag = app_state.canvas_tag_by_element[element]
app_state.selected_canvas_tags.add(tag)
app_state.selected_canvas_tag = tag
for tag in app_state.selected_canvas_tags:
canvas.itemconfig(f"{tag} && block_outline", outline="#1f4e79", width=3)
def apply_sheet_zoom(fonts, extent_x, extent_y):
def apply_sheet_zoom(fonts):
"""Scales the finished sheet to app_state.zoom and sizes the canvas to match.
Painting and routing both work at 1:1, because that is where every metric here is
@@ -1212,10 +1342,46 @@ def apply_sheet_zoom(fonts, extent_x, extent_y):
scale_knob_ends(zoom)
canvas.config(scrollregion=(0, 0, extent_x * zoom, extent_y * zoom))
fit_sheet_region()
def fit_sheet_region():
"""Sets the scrollable area: the whole sheet, plus whatever hangs off its edges.
The sheet itself never grows (see sheet_extent). A box near the right or bottom
edge can still reach past it, or a wire loop past the top, so the scrollable area
takes those in too, with SHEET_MARGIN_GRIDS of grey beyond them.
"""
canvas = app_state.canvas
zoom = app_state.zoom
margin = SHEET_MARGIN_GRIDS * app_state.GRID_SIZE
width, height = sheet_extent()
x1, y1, x2, y2 = 0, 0, width, height
bounds = sheet_content_bounds()
if bounds is not None:
if bounds[0] < 0:
x1 = bounds[0] - margin
if bounds[1] < 0:
y1 = bounds[1] - margin
if bounds[2] > width:
x2 = bounds[2] + margin
if bounds[3] > height:
y2 = bounds[3] + margin
canvas.config(scrollregion=(x1 * zoom, y1 * zoom, x2 * zoom, y2 * zoom))
draw_grid()
def scroll_view_to(x, y):
"""Scrolls so canvas point (x, y) sits at the view's top-left, as near as the sheet allows."""
canvas = app_state.canvas
region = str(canvas.cget("scrollregion")).split()
if len(region) != 4:
return
x1, y1, x2, y2 = (float(v) for v in region)
canvas.xview_moveto(max(0.0, (x - x1) / ((x2 - x1) or 1.0)))
canvas.yview_moveto(max(0.0, (y - y1) / ((y2 - y1) or 1.0)))
def scale_knob_ends(zoom):
"""Brings the cached knob-to-knob ends onto the painted scale.
@@ -1237,6 +1403,7 @@ def redraw_links():
zoom = app_state.zoom
if zoom == 1.0:
draw_links()
fit_sheet_region() # a box dragged over the edge brings the grey past it into view
return
canvas = app_state.canvas
@@ -1245,6 +1412,7 @@ def redraw_links():
canvas.scale("component", 0, 0, zoom, zoom)
canvas.scale("link", 0, 0, zoom, zoom)
scale_knob_ends(zoom)
fit_sheet_region()
def zoom_step(direction):
@@ -1307,14 +1475,8 @@ def fit_zoom_level():
if bounds is None:
return None
# A canvas cannot scroll left of or above its origin, so anything out there cannot
# be brought into view however far the sheet is zoomed out. The fit is measured from
# the origin in that case rather than from the content's own corner.
left = max(0.0, bounds[0])
top = max(0.0, bounds[1])
content_width = bounds[2] - left
content_height = bounds[3] - top
content_width = bounds[2] - bounds[0]
content_height = bounds[3] - bounds[1]
view_width = canvas.winfo_width() - 2 * FIT_PADDING
view_height = canvas.winfo_height() - 2 * FIT_PADDING
@@ -1411,59 +1573,364 @@ def column_gap_for(run_count):
+ max(0, run_count - 1) * clear.lane)
def plan_columns(columns, column_keys, demand, scale):
LAYOUT_SWEEPS = 4 # down-and-up passes plan_columns makes to straighten the wires
def plan_columns(columns, column_keys, demand, scale, wires=()):
"""Walks the columns at `scale` px per meta step, without touching the DOM.
Returns (placements, clamped) where a placement is (item, step_x, step_y). Boxes are
positioned in pixels exactly as they will be painted, then divided down into meta
steps, so a coarser scale reaches further across the sheet for the same 0..255 range.
Column x positions come from widths and wire lanes alone. Heights are then settled
to make the wires run as straight as they can: each box is offered the height that
lines its slot up with the slot at the other end of its wires (the median, when it
has several), and each column is re-sorted by those heights, which also untangles
crossings. Boxes keep at least TIDY_GAP apart, but any gap above that is free -- a
box drops as far as its wires want. Sweeps run left to right, lining boxes up with
what feeds them, and right to left, lining them up with what they feed.
`wires` are (src_column, dst_column, src_item, src_slot, dst_item, dst_slot), left to
right only. One that skips columns stands in each column it crosses as a placeholder
box one wire tall, so it is sorted and spaced like the boxes there. The router turns
such a wire down beside its source, crossing the columns in between level with its
target slot, so a box still in its way is pushed down below it. The last sweep runs
right to left so those heights are final when that is checked.
"""
margin = snap_up(TIDY_MARGIN, scale)
clear_end = grid_clearance().end
count = len(column_keys)
# Column x positions depend only on widths and wire lanes, so they come first.
column_x = []
cursor_x = margin # the left-most column clears the canvas edge by the margin
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 = []
clamped = 0
cursor_x = margin # the left-most column clears the canvas edge by the margin
for index, grid_x in enumerate(column_keys):
column = sorted(columns[grid_x], key=lambda i: i["grid_y"])
cursor_y = margin
for index, column in enumerate(order):
for item in column:
step_x = min(META_COORD_MAX, round(cursor_x / scale))
step_y = min(META_COORD_MAX, round(cursor_y / scale))
if item.get("wire"):
continue
step_x = min(META_COORD_MAX, round(column_x[index] / scale))
step_y = min(META_COORD_MAX, round(top[id(item)] / scale))
if step_x == META_COORD_MAX or step_y == META_COORD_MAX:
clamped += 1
placements.append((item, step_x, step_y))
# Clear the box just placed by at least TIDY_GAP, then step to the next
# whole meta step so the box still lands on a storable position.
cursor_y = snap_up(cursor_y + item["height"] + TIDY_GAP, scale)
gap = column_gap_for(demand.get(index, 0))
cursor_x = snap_up(cursor_x + max(i["width"] for i in column) + gap, scale)
return placements, clamped
def sheet_link_pairs(components):
"""(source, src_slot, target, dst_slot) for each link between two components here."""
root = app_state.xml_root_element
pairs = []
if root is None:
return pairs
links_elem = root.find("links")
if links_elem is None:
return pairs
comp_by_path = {get_component_path(comp, root): comp for comp in components}
for link in links_elem.findall("link"):
from_ref, to_ref = link.get("from", ""), link.get("to", "")
if "." not in from_ref or "." not in to_ref:
continue
src_path, src_slot = from_ref.rsplit(".", 1)
dst_path, dst_slot = to_ref.rsplit(".", 1)
src = comp_by_path.get(src_path)
dst = comp_by_path.get(dst_path)
if src is not None and dst is not None and src is not dst:
pairs.append((src, src_slot, dst, dst_slot))
return pairs
def flow_columns(items, pairs):
"""Lays linked boxes out as a left-to-right signal flow, in as few columns as it can.
Returns a list of columns, each listed top to bottom. A box goes one column right of
the furthest box feeding it, so the column count is the length of the longest chain
and cannot be smaller. A box nothing feeds is then pulled right to sit just before
the first box it feeds, which keeps its wires short. Feedback loops are cut where
they run backwards relative to the boxes' current positions, so a loop keeps the
direction the author drew it in.
"""
def position(item):
return item["grid_x"], item["grid_y"], item["comp"].get("name", "")
item_of = {item["comp"]: item for item in items}
succs = {id(item): [] for item in items}
for src, _, dst, _ in pairs:
if item_of[dst] not in succs[id(item_of[src])]:
succs[id(item_of[src])].append(item_of[dst])
for out in succs.values():
out.sort(key=position)
# Depth-first from the left-most boxes; an edge back onto the current path is
# feedback and is left out of the layering.
forward = {id(item): [] for item in items}
state = {} # id -> 1 on the current path, 2 finished
for start in sorted(items, key=position):
if id(start) in state:
continue
state[id(start)] = 1
stack = [(start, iter(succs[id(start)]))]
while stack:
node, pending = stack[-1]
nxt = next(pending, None)
if nxt is None:
state[id(node)] = 2
stack.pop()
elif state.get(id(nxt)) == 1:
continue # feedback
else:
forward[id(node)].append(nxt)
if id(nxt) not in state:
state[id(nxt)] = 1
stack.append((nxt, iter(succs[id(nxt)])))
preds = {id(item): [] for item in items}
for item in items:
for nxt in forward[id(item)]:
preds[id(nxt)].append(item)
# Longest path from the sources, in topological order.
layer = {}
indegree = {id(item): len(preds[id(item)]) for item in items}
ready = sorted((i for i in items if not indegree[id(i)]), key=position)
order = []
while ready:
node = ready.pop(0)
order.append(node)
layer[id(node)] = max((layer[id(p)] + 1 for p in preds[id(node)]), default=0)
for nxt in forward[id(node)]:
indegree[id(nxt)] -= 1
if not indegree[id(nxt)]:
ready.append(nxt)
for node in reversed(order):
if not preds[id(node)] and forward[id(node)]:
layer[id(node)] = min(layer[id(n)] for n in forward[id(node)]) - 1
# Into any one box, wires from further left must arrive at higher slots than wires
# from nearer columns. A long wire crosses the columns in between level with its
# slot, so a nearer box feeding a slot above it would have to cross it on the way.
# Only boxes nothing feeds are free to move, so they are held back far enough left.
feeders = {} # id(target) -> [(slot height, source)]
for src, _, dst, d_slot in pairs:
src_item, dst_item = item_of[src], item_of[dst]
if dst_item in forward[id(src_item)]:
feeders.setdefault(id(dst_item), []).append(
(dst_item["slot_offsets"].get(d_slot, 0), src_item))
sources = [n for n in order if not preds[id(n)] and forward[id(n)]]
changed = True
while changed:
changed = False
for node in sources:
limit = layer[id(node)]
for target_id, fed in feeders.items():
own = [height for height, src in fed if src is node]
for height, other in fed:
if other is not node and own and height > min(own):
limit = min(limit, layer[id(other)])
limit = max(0, limit)
if limit < layer[id(node)]:
layer[id(node)] = limit
changed = True
columns = [[] for _ in range(max(layer.values(), default=-1) + 1)]
for item in sorted(items, key=lambda i: (i["grid_y"], i["grid_x"])):
columns[layer[id(item)]].append(item)
# Order each column after the boxes feeding it, so wires run as level as they can;
# then once back right to left, settling the boxes nothing feeds beside the boxes
# they feed. Ties keep the author's top-to-bottom order.
row = {}
def settle(column, neighbours):
def key(item):
near = [row[id(n)] for n in neighbours(item) if id(n) in row]
return sum(near) / len(near) if near else row.get(id(item), float("inf"))
column.sort(key=key)
for index, item in enumerate(column):
row[id(item)] = index
for column in columns:
settle(column, lambda i: preds[id(i)])
for column in reversed(columns):
settle(column, lambda i: forward[id(i)] if not preds[id(i)] else ())
return columns
def build_layout_items(components):
"""Column-grouped layout items for a sheet, measured rather than read off the canvas.
Returns (columns, column_keys, demand). Called both while painting -- to settle the
scale before anything is drawn -- and by tidy, so the two agree.
Returns (columns, column_keys, demand, wires), each column listed top to bottom.
Linked boxes are arranged by flow_columns. A box with no link to another box on this
sheet -- unlinked, or linked only off-sheet -- goes to the bottom of the first
column, alphabetically by name.
Called both while painting -- to settle the scale before anything is drawn -- and by
tidy, so the two agree.
"""
fonts = sheet_fonts()
linked_slot_index = build_linked_slot_index()
pairs = sheet_link_pairs(components)
on_sheet = {comp for src, _, dst, _ in pairs for comp in (src, dst)}
knobbed = offsheet_input_comps(components)
columns = {}
linked = []
loose = []
for comp in components:
meta_prop = comp.find("prop[@name='meta']")
meta_val = meta_prop.get("val") if meta_prop is not None else None
grid_x, grid_y = read_meta_property(meta_val)[:2]
width, height = measure_box(comp, fonts, linked_slot_index)[:2]
columns.setdefault(grid_x, []).append({
width, height, slot_rows = measure_box(comp, fonts, linked_slot_index)[:3]
item = {
"comp": comp, "meta": meta_val,
"grid_x": grid_x, "grid_y": grid_y,
"width": width, "height": height,
})
"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_of_comp = {
@@ -1471,7 +1938,42 @@ def build_layout_items(components):
for index, key in enumerate(column_keys)
for item in columns[key]
}
return columns, column_keys, wire_demand_by_boundary(column_of_comp)
return (columns, column_keys, wire_demand_by_boundary(column_of_comp),
forward_wires(columns, column_of_comp))
def forward_wires(columns, column_of_comp):
"""Every wire running left to right between two columns, for plan_columns.
Each is (src_column, dst_column, src_item, src_slot, dst_item, dst_slot). Feedback
wires, running right to left, are left out: they go over or under the boxes anyway.
"""
wires = []
root = app_state.xml_root_element
links_elem = root.find("links") if root is not None else None
if links_elem is None:
return wires
item_by_path = {
get_component_path(item["comp"], root): item
for column in columns.values()
for item in column
}
for link in links_elem.findall("link"):
from_ref, to_ref = link.get("from", ""), link.get("to", "")
if "." not in from_ref or "." not in to_ref:
continue
src_path, src_slot = from_ref.rsplit(".", 1)
dst_path, dst_slot = to_ref.rsplit(".", 1)
src_item = item_by_path.get(src_path)
dst_item = item_by_path.get(dst_path)
if src_item is None or dst_item is None:
continue
src_col = column_of_comp[src_item["comp"]]
dst_col = column_of_comp[dst_item["comp"]]
if dst_col > src_col:
wires.append((src_col, dst_col, src_item, src_slot, dst_item, dst_slot))
return wires
def sheet_meta_scale(components):
@@ -1485,22 +1987,24 @@ def sheet_meta_scale(components):
if not components:
return grid_size
columns, column_keys, demand = build_layout_items(components)
columns, column_keys, demand, wires = build_layout_items(components)
if not column_keys:
return grid_size
for multiplier in range(1, META_SCALE_MAX + 1):
_, clamped = plan_columns(columns, column_keys, demand, grid_size * multiplier)
_, clamped = plan_columns(columns, column_keys, demand, grid_size * multiplier,
wires)
if not clamped:
return grid_size * multiplier
return grid_size * META_SCALE_MAX
def tidy_layout():
"""Re-flows the open sheet so no two boxes overlap, preserving the author's columns.
"""Re-flows the open sheet as a left-to-right signal flow with no two boxes overlapping.
Boxes keep their column grouping and their top-to-bottom order; only the spacing is
rebuilt, from the sizes actually painted. Every placement lands on a whole meta step,
Columns follow the wiring (see flow_columns), using as few as the longest chain of
links allows; spacing is rebuilt from the sizes actually painted. Boxes with no link to another box on the
sheet are gathered at the bottom of the first column, A to Z. Every placement lands on a whole meta step,
the first column and row clear the canvas edge by at least TIDY_MARGIN, and
neighbours are left at least TIDY_GAP apart, widened where wires need lanes. A sheet
too wide for meta's 0..255 range is laid out at a coarser multiple of the grid rather
@@ -1511,12 +2015,12 @@ def tidy_layout():
return 0, 0
components = list(app_state.canvas_comp_map.values())
columns, column_keys, demand = build_layout_items(components)
columns, column_keys, demand, wires = build_layout_items(components)
if not column_keys:
return 0, 0
scale = sheet_meta_scale(components)
placements, clamped = plan_columns(columns, column_keys, demand, scale)
placements, clamped = plan_columns(columns, column_keys, demand, scale, wires)
changes = [
(item["comp"], item["meta"], step_x, step_y)