Compare commits
23
Commits
9e8d737fac
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dd01b13b9 | ||
|
|
0dddea53f8 | ||
|
|
0d5e5115d9 | ||
|
|
1a4c84f9d5 | ||
|
|
75df6d0a6e | ||
|
|
81c4a1d31d | ||
|
|
0c69f18046 | ||
|
|
3d9db54956 | ||
|
|
b3d96d96b6 | ||
|
|
160f65c95e | ||
|
|
b8af483890 | ||
|
|
6365d1f74d | ||
|
|
71effdc160 | ||
|
|
d905d25145 | ||
|
|
7ff2479736 | ||
|
|
cb0196caa7 | ||
|
|
db7b6632af | ||
|
|
f2b18923b7 | ||
|
|
e070bd0f1e | ||
|
|
08b4dec532 | ||
|
|
232eea99f4 | ||
|
|
1deb31f3b3 | ||
|
|
1f3b841dc0 |
+663
-16
@@ -1,4 +1,5 @@
|
||||
"""App > Get with File: a full backup of the connected device's app as one zip.
|
||||
"""App > Get with Files / Put with Files: a full backup of the device's app as one
|
||||
zip, and putting such a zip back onto a device.
|
||||
|
||||
The zip follows the layout Ontrol's tools read back on a restore:
|
||||
|
||||
@@ -6,7 +7,7 @@ The zip follows the layout Ontrol's tools read back on a restore:
|
||||
device/kits.scode
|
||||
device/images/<name>.bin
|
||||
device/fonts/<font file>
|
||||
device/system/logo.bin
|
||||
device/system/logo.jpg Orion's boot logo; Rion's is system/logo.bin
|
||||
app.sax app.sab decoded, as sedonac would write it
|
||||
imageBank[XL]/<name>.png the sources of the device's images and fonts, taken from
|
||||
fontBank[XL]/<font file> Sedona home's banks (XL for Orion's 800x480 screen)
|
||||
@@ -14,6 +15,9 @@ The zip follows the layout Ontrol's tools read back on a restore:
|
||||
|
||||
The device cannot list its files, so which images and fonts to fetch is read from the
|
||||
app itself: every image name and font its Ox widgets and pages name.
|
||||
|
||||
A put stages app.sab and kits.scode for the VM to take up on its next start; see
|
||||
put_with_files for the order it goes in.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
@@ -22,12 +26,15 @@ import re
|
||||
import zipfile
|
||||
|
||||
import app_state
|
||||
import kit_build
|
||||
import palette
|
||||
import sab_app
|
||||
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, RION_KIT
|
||||
import schema
|
||||
import sox
|
||||
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, ORION_PLATFORM_TYPE, RION_KIT
|
||||
|
||||
APP_SAB = "app.sab"
|
||||
KITS_SCODE = "kits.scode"
|
||||
LOGO_URI = "system/logo.bin"
|
||||
|
||||
_IMAGE_SLOTS = ("imageFileName", "trueImageFileName", "falseImageFileName")
|
||||
_SOURCE_IMAGE_EXTENSIONS = tuple(e for e in IMAGE_EXTENSIONS if e != ".bin")
|
||||
@@ -35,25 +42,41 @@ _UNSAFE_FILE_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||
|
||||
|
||||
class _Platform:
|
||||
def __init__(self, name, image_bank, font_bank, font_extensions):
|
||||
def __init__(self, name, kit, service_type, image_bank=None, font_bank=None,
|
||||
font_extensions=(), logo_uri=None, screen=None):
|
||||
self.name = name
|
||||
self.kit = kit # the kit only this platform's apps hold
|
||||
self.service_type = service_type # its platform service
|
||||
self.platform_id = "ontrol-" + name.lower() # the device's id starts so
|
||||
self.image_bank = image_bank
|
||||
self.font_bank = font_bank
|
||||
self.font_extensions = font_extensions
|
||||
self.logo_uri = logo_uri
|
||||
self.screen = screen # (width, height) in landscape; None: none
|
||||
|
||||
|
||||
# Orion keeps a landscape (.oxfl) and a portrait (.oxfp) cut of each font.
|
||||
_ORION = _Platform("ORION", "imageBankXL", "fontBankXL", (".oxfl", ".oxfp"))
|
||||
_RION = _Platform("RION", "imageBank", "fontBank", (".bin",))
|
||||
# Orion keeps a landscape (.oxfl) and a portrait (.oxfp) cut of each font, and its boot
|
||||
# logo as a jpg; Rion's is a native image (Ontrol's BGetWithFilesTool). A P-ION has no
|
||||
# screen: nothing but the app goes to it.
|
||||
_ORION = _Platform("ORION", ORION_KIT, ORION_PLATFORM_TYPE, "imageBankXL", "fontBankXL",
|
||||
(".oxfl", ".oxfp"), "system/logo.jpg", (800, 480))
|
||||
_RION = _Platform("RION", RION_KIT, "ontrolRion::RionPlatformService", "imageBank",
|
||||
"fontBank", (".bin",), "system/logo.bin", (320, 240))
|
||||
_PION = _Platform("PION", "ontrolPion", "ontrolPion::PionPlatformService")
|
||||
_PLATFORMS = (_ORION, _RION, _PION)
|
||||
|
||||
|
||||
def platform_of(app):
|
||||
kits = app.kit_names()
|
||||
if ORION_KIT in kits:
|
||||
return _ORION
|
||||
if RION_KIT in kits:
|
||||
return _RION
|
||||
return 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():
|
||||
@@ -98,6 +121,8 @@ def _font_stem(app, comp):
|
||||
def media_files(app, platform):
|
||||
"""The images and fonts the app names, each with its device path and bank source."""
|
||||
files, seen = [], set()
|
||||
if platform.screen is None:
|
||||
return files
|
||||
|
||||
def add(uri, bank_folder, bank_name, group, required=True):
|
||||
if uri not in seen:
|
||||
@@ -134,7 +159,7 @@ def media_files(app, platform):
|
||||
add_font(f"bold {stem}", group, True)
|
||||
add_font(stem, group, not bold)
|
||||
|
||||
add(LOGO_URI, platform.image_bank, "logo", "logo", required=False)
|
||||
add(platform.logo_uri, platform.image_bank, "logo", "logo", required=False)
|
||||
return files
|
||||
|
||||
|
||||
@@ -154,7 +179,7 @@ def _bank_source(bank_folder, bank_name, is_image):
|
||||
def _source_entry(media, source_path):
|
||||
"""Where the bank file goes in the zip: next to its bank's name, the logo in system/."""
|
||||
file_name = os.path.basename(source_path)
|
||||
if media.device_uri == LOGO_URI:
|
||||
if media.device_uri.startswith("system/"):
|
||||
return "system/logo" + os.path.splitext(file_name)[1]
|
||||
return f"{media.bank_folder}/{file_name}"
|
||||
|
||||
@@ -259,3 +284,625 @@ def get_with_file(client, path, status=None):
|
||||
result.entries = [name for name, _ in entries]
|
||||
report("Done", 1.0)
|
||||
return result
|
||||
|
||||
|
||||
# --- Put with Files ---------------------------------------------------------------------
|
||||
#
|
||||
# Follows Ontrol's Workbench tool (ontrolSedonaOx BPutWithFilesTool, ontrolSedonanet
|
||||
# PutApp): the backup's app.sax is encoded for the chosen kit builds, kits.scode built
|
||||
# for them with sedonac when they are not the device's (kit_build); both go as
|
||||
# '.writing' renamed to '.stage'; the platform service's deleteFile clears fonts/* and
|
||||
# images/*; then the images, the fonts and the boot logo go. Fonts come from Sedona
|
||||
# home's bank in the cut the device draws with, images in the form its screen takes.
|
||||
|
||||
_FONT_FOLDERS = ("device/fonts/", "fonts/", "fontBank/", "fontBankXL/")
|
||||
_JPG = (".jpg", ".jpeg")
|
||||
|
||||
|
||||
def _checksum(value):
|
||||
"""A kit checksum as an unsigned int, from a signed Sox int or a .sab hex string."""
|
||||
return int(value, 16) if isinstance(value, str) else value & 0xFFFFFFFF
|
||||
|
||||
|
||||
def kits_fit(app_kits, device_kits):
|
||||
"""Whether the device's kits.scode can load an app.sab of `app_kits`.
|
||||
|
||||
App.loadSchema's rule: the app's kits must appear among the device's, in the same
|
||||
order, each with the same checksum; the device may hold more.
|
||||
"""
|
||||
device = iter((n, _checksum(c)) for n, c in device_kits)
|
||||
return all(any(d == (n, _checksum(c)) for d in device) for n, c in app_kits)
|
||||
|
||||
|
||||
class DeviceTarget:
|
||||
"""What a put needs to know of the connected device."""
|
||||
|
||||
def __init__(self, platform, kits, name=None, portrait=False,
|
||||
platform_comp_id=None, delete_slot_id=None, platform_id=""):
|
||||
self.platform = platform # one of _PLATFORMS, or None
|
||||
self.platform_id = platform_id # e.g. "ontrol-pion-b427a5b9"
|
||||
self.kits = kits # [(name, checksum)] in kit-id order
|
||||
self.name = name # the app's deviceName
|
||||
self.portrait = portrait # Orion's screenOrientation
|
||||
self.platform_comp_id = platform_comp_id
|
||||
self.delete_slot_id = delete_slot_id
|
||||
|
||||
@property
|
||||
def font_extension(self):
|
||||
if self.platform is _ORION:
|
||||
return ".oxfp" if self.portrait else ".oxfl"
|
||||
return ".bin"
|
||||
|
||||
@property
|
||||
def screen(self):
|
||||
"""(width, height) as the device is set up, as Ontrol's ScreenInfo has it."""
|
||||
w, h = self.platform.screen
|
||||
return (h, w) if self.portrait else (w, h)
|
||||
|
||||
|
||||
def device_target(ds):
|
||||
"""Reads the platform, kits, deviceName and platform service off the device.
|
||||
|
||||
The platform comes from the platform id, as Ontrol's tools read it, the kits
|
||||
standing in for a device that does not give one.
|
||||
"""
|
||||
client = ds.sox_client
|
||||
kits = [(p.name, p.checksum) for p in client.read_schema()]
|
||||
kit_names = {n for n, _ in kits}
|
||||
platform_id = (client.read_version().platform_id or "").lower()
|
||||
platform = (next((p for p in _PLATFORMS if platform_id.startswith(p.platform_id)), None)
|
||||
or next((p for p in _PLATFORMS if p.kit in kit_names), None))
|
||||
|
||||
target = DeviceTarget(platform, kits, platform_id=client.read_version().platform_id)
|
||||
comp = client.cache_get(0)
|
||||
if comp is not None:
|
||||
client.update([comp], sox.CONFIG)
|
||||
target.name = comp.values.get("deviceName") or None
|
||||
if platform is not None and platform.screen is not None:
|
||||
plat = ds.find_component_of_type(platform.service_type, "screenOrientation")
|
||||
if plat is not None:
|
||||
target.platform_comp_id = plat.id
|
||||
target.portrait = (platform is _ORION and
|
||||
str(plat.values.get("screenOrientation")).lower() == "true")
|
||||
slots = client.slot_registry.get((plat.kit_id, plat.type_id)) or []
|
||||
target.delete_slot_id = next((s["id"] for s in slots if s["name"] == "deleteFile"
|
||||
and s.get("is_action")), None)
|
||||
return target
|
||||
|
||||
|
||||
# --- images in the device's form --------------------------------------------------------
|
||||
|
||||
def native_image(data, name, target):
|
||||
"""A png (or any image PIL reads) as the device's native .bin.
|
||||
|
||||
Ported from OntOxImageUtil.convertImageToNativeFormat: a 32-byte name, a 32-byte
|
||||
header (flags, width, height), then the pixels. Orion draws BGR(A), turned for a
|
||||
portrait screen, with alpha unless the image fills the screen; Rion RGB565, with
|
||||
alpha only for a translucent image smaller than the screen.
|
||||
"""
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img.load()
|
||||
width, height = img.size
|
||||
translucent = img.mode in ("RGBA", "LA", "PA") or (
|
||||
img.mode == "P" and "transparency" in img.info and
|
||||
any(0 < a < 255 for a in img.convert("RGBA").getchannel("A").getdata()))
|
||||
background = (width, height) == target.screen
|
||||
orion = target.platform is _ORION
|
||||
rgb_mode = use_alpha = orion and not background
|
||||
if not orion:
|
||||
use_alpha = translucent and not background
|
||||
rotate = orion and target.portrait
|
||||
|
||||
rgba = img.convert("RGBA")
|
||||
if rotate: # the Java walks columns right to left: a quarter turn anticlockwise
|
||||
rgba = rgba.transpose(Image.Transpose.ROTATE_90)
|
||||
w, h = rgba.size
|
||||
|
||||
out = bytearray(name.encode("ascii", errors="replace"))
|
||||
out += bytes(max(0, 32 - len(out)))
|
||||
out += bytes([(4 if rotate else 0) + (2 if rgb_mode else 0) + (1 if use_alpha else 0),
|
||||
w & 0xFF, w >> 8, h & 0xFF, h >> 8]) + bytes(27)
|
||||
pixels = rgba.tobytes()
|
||||
if rgb_mode:
|
||||
for i in range(0, len(pixels), 4):
|
||||
r, g, b, a = pixels[i:i + 4]
|
||||
out += bytes((b, g, r, a)) if use_alpha else bytes((b, g, r))
|
||||
else:
|
||||
for i in range(0, len(pixels), 4):
|
||||
r, g, b, a = pixels[i:i + 4]
|
||||
rgb565 = (r >> 3) << 11 | (g >> 2) << 5 | (b >> 3)
|
||||
if use_alpha:
|
||||
out.append(a)
|
||||
out += bytes((rgb565 & 0xFF, rgb565 >> 8))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def device_jpg(data, target):
|
||||
"""A jpg as the device takes it: turned a quarter anticlockwise for a portrait Orion
|
||||
(OntOxImageUtil.rotateJpg), otherwise as it is."""
|
||||
if not (target.platform is _ORION and target.portrait):
|
||||
return data
|
||||
import io
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(data))
|
||||
buf = io.BytesIO()
|
||||
img.transpose(Image.Transpose.ROTATE_90).save(buf, "JPEG", quality=75)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# --- the plan ---------------------------------------------------------------------------
|
||||
|
||||
class PutFile:
|
||||
def __init__(self, device_uri, data, source):
|
||||
self.device_uri = device_uri
|
||||
self.data = data
|
||||
self.source = source # where the bytes come from, for the summary
|
||||
|
||||
|
||||
class PutPlan:
|
||||
"""What a put of the zip would do, and whether the zip fits the device."""
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.problems = [] # any of these stops the put
|
||||
self.warnings = [] # worth confirming, but the put can go on
|
||||
self.app_source = "" # how app.sab was come by, for the summary
|
||||
self.kit_changes = [] # each chosen kit build that is not the device's
|
||||
self.scode_source = "" # where kits.scode comes from, when it goes
|
||||
self.app_files = [] # PutFile to '<name>.writing', renamed to '.stage'
|
||||
self.media = [] # PutFile: images, fonts, logo
|
||||
self.converted = 0 # images made from the backup's image bank
|
||||
self.images_missing = [] # images neither in device form nor as a source
|
||||
self.fonts_from_zip = [] # fonts the bank lacks; the backup's copy goes
|
||||
self.fonts_missing = [] # fonts the app names that neither has
|
||||
self.staged = False # set once the device holds app.sab.stage
|
||||
self.has_screen = True # False for a P-ION: the app is all that goes
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
return not self.problems
|
||||
|
||||
|
||||
def _app_portrait(app):
|
||||
comp = next((c for c in app.comps.values() if c["type"] == ORION_PLATFORM_TYPE), None)
|
||||
return bool(comp and app.prop(comp, "screenOrientation", False))
|
||||
|
||||
|
||||
class KitRow:
|
||||
"""One kit in the kits step of a put: the device's build, the backup's, and what to put.
|
||||
|
||||
`included` rows are what goes: into app.sab's schema and, when it is rebuilt, into
|
||||
kits.scode. A `required` row -- a kit the app's components name, or one an included
|
||||
kit depends on -- is always included.
|
||||
"""
|
||||
|
||||
def __init__(self, name, device, backup, builds, used=False, locked=False,
|
||||
unsupported=False):
|
||||
self.name = name
|
||||
self.device = device # checksum hex the device runs, or None
|
||||
self.backup = backup # checksum hex the backup's schema names, or None
|
||||
self.builds = builds # [(version, checksum hex)] Sedona home has, newest first
|
||||
self.used = used # the app's components name a type of it
|
||||
self.locked = locked # the backup holds only app.sab: nothing can change
|
||||
self.unsupported = unsupported # cannot run on the device's platform
|
||||
self.required = used
|
||||
self.included = used or (device is not None and not unsupported) or \
|
||||
(backup is not None and not unsupported)
|
||||
self.use = self._default() # the chosen checksum hex
|
||||
|
||||
def _default(self):
|
||||
"""The device's build, when Sedona home can describe it; then the backup's; then
|
||||
the newest."""
|
||||
known = {c for _, c in self.builds}
|
||||
if self.locked:
|
||||
return self.backup
|
||||
for checksum in (self.device, self.backup):
|
||||
if checksum in known:
|
||||
return checksum
|
||||
return self.builds[0][1] if self.builds else self.device or self.backup
|
||||
|
||||
@property
|
||||
def latest(self):
|
||||
return self.builds[0][1] if self.builds else None
|
||||
|
||||
|
||||
def _hex(checksum):
|
||||
return f"{_checksum(checksum):08x}"
|
||||
|
||||
|
||||
def _app_source(z, names):
|
||||
"""(sax entry or None, sab entry or None) of the backup's app."""
|
||||
sax = "app.sax" if "app.sax" in names else next(
|
||||
(n for n in sorted(names) if n.endswith(".sax") and "/" not in n), None)
|
||||
sab = "device/" + APP_SAB
|
||||
return sax, sab if sab in names else None
|
||||
|
||||
|
||||
def _backup_kits(path):
|
||||
"""([(kit, checksum or None)] the backup's schema names, {kits its components use},
|
||||
whether it holds only app.sab). Raises ValueError when the zip holds no app."""
|
||||
import xml.etree.ElementTree as ET
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = {n for n in z.namelist() if not n.endswith("/")}
|
||||
sax_name, sab_name = _app_source(z, names)
|
||||
if sax_name:
|
||||
root = ET.fromstring(z.read(sax_name))
|
||||
schema_elem = root.find("schema")
|
||||
kits = [(k.get("name"), k.get("checksum"))
|
||||
for k in (schema_elem.findall("kit") if schema_elem is not None else ())]
|
||||
used = {c.get("type", "").split("::")[0] for c in root.iter("comp")}
|
||||
return kits, used | {"sys"}, False
|
||||
if sab_name:
|
||||
app = sab_app.SabApp(z.read(sab_name))
|
||||
used = {c["type"].split("::")[0] for c in app.comps.values()}
|
||||
return app.kit_parts, used, True
|
||||
raise ValueError("The zip holds no app: neither app.sax nor device/app.sab.")
|
||||
|
||||
|
||||
def kit_rows(path, target):
|
||||
"""Every kit the kits step lists, in Manage Kits' groups (see KitRow.group).
|
||||
|
||||
The kits the backup's app needs, those its schema or the device holds without the
|
||||
app needing them, and every other kit Sedona home has. Each defaults to the device's
|
||||
build. Raises ValueError when the zip holds no readable app.
|
||||
"""
|
||||
kits, used, locked = _backup_kits(path)
|
||||
device = {n: _hex(c) for n, c in target.kits}
|
||||
backup = {n: _hex(c) if c else None for n, c in kits}
|
||||
names = list(dict.fromkeys([n for n, _ in kits] + list(device) + palette.list_kits()))
|
||||
unsupported = palette.unsupported_kits(set(backup) | set(device))
|
||||
rows = []
|
||||
for name in names:
|
||||
builds = [(v, _hex(c)) for v, c in palette.kit_builds(name)]
|
||||
if locked and name not in backup:
|
||||
continue # nothing can be added to a bare app.sab
|
||||
rows.append(KitRow(name, device.get(name), backup.get(name), builds,
|
||||
used=name in used, locked=locked,
|
||||
unsupported=name in unsupported and name not in used))
|
||||
settle_required(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def settle_required(rows):
|
||||
"""Marks every kit an included kit depends on as required -- and so included, at its
|
||||
default build -- all the way down, as Manage Kits counts dependencies."""
|
||||
by_name = {r.name: r for r in rows}
|
||||
for row in rows:
|
||||
row.required = row.used
|
||||
pending = [r for r in rows if r.included or r.used]
|
||||
while pending:
|
||||
row = pending.pop()
|
||||
for dep in schema.read_kit_dependencies(row.name, row.use):
|
||||
dep_row = by_name.get(dep)
|
||||
if dep_row is not None and not dep_row.required:
|
||||
dep_row.required = dep_row.included = True
|
||||
pending.append(dep_row)
|
||||
for row in rows:
|
||||
if row.used:
|
||||
row.included = True
|
||||
|
||||
|
||||
KIT_GROUPS = ("Installed kits", "Installed but not used kits",
|
||||
"Kits available on the computer", "Not supported")
|
||||
|
||||
|
||||
def group(row):
|
||||
"""The Manage Kits section a row is listed under: the app needs it; the device or
|
||||
the backup holds it without the app needing it; Sedona home has it; or it cannot run
|
||||
on the device."""
|
||||
if row.unsupported:
|
||||
return KIT_GROUPS[3]
|
||||
if row.required:
|
||||
return KIT_GROUPS[0]
|
||||
if row.device or row.backup:
|
||||
return KIT_GROUPS[1]
|
||||
return KIT_GROUPS[2]
|
||||
|
||||
|
||||
def chosen_kits(rows):
|
||||
"""[(name, checksum hex)] of the included rows."""
|
||||
return [(r.name, r.use) for r in rows if r.included]
|
||||
|
||||
|
||||
def plan_put(path, target, rows=None, rebuild=False):
|
||||
"""Reads the zip at `path` and checks it against the device `target`.
|
||||
|
||||
rows are kit_rows() as the kits step left them; None takes the defaults. The
|
||||
included kits make app.sab's schema; kits.scode is built for them when they are not
|
||||
the device's kits, or when `rebuild` asks for it anyway.
|
||||
"""
|
||||
plan = PutPlan(path)
|
||||
try:
|
||||
z = zipfile.ZipFile(path)
|
||||
except (OSError, zipfile.BadZipFile) as e:
|
||||
plan.problems.append(f"Not a readable zip: {e}")
|
||||
return plan
|
||||
if rows is None:
|
||||
try:
|
||||
rows = kit_rows(path, target)
|
||||
except Exception as e:
|
||||
plan.problems.append(str(e))
|
||||
return plan
|
||||
with z:
|
||||
names = {n for n in z.namelist() if not n.endswith("/")}
|
||||
app, backup_portrait = _plan_app(plan, z, names, target, rows, rebuild)
|
||||
if app is None:
|
||||
return plan
|
||||
|
||||
platform = platform_of(app)
|
||||
if platform is None or platform is not target.platform:
|
||||
plan.problems.append(f"The backup is from {platform_label(platform)}; the device "
|
||||
f"is {platform_label(target.platform)}.")
|
||||
return plan
|
||||
|
||||
zip_name = app.prop(app.app, "deviceName", "") or ""
|
||||
if target.name and zip_name and zip_name != target.name:
|
||||
plan.warnings.append(f"The backup was taken from '{zip_name}'; this device is "
|
||||
f"'{target.name}'.")
|
||||
if platform.screen is None:
|
||||
plan.has_screen = False # no images or fonts to put
|
||||
return plan
|
||||
if target.delete_slot_id is None:
|
||||
plan.warnings.append("The device's platform service has no deleteFile action, "
|
||||
"so its old fonts and images stay.")
|
||||
|
||||
same_screen = platform is _RION or backup_portrait == target.portrait
|
||||
if not same_screen:
|
||||
plan.warnings.append(
|
||||
f"The backup is from a {'portrait' if backup_portrait else 'landscape'} "
|
||||
f"Orion; this one is {'portrait' if target.portrait else 'landscape'}. Its "
|
||||
"images are made again from the backup's image bank.")
|
||||
_plan_images(plan, z, names, platform, target, same_screen)
|
||||
_plan_fonts(plan, z, names, app, platform, target.font_extension)
|
||||
_plan_logo(plan, z, names, platform, target, same_screen)
|
||||
return plan
|
||||
|
||||
|
||||
def _plan_app(plan, z, names, target, rows, rebuild):
|
||||
"""Settles app.sab and kits.scode, as Ontrol's PutApp does.
|
||||
|
||||
The backup's app.sax is encoded for the chosen kit builds (a backup with only
|
||||
device/app.sab goes as it is). When the device's kits.scode cannot load that app, or
|
||||
a rebuild is asked for, kits.scode is built for the chosen builds with sedonac after
|
||||
a check of their natives against the device's VM; without sedonac, the backup's own
|
||||
kits.scode can stand in when the chosen builds are the backup's. Returns (decoded
|
||||
app, whether the backup came from a portrait screen).
|
||||
"""
|
||||
sax_name, sab_name = _app_source(z, names)
|
||||
chosen = chosen_kits(rows)
|
||||
original = None
|
||||
if sab_name:
|
||||
try:
|
||||
original = sab_app.SabApp(z.read(sab_name))
|
||||
except Exception as e:
|
||||
if not sax_name:
|
||||
plan.problems.append(f"{sab_name} cannot be read: {e}")
|
||||
return None, False
|
||||
|
||||
if sax_name:
|
||||
try:
|
||||
sab = sab_app.encode_sax(z.read(sax_name), kits=chosen)
|
||||
except Exception as e:
|
||||
plan.problems.append(f"{sax_name} cannot be encoded for the chosen kits: {e}")
|
||||
return None, False
|
||||
plan.app_source = f"{sax_name}, encoded for the chosen kits"
|
||||
source = sax_name
|
||||
else:
|
||||
sab = z.read(sab_name)
|
||||
plan.app_source = sab_name
|
||||
source = sab_name
|
||||
app = sab_app.SabApp(sab)
|
||||
app_kits = app.kit_parts
|
||||
|
||||
plan.kit_changes = [line for line in map(kit_change, rows) if line]
|
||||
device_kits = {(n, _checksum(c)) for n, c in target.kits}
|
||||
if sax_name:
|
||||
same = {(n, _checksum(c)) for n, c in app_kits} == device_kits
|
||||
else:
|
||||
same = kits_fit(app_kits, target.kits)
|
||||
if rebuild or not same:
|
||||
scode = _plan_scode(plan, z, names, target, app_kits)
|
||||
if scode is None:
|
||||
return None, False
|
||||
plan.app_files.append(scode)
|
||||
plan.app_files.append(PutFile(APP_SAB, sab, source))
|
||||
return app, _app_portrait(original or app)
|
||||
|
||||
|
||||
def kit_change(row):
|
||||
"""How a row changes the device's kits, or None."""
|
||||
def label(checksum):
|
||||
version = palette.version_for_checksum(row.name, checksum)
|
||||
return f"{version} ({checksum})" if version else checksum
|
||||
if row.device is None:
|
||||
return f"{row.name}: added at {label(row.use)}" if row.included else None
|
||||
if not row.included:
|
||||
return f"{row.name}: removed"
|
||||
if row.use != row.device:
|
||||
return f"{row.name}: {label(row.device)} -> {label(row.use)}"
|
||||
return None
|
||||
|
||||
|
||||
def _plan_scode(plan, z, names, target, app_kits):
|
||||
"""kits.scode for the app's kits: built by sedonac, or the backup's own."""
|
||||
manifest = kit_build.platform_manifest(target.platform_id)
|
||||
backup_scode = "device/" + KITS_SCODE
|
||||
backup_kits = None
|
||||
if "device/" + APP_SAB in names:
|
||||
try:
|
||||
backup_kits = sab_app.SabApp(z.read("device/" + APP_SAB)).kit_parts
|
||||
except Exception:
|
||||
pass
|
||||
why = None
|
||||
if manifest is None:
|
||||
why = (f"Sedona home has no platform manifest for {target.platform_id or 'the device'}"
|
||||
" (platforms/db), so kits.scode cannot be built or checked for it.")
|
||||
else:
|
||||
problems = kit_build.native_problems(app_kits, manifest)
|
||||
if problems:
|
||||
plan.problems.append("The chosen kits call native methods the device's VM "
|
||||
"does not have:")
|
||||
plan.problems += [" " + p for p in problems]
|
||||
return None
|
||||
try:
|
||||
data = kit_build.build_kits_scode(app_kits, manifest)
|
||||
except kit_build.BuildError as e:
|
||||
why = str(e)
|
||||
else:
|
||||
plan.scode_source = "built by sedonac for the chosen kits"
|
||||
return PutFile(KITS_SCODE, data, "sedonac")
|
||||
|
||||
if backup_kits is not None and backup_scode in names and \
|
||||
[(n, _checksum(c)) for n, c in backup_kits] == \
|
||||
[(n, _checksum(c)) for n, c in app_kits]:
|
||||
plan.warnings.append(why + " The backup's own kits.scode goes instead.")
|
||||
plan.scode_source = "the backup's, unchecked against the device's VM"
|
||||
return PutFile(KITS_SCODE, z.read(backup_scode), backup_scode)
|
||||
plan.problems.append(why)
|
||||
return None
|
||||
|
||||
|
||||
def _plan_images(plan, z, names, platform, target, same_screen):
|
||||
"""Every image the backup holds, in the form the device's screen takes.
|
||||
|
||||
The backup's device copy goes when it was taken off the same kind of screen;
|
||||
otherwise the image bank's source is made over, as Ontrol's tool always does.
|
||||
"""
|
||||
native, source = {}, {}
|
||||
for name in names:
|
||||
folder, _, file_name = name.rpartition("/")
|
||||
stem, ext = os.path.splitext(file_name)
|
||||
if folder == "device/images":
|
||||
native[stem] = name
|
||||
elif folder == platform.image_bank and ext.lower() in _SOURCE_IMAGE_EXTENSIONS:
|
||||
source[stem] = name
|
||||
for stem in sorted(set(native) | set(source), key=str.lower):
|
||||
if same_screen and stem in native:
|
||||
name = native[stem]
|
||||
plan.media.append(PutFile("images/" + name.rpartition("/")[2], z.read(name),
|
||||
"backup"))
|
||||
elif stem in source:
|
||||
name = source[stem]
|
||||
try:
|
||||
if name.lower().endswith(_JPG):
|
||||
plan.media.append(PutFile(f"images/{stem}.jpg",
|
||||
device_jpg(z.read(name), target), name))
|
||||
else:
|
||||
plan.media.append(PutFile(f"images/{stem}.bin",
|
||||
native_image(z.read(name), stem, target), name))
|
||||
plan.converted += 1
|
||||
except Exception as e:
|
||||
plan.images_missing.append(f"{stem} ({name}: {e})")
|
||||
else:
|
||||
plan.images_missing.append(stem)
|
||||
|
||||
|
||||
def _plan_logo(plan, z, names, platform, target, same_screen):
|
||||
"""The boot logo: the device's copy, or the backup's source made over (Ontrol's
|
||||
tool sends it into images/ -- system/ is where the device looks for it)."""
|
||||
copy = "device/" + platform.logo_uri
|
||||
if same_screen and copy in names:
|
||||
plan.media.append(PutFile(platform.logo_uri, z.read(copy), "backup"))
|
||||
return
|
||||
source = next((n for n in sorted(names) if n.rpartition("/")[0] == "system" and
|
||||
os.path.splitext(n)[0].endswith("logo")), None)
|
||||
if source is None:
|
||||
return
|
||||
try:
|
||||
data = z.read(source)
|
||||
if platform is _ORION:
|
||||
if not source.lower().endswith(_JPG):
|
||||
return
|
||||
plan.media.append(PutFile(platform.logo_uri, device_jpg(data, target), source))
|
||||
else:
|
||||
plan.media.append(PutFile(platform.logo_uri, native_image(data, "logo", target),
|
||||
source))
|
||||
except Exception as e:
|
||||
plan.warnings.append(f"The boot logo could not be made from {source}: {e}")
|
||||
|
||||
|
||||
def _plan_fonts(plan, z, names, app, platform, ext):
|
||||
"""Every font the backup holds or the app names, in the device's cut `ext`.
|
||||
|
||||
As Ontrol's tool does, the bank's file goes rather than the backup's; the backup's
|
||||
goes only for a font the bank lacks.
|
||||
"""
|
||||
in_zip = {} # stem -> {extension: entry}
|
||||
for name in names:
|
||||
folder = next((f for f in _FONT_FOLDERS if name.startswith(f)), None)
|
||||
if folder and "/" not in name[len(folder):]:
|
||||
stem, file_ext = os.path.splitext(name[len(folder):])
|
||||
in_zip.setdefault(stem, {}).setdefault(file_ext.lower(), name)
|
||||
required = {}
|
||||
for m in media_files(app, platform):
|
||||
if m.device_uri.startswith("fonts/"):
|
||||
stem = os.path.splitext(m.device_uri[len("fonts/"):])[0]
|
||||
required[stem] = required.get(stem, False) or m.required
|
||||
|
||||
for stem in sorted(set(in_zip) | set(required)):
|
||||
bank = _bank_source(platform.font_bank, stem + ext, False)
|
||||
if bank:
|
||||
with open(bank, "rb") as f:
|
||||
plan.media.append(PutFile("fonts/" + stem + ext, f.read(),
|
||||
f"{platform.font_bank}/{stem}{ext}"))
|
||||
elif ext in in_zip.get(stem, {}):
|
||||
plan.media.append(PutFile("fonts/" + stem + ext,
|
||||
z.read(in_zip[stem][ext]), "backup"))
|
||||
plan.fonts_from_zip.append(stem + ext)
|
||||
elif required.get(stem) or stem in in_zip:
|
||||
plan.fonts_missing.append(stem + ext)
|
||||
|
||||
|
||||
def put_with_files(client, plan, target, status=None):
|
||||
"""Puts the plan on the device; the device takes the app up when it restarts.
|
||||
|
||||
Runs off the main thread; status(text, fraction) reports progress. A failure
|
||||
raises, naming the step. Once app.sab.stage is in place the app is committed: a
|
||||
later failure leaves the new app staged with some of its media missing.
|
||||
"""
|
||||
def report(text, fraction=None):
|
||||
if status:
|
||||
status(text, fraction)
|
||||
|
||||
total = sum(len(f.data) for f in plan.app_files + plan.media) or 1
|
||||
done = 0
|
||||
|
||||
def put(uri, f):
|
||||
nonlocal done
|
||||
base = done
|
||||
|
||||
def progress(sent, size):
|
||||
report(f"Writing {uri} ...", (base + sent) / total)
|
||||
report(f"Writing {uri} ...", base / total)
|
||||
try:
|
||||
client.put_file(uri, f.data, progress)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Writing {uri}: {e}") from e
|
||||
done += len(f.data)
|
||||
|
||||
for f in plan.app_files:
|
||||
put(f.device_uri + ".writing", f)
|
||||
for f in plan.app_files:
|
||||
writing, stage = f.device_uri + ".writing", f.device_uri + ".stage"
|
||||
report(f"Renaming {writing} to {stage} ...", done / total)
|
||||
try:
|
||||
client.rename_file(writing, stage)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Renaming {writing} to {stage}: {e}") from e
|
||||
plan.staged = True
|
||||
|
||||
if target.delete_slot_id is not None:
|
||||
for pattern in ("fonts/*", "images/*"):
|
||||
report(f"Deleting {pattern} on the device ...", done / total)
|
||||
try:
|
||||
client.invoke(target.platform_comp_id, target.delete_slot_id,
|
||||
sox.STR_ID, pattern) # an asStr Buf goes as a Str
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Deleting {pattern}: {e}") from e
|
||||
|
||||
for f in plan.media:
|
||||
put(f.device_uri, f)
|
||||
report("Done", 1.0)
|
||||
|
||||
+35
-7
@@ -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.163"
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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.29
|
||||
host = 192.168.0.26
|
||||
port = 1876
|
||||
user = admin
|
||||
|
||||
|
||||
+190
@@ -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
@@ -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="+")
|
||||
|
||||
@@ -155,6 +155,43 @@ def live_rename_component(element, new_name):
|
||||
return False
|
||||
|
||||
|
||||
# ── Reorder Children ────────────────────────────────────────────
|
||||
|
||||
def live_reorder_children(parent_element, ordered_children):
|
||||
"""In live mode, sends a Sox reorder to the device. Returns True on success.
|
||||
|
||||
The device takes the order only when it names every child it has, no more and no
|
||||
fewer, so the list is checked against what the device last said before it is sent.
|
||||
"""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
parent_id = _comp_id(parent_element)
|
||||
if parent_id is None:
|
||||
_fail(f"'{parent_element.get('name', '?')}' has no component id.")
|
||||
return False
|
||||
child_ids = []
|
||||
for child in ordered_children:
|
||||
cid = _comp_id(child)
|
||||
if cid is None:
|
||||
_fail(f"'{child.get('name', '?')}' has no component id.")
|
||||
return False
|
||||
child_ids.append(cid)
|
||||
|
||||
client = ds.sox_client
|
||||
cached = client.cache_get(parent_id)
|
||||
if cached is not None and set(cached.children) != set(child_ids):
|
||||
_fail("The editor's list of children no longer matches the device's. "
|
||||
"Reconnect to read the device again.")
|
||||
return False
|
||||
try:
|
||||
client.reorder(parent_id, child_ids)
|
||||
return True
|
||||
except Exception as e:
|
||||
_fail(f"The device rejected the reorder: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Link / Unlink ───────────────────────────────────────────────
|
||||
|
||||
def _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name):
|
||||
|
||||
@@ -17,8 +17,10 @@ import live_hooks
|
||||
import manage_kits
|
||||
import palette
|
||||
import properties
|
||||
import put_kits_dialog
|
||||
import sax_file
|
||||
import schema
|
||||
import sox
|
||||
import undo
|
||||
import ox_page_view
|
||||
import user_service_view
|
||||
@@ -615,24 +617,39 @@ 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 _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:
|
||||
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
|
||||
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()
|
||||
elif slot_name in ("hibernate", "quit", "restart", "reboot"):
|
||||
do_device_disconnect()
|
||||
app_state.set_status(f"Invoked '{slot_name}' on device")
|
||||
elif goes_down:
|
||||
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():
|
||||
"""App > Get with File: backs the device's app up to a zip in <Sedona home>/store/apps.
|
||||
"""App > Get with Files: backs the device's app up to a zip in <Sedona home>/store/apps.
|
||||
|
||||
The app is saved on the device before its app.sab is read, as Ontrol's Niagara
|
||||
driver does, so unsaved changes are announced first and the backup can be called
|
||||
@@ -640,25 +657,44 @@ def do_get_with_file():
|
||||
"""
|
||||
ds = app_state.device_session
|
||||
if not app_state.live_mode or ds is None or not ds.is_connected:
|
||||
messagebox.showinfo("Get with File", "Connect to a device first.")
|
||||
messagebox.showinfo("Get with Files", "Connect to a device first.")
|
||||
return
|
||||
if not app_state.SEDONA_HOME:
|
||||
messagebox.showerror("Get with File", "The Sedona home folder is not set (Preferences).")
|
||||
messagebox.showerror("Get with Files", "The Sedona home folder is not set (Preferences).")
|
||||
return
|
||||
|
||||
if app_state.is_dirty and not messagebox.askokcancel(
|
||||
"Unsaved Changes",
|
||||
"The app has changes that are not saved on the device yet." + NL + NL +
|
||||
"Get with File saves the app on the device first, so they will be saved."):
|
||||
"Get with Files saves the app on the device first, so they will be saved."):
|
||||
return
|
||||
|
||||
path = _ask_backup_path(ds)
|
||||
if not path:
|
||||
return
|
||||
|
||||
def finish(result, error):
|
||||
if error is not None:
|
||||
app_state.set_status(f"Get with Files failed: {error}", warn=True)
|
||||
messagebox.showerror("Get with Files", f"The backup failed:{NL}{NL}{error}")
|
||||
return
|
||||
app_state.mark_clean() # the app was saved on the device first
|
||||
_report_backup(result)
|
||||
|
||||
_run_with_progress("Get with Files",
|
||||
lambda status: app_backup.get_with_file(ds.sox_client, path, status),
|
||||
finish)
|
||||
|
||||
|
||||
def _run_with_progress(title, work, finish):
|
||||
"""Runs work(status) off the main thread behind a small progress window.
|
||||
|
||||
status(text, fraction) updates the window; finish(result, error) then runs on the
|
||||
main thread, error being the exception work raised, or None.
|
||||
"""
|
||||
window = app_state.window
|
||||
dlg = tk.Toplevel(window)
|
||||
dlg.title("Get with File")
|
||||
dlg.title(title)
|
||||
dlg.resizable(False, False)
|
||||
dlg.transient(window)
|
||||
dlg.protocol("WM_DELETE_WINDOW", lambda: None) # a transfer cannot be stopped midway
|
||||
@@ -681,42 +717,42 @@ def do_get_with_file():
|
||||
bar.config(value=fraction)
|
||||
window.after(0, show)
|
||||
|
||||
def finish(result, error):
|
||||
def done(result, error):
|
||||
dlg.grab_release()
|
||||
dlg.destroy()
|
||||
if error is not None:
|
||||
app_state.set_status(f"Get with File failed: {error}", warn=True)
|
||||
messagebox.showerror("Get with File", f"The backup failed:{NL}{NL}{error}")
|
||||
return
|
||||
app_state.mark_clean() # the app was saved on the device first
|
||||
_report_backup(result)
|
||||
finish(result, error)
|
||||
|
||||
def work():
|
||||
def run():
|
||||
try:
|
||||
result = app_backup.get_with_file(ds.sox_client, path, status)
|
||||
result = work(status)
|
||||
except Exception as e:
|
||||
window.after(0, lambda e=e: finish(None, e))
|
||||
window.after(0, lambda e=e: done(None, e))
|
||||
else:
|
||||
window.after(0, lambda: finish(result, None))
|
||||
window.after(0, lambda: done(result, None))
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
def _ask_backup_path(ds):
|
||||
"""The save dialog for the zip: <Sedona home>/store/apps, the name filled in.
|
||||
def _device_app_names(ds):
|
||||
"""(deviceName, appName) of the device's app, or None for each it does not give.
|
||||
|
||||
The name takes the app's deviceName and appName, read off the device now -- the
|
||||
app component's config is not loaded until something asks for it.
|
||||
Read off the device now -- the app component's config is not loaded until
|
||||
something asks for it.
|
||||
"""
|
||||
device_name, app_name = app_state.device_host, "app"
|
||||
try:
|
||||
comp = ds.sox_client.cache_get(0)
|
||||
if comp is not None:
|
||||
ds.sox_client.update([comp], device_session.CONFIG)
|
||||
device_name = comp.values.get("deviceName") or device_name
|
||||
app_name = comp.values.get("appName") or app_name
|
||||
return comp.values.get("deviceName") or None, comp.values.get("appName") or None
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Could not read the app's names: {e}", warn=True)
|
||||
return None, None
|
||||
|
||||
|
||||
def _ask_backup_path(ds):
|
||||
"""The save dialog for the zip: <Sedona home>/store/apps, named after the app."""
|
||||
device_name, app_name = _device_app_names(ds)
|
||||
device_name, app_name = device_name or app_state.device_host, app_name or "app"
|
||||
|
||||
folder = app_backup.backup_folder()
|
||||
try:
|
||||
@@ -724,7 +760,7 @@ def _ask_backup_path(ds):
|
||||
except OSError:
|
||||
folder = app_state.SEDONA_HOME
|
||||
return filedialog.asksaveasfilename(
|
||||
title="Get with File",
|
||||
title="Get with Files",
|
||||
initialdir=folder,
|
||||
initialfile=app_backup.backup_file_name(device_name, app_name),
|
||||
defaultextension=".zip",
|
||||
@@ -748,7 +784,138 @@ def _report_backup(result):
|
||||
lines += ["", "No source for these in Sedona home's banks; only the device copy is in the zip:"]
|
||||
lines += [f" {name}" for name in result.missing_in_bank]
|
||||
app_state.set_status(f"Got {os.path.basename(result.path)}", warn=warn)
|
||||
(messagebox.showwarning if warn else messagebox.showinfo)("Get with File", NL.join(lines))
|
||||
(messagebox.showwarning if warn else messagebox.showinfo)("Get with Files", NL.join(lines))
|
||||
|
||||
|
||||
def do_put_with_files():
|
||||
"""App > Put with Files: puts a Get with Files zip back onto the connected device.
|
||||
|
||||
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:
|
||||
messagebox.showinfo("Put with Files", "Connect to a device first.")
|
||||
return
|
||||
if not app_state.SEDONA_HOME:
|
||||
messagebox.showerror("Put with Files", "The Sedona home folder is not set (Preferences).")
|
||||
return
|
||||
|
||||
folder = app_backup.backup_folder()
|
||||
path = filedialog.askopenfilename(
|
||||
title="Put with Files",
|
||||
initialdir=folder if os.path.isdir(folder) else app_state.SEDONA_HOME,
|
||||
filetypes=[("Sedona backup files", "*.zip"), ("All files", "*.*")],
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
|
||||
try:
|
||||
target = app_backup.device_target(ds)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Put with Files", f"Could not read the device:{NL}{NL}{e}")
|
||||
return
|
||||
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}"
|
||||
f"The backup is from {app_backup.platform_label(backup_platform)}; the device "
|
||||
f"is {app_backup.platform_label(target.platform)}.")
|
||||
return
|
||||
rebuild = put_kits_dialog.ask(app_state.window, rows)
|
||||
if rebuild is None:
|
||||
return
|
||||
|
||||
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(
|
||||
"Put with Files",
|
||||
f"The put failed:{NL}{NL}{error}{NL}{NL}" +
|
||||
("The new app is staged, but not all of its fonts and images are on the "
|
||||
"device. Put the backup again before restarting it."
|
||||
if plan.staged else
|
||||
"app.sab.stage was not written; the device keeps its current app."))
|
||||
return
|
||||
app_state.set_status(f"Put {os.path.basename(path)}; restart the device to take it up")
|
||||
if messagebox.askyesno(
|
||||
"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, disconnected")
|
||||
|
||||
_run_with_progress("Put with Files", prepare, planned)
|
||||
|
||||
|
||||
def _describe_put(plan):
|
||||
"""The confirmation for a put: what goes to the device, and what that replaces."""
|
||||
images = sum(f.device_uri.startswith("images/") for f in plan.media)
|
||||
fonts = [f for f in plan.media if f.device_uri.startswith("fonts/")]
|
||||
lines = [f"Put {os.path.basename(plan.path)} on {app_state.device_host}?", "",
|
||||
"The device's app is replaced; anything not saved on it is lost." +
|
||||
(" Its fonts and images are deleted and replaced by the backup's."
|
||||
if plan.has_screen else ""), "",
|
||||
f" app.sab, from {plan.app_source}"]
|
||||
if plan.scode_source:
|
||||
lines.append(f" kits.scode, {plan.scode_source}")
|
||||
lines += [f" {line}" for line in plan.kit_changes]
|
||||
if not plan.has_screen:
|
||||
return NL.join(lines + ([""] + plan.warnings if plan.warnings else []))
|
||||
lines.append(f" {images} images" +
|
||||
(f" ({plan.converted} made from the backup's image bank)"
|
||||
if plan.converted else ""))
|
||||
lines.append(f" {len(fonts)} fonts" +
|
||||
(f" ({len(plan.fonts_from_zip)} from the backup, the rest from Sedona home)"
|
||||
if plan.fonts_from_zip else " from Sedona home" if fonts else ""))
|
||||
if any(f.device_uri.startswith("system/") for f in plan.media):
|
||||
lines.append(" boot logo")
|
||||
for title, items in (("The backup has no usable copy of these images:",
|
||||
plan.images_missing),
|
||||
("Neither Sedona home nor the backup has these fonts:",
|
||||
plan.fonts_missing)):
|
||||
if items:
|
||||
lines += ["", title] + [f" {item}" for item in items]
|
||||
if plan.warnings:
|
||||
lines += [""] + plan.warnings
|
||||
return NL.join(lines)
|
||||
|
||||
|
||||
NL = chr(10) # newline, for the multi-line message boxes
|
||||
@@ -766,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",
|
||||
@@ -824,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):
|
||||
@@ -887,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"
|
||||
|
||||
|
||||
@@ -1089,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):
|
||||
@@ -1192,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
|
||||
@@ -1240,6 +1412,10 @@ def on_tree_right_click(event):
|
||||
if app_element is not None:
|
||||
tree_root_context_menu.add_command(
|
||||
label="Properties", command=lambda: show_properties(app_element))
|
||||
if _has_reorderable_children(app_element):
|
||||
tree_root_context_menu.add_command(
|
||||
label="Reorder...",
|
||||
command=lambda: open_reorder_dialog(current_element(app_element)))
|
||||
tree_root_context_menu.add_separator()
|
||||
if app_state.live_mode and app_state.device_session and app_state.device_session.is_connected:
|
||||
for action_name in _APP_ACTIONS:
|
||||
@@ -1263,6 +1439,12 @@ def on_tree_right_click(event):
|
||||
selection = [app_state.tree_element_map[row] for row in selected_rows
|
||||
if row in app_state.tree_element_map]
|
||||
fill_component_popup(tree_comp_context_menu, comp_element, selection)
|
||||
if _has_reorderable_children(comp_element):
|
||||
# After Rename..., with the other entries that change the component itself.
|
||||
rename_index = tree_comp_context_menu.index("Rename...")
|
||||
tree_comp_context_menu.insert_command(
|
||||
rename_index + 1, label="Reorder...",
|
||||
command=lambda: open_reorder_dialog(current_element(comp_element)))
|
||||
tree_comp_context_menu.post(event.x_root, event.y_root)
|
||||
|
||||
|
||||
@@ -1370,6 +1552,15 @@ def on_canvas_click(event):
|
||||
_toggle_canvas_box(group_tag)
|
||||
return
|
||||
|
||||
# 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)
|
||||
@@ -1379,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
|
||||
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()
|
||||
|
||||
@@ -1676,6 +1873,128 @@ def open_rename_dialog(comp):
|
||||
entry.select_range(0, tk.END)
|
||||
|
||||
|
||||
def _child_display_names(parent, children):
|
||||
"""Names for a reorder list. On a device a child not yet read is a "..." row, so
|
||||
its name is read from the device rather than shown as dots."""
|
||||
names = [child.get("name", "?") for child in children]
|
||||
if not (app_state.live_mode and "..." in names):
|
||||
return names
|
||||
ds = app_state.device_session
|
||||
try:
|
||||
ds.load_children(int(parent.get("id")))
|
||||
except Exception:
|
||||
return names
|
||||
for i, child in enumerate(children):
|
||||
if names[i] == "...":
|
||||
cached = ds.sox_client.cache_get(int(child.get("id", -1)))
|
||||
if cached is not None:
|
||||
names[i] = cached.name
|
||||
return names
|
||||
|
||||
|
||||
def open_reorder_dialog(parent):
|
||||
"""Lists a component's children to move up and down; OK applies the new order."""
|
||||
window = app_state.window
|
||||
original = [child for child in parent if child.tag == "comp"]
|
||||
if len(original) < 2:
|
||||
return
|
||||
names = dict(zip(map(id, original), _child_display_names(parent, original)))
|
||||
order = list(original)
|
||||
|
||||
dialog = tk.Toplevel(window)
|
||||
dialog.title(f"Reorder {parent.get('name', 'app')}")
|
||||
dialog.transient(window)
|
||||
dialog.grab_set()
|
||||
dialog.geometry(f"+{window.winfo_x() + 100}+{window.winfo_y() + 60}")
|
||||
|
||||
main_frame = tk.Frame(dialog, padx=10, pady=10)
|
||||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||||
main_frame.columnconfigure(0, weight=1)
|
||||
main_frame.rowconfigure(0, weight=1)
|
||||
|
||||
list_frame = tk.Frame(main_frame)
|
||||
list_frame.grid(row=0, column=0, sticky="nsew")
|
||||
listbox = tk.Listbox(list_frame, width=28, height=min(max(len(order), 10), 20),
|
||||
exportselection=False, activestyle="none")
|
||||
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=listbox.yview)
|
||||
listbox.configure(yscrollcommand=scrollbar.set)
|
||||
listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
side = tk.Frame(main_frame, padx=10)
|
||||
side.grid(row=0, column=1, sticky="n")
|
||||
up_button = ttk.Button(side, text="Move Up", width=14)
|
||||
up_button.pack(fill=tk.X)
|
||||
down_button = ttk.Button(side, text="Move Down", width=14)
|
||||
down_button.pack(fill=tk.X, pady=(6, 0))
|
||||
reset_button = ttk.Button(side, text="Reset", width=14)
|
||||
reset_button.pack(fill=tk.X, pady=(40, 0))
|
||||
|
||||
btn_frame = tk.Frame(main_frame)
|
||||
btn_frame.grid(row=1, column=0, columnspan=2, pady=(10, 0))
|
||||
ok_button = ttk.Button(btn_frame, text="OK", width=10)
|
||||
ok_button.pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(btn_frame, text="Cancel", width=10,
|
||||
command=dialog.destroy).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
def selected_index():
|
||||
sel = listbox.curselection()
|
||||
return sel[0] if sel else None
|
||||
|
||||
def update_buttons(*_):
|
||||
index = selected_index()
|
||||
up_button.state(["!disabled"] if index not in (None, 0) else ["disabled"])
|
||||
down_button.state(["!disabled"] if index not in (None, len(order) - 1)
|
||||
else ["disabled"])
|
||||
reset_button.state(["!disabled"] if order != original else ["disabled"])
|
||||
|
||||
def fill(select=None):
|
||||
listbox.delete(0, tk.END)
|
||||
for child in order:
|
||||
listbox.insert(tk.END, names[id(child)])
|
||||
if select is not None:
|
||||
listbox.selection_set(select)
|
||||
listbox.activate(select)
|
||||
listbox.see(select)
|
||||
update_buttons()
|
||||
|
||||
def move(step):
|
||||
index = selected_index()
|
||||
if index is None or not 0 <= index + step < len(order):
|
||||
return
|
||||
order[index], order[index + step] = order[index + step], order[index]
|
||||
fill(index + step)
|
||||
|
||||
def reset():
|
||||
# Back to the order the dialog opened with, keeping the same row selected.
|
||||
index = selected_index()
|
||||
picked = order[index] if index is not None else None
|
||||
order[:] = original
|
||||
fill(order.index(picked) if picked is not None else None)
|
||||
|
||||
def on_ok(*_):
|
||||
dialog.destroy()
|
||||
if sax_file.reorder_children(parent, order):
|
||||
refresh_all_views()
|
||||
|
||||
up_button.configure(command=lambda: move(-1))
|
||||
down_button.configure(command=lambda: move(1))
|
||||
reset_button.configure(command=reset)
|
||||
ok_button.configure(command=on_ok)
|
||||
listbox.bind("<<ListboxSelect>>", update_buttons)
|
||||
dialog.bind("<Alt-Up>", lambda _: move(-1))
|
||||
dialog.bind("<Alt-Down>", lambda _: move(1))
|
||||
dialog.bind("<Return>", on_ok)
|
||||
dialog.bind("<Escape>", lambda _: dialog.destroy())
|
||||
|
||||
fill(0)
|
||||
listbox.focus_set()
|
||||
|
||||
|
||||
def _has_reorderable_children(element):
|
||||
return sum(1 for child in element if child.tag == "comp") >= 2
|
||||
|
||||
|
||||
def fill_component_popup(menu, comp, selection=None):
|
||||
"""Fills a popup with the actions that apply to one component.
|
||||
|
||||
@@ -1787,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
|
||||
|
||||
@@ -1807,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))
|
||||
|
||||
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(group_tag, shift_x, shift_y)
|
||||
# Wires are not part of the dragged group, so they are re-routed against the
|
||||
canvas.move(entry["tag"], shift_x, shift_y)
|
||||
shifted = True
|
||||
|
||||
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
|
||||
@@ -1852,15 +2203,17 @@ 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:
|
||||
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)
|
||||
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']")
|
||||
@@ -1871,24 +2224,72 @@ def on_canvas_release(event):
|
||||
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()
|
||||
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.
|
||||
|
||||
@@ -2400,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())
|
||||
@@ -2456,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()
|
||||
@@ -2474,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()
|
||||
@@ -2652,8 +3078,10 @@ def _on_connected(ds, root_comp):
|
||||
app_state.window.title(app_state.base_window_title_live())
|
||||
menu_device.entryconfig("Disconnect", state="normal")
|
||||
menu_device.entryconfig("Download to SAX...", state="normal")
|
||||
menu_app_info.entryconfig("Get with File...", state="normal")
|
||||
app_state.set_status(f"Connected to {app_state.device_host}:{app_state.device_port}")
|
||||
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}"
|
||||
f" ({ds.seed_info})")
|
||||
|
||||
sax_root = ds.build_dom_tree(root_comp)
|
||||
app_state.xml_root_element = sax_root
|
||||
@@ -2696,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:
|
||||
@@ -2706,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
|
||||
@@ -2719,7 +3148,8 @@ def do_device_disconnect():
|
||||
menu_device.entryconfig("Discover...", state="normal")
|
||||
menu_device.entryconfig("Disconnect", state="disabled")
|
||||
menu_device.entryconfig("Download to SAX...", state="disabled")
|
||||
menu_app_info.entryconfig("Get with File...", state="disabled")
|
||||
menu_app_info.entryconfig("Get with Files...", state="disabled")
|
||||
menu_app_info.entryconfig("Put with Files...", state="disabled")
|
||||
app_state.set_status("Disconnected")
|
||||
|
||||
sax_file.reset_to_startup_state()
|
||||
@@ -2795,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():
|
||||
@@ -2925,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)
|
||||
@@ -3007,7 +3447,8 @@ menu_bar.add_cascade(label="Kits", menu=menu_kits)
|
||||
menu_app_info = tk.Menu(menu_bar, tearoff=0)
|
||||
menu_app_info.add_command(label="App Info", command=show_app_info)
|
||||
menu_app_info.add_separator()
|
||||
menu_app_info.add_command(label="Get with File...", command=do_get_with_file, state="disabled")
|
||||
menu_app_info.add_command(label="Get with Files...", command=do_get_with_file, state="disabled")
|
||||
menu_app_info.add_command(label="Put with Files...", command=do_put_with_files, state="disabled")
|
||||
menu_bar.add_cascade(label="App", menu=menu_app_info)
|
||||
|
||||
menu_prefs = tk.Menu(menu_bar, tearoff=0)
|
||||
@@ -3418,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)
|
||||
@@ -3436,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
@@ -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()
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""The kits step of App > Put with Files, laid out as the Manage Kits panel is.
|
||||
|
||||
The same groups (kits the app needs, kits declared without being used, kits available,
|
||||
kits not supported), the same tick column and the same Action cell: "Keep at",
|
||||
"Upgrade to", "Downgrade to", "Add at", "REMOVE". Installed is what the device runs;
|
||||
Backup is what the backup's app was saved with.
|
||||
|
||||
What is ticked goes: into app.sab's schema and, when that is not the device's kits,
|
||||
into a kits.scode built for them (Ontrol's PutApp does the same). "Rebuild kits.scode"
|
||||
forces the build when nothing changed.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
import app_backup
|
||||
import manage_kits
|
||||
from manage_kits import NOT_DECLARED, SECTION_TAG, column_label, make_checkbox_image
|
||||
|
||||
REMOVE_ACTION = "REMOVE from device"
|
||||
|
||||
|
||||
def _options(row):
|
||||
"""The Action choices for a row, as (label, checksum) pairs."""
|
||||
if row.device:
|
||||
return [(label, checksum or row.device) for label, checksum in
|
||||
manage_kits.action_options(row.name, row.device, True, row.builds)]
|
||||
return [(f"Add at {manage_kits.build_label(v, c)}", c) for v, c in row.builds]
|
||||
|
||||
|
||||
def _build_text(row, checksum):
|
||||
if not checksum:
|
||||
return NOT_DECLARED
|
||||
version = next((v for v, c in row.builds if c == checksum), "")
|
||||
return column_label(version, checksum)
|
||||
|
||||
|
||||
class _Dialog:
|
||||
def __init__(self, parent, rows, title):
|
||||
self.rows = rows
|
||||
self.result = None
|
||||
self.original = [(r.included, r.use) for r in rows]
|
||||
|
||||
self.top = top = tk.Toplevel(parent)
|
||||
top.title(f"{title} - Kits")
|
||||
top.transient(parent)
|
||||
top.grab_set()
|
||||
top.geometry(f"+{parent.winfo_x() + 60}+{parent.winfo_y() + 40}")
|
||||
frame = ttk.Frame(top, padding=10)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
columns = ("name", "installed", "backup", "latest", "action")
|
||||
table_frame = ttk.Frame(frame)
|
||||
table_frame.pack(fill=tk.BOTH, expand=True)
|
||||
self.table = ttk.Treeview(table_frame, columns=columns, show="tree headings",
|
||||
selectmode="browse", height=20)
|
||||
self.table.heading("#0", text="")
|
||||
self.table.column("#0", width=32, minwidth=32, stretch=False, anchor="center")
|
||||
for column, heading, width in [("name", "Kit name", 190),
|
||||
("installed", "Installed", 105),
|
||||
("backup", "Backup", 105),
|
||||
("latest", "Latest", 105),
|
||||
("action", "Action", 215)]:
|
||||
self.table.heading(column, text=heading, anchor="w")
|
||||
self.table.column(column, width=width, minwidth=width, anchor="w", stretch=False)
|
||||
vscroll = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.table.yview)
|
||||
self.table.configure(yscrollcommand=vscroll.set)
|
||||
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
self.table.pack(fill=tk.BOTH, expand=True)
|
||||
self.table.tag_configure(SECTION_TAG, background="#dcdcdc")
|
||||
|
||||
self.boxes = {(checked, enabled): make_checkbox_image(checked, enabled)
|
||||
for checked in (True, False) for enabled in (True, False)}
|
||||
self.cross_box = make_checkbox_image(False, False, cross=True)
|
||||
|
||||
locked = any(r.locked for r in rows)
|
||||
hint = ("The backup holds no app.sax, so its app goes with the kits it was saved "
|
||||
"with." if locked else
|
||||
"A tick means the kit goes to the device. Grey ticks are needed by the app "
|
||||
"and cannot be removed; untick another to remove it, tick one to add it. "
|
||||
"Click the Action cell to choose a build. Installed is the device's build. "
|
||||
"Kits other than the device's mean a new kits.scode.")
|
||||
ttk.Label(frame, text=hint, foreground="#666666", wraplength=780
|
||||
).pack(anchor="w", pady=(8, 0))
|
||||
|
||||
self.rebuild = tk.BooleanVar(value=False)
|
||||
self.rebuild_check = ttk.Checkbutton(frame, text="Rebuild kits.scode",
|
||||
variable=self.rebuild)
|
||||
self.rebuild_check.pack(anchor="w", pady=(6, 0))
|
||||
|
||||
button_row = ttk.Frame(frame)
|
||||
button_row.pack(fill=tk.X, pady=(10, 0))
|
||||
upgrade = ttk.Button(button_row, text="Upgrade all kits to latest version",
|
||||
command=self._upgrade_all)
|
||||
upgrade.pack(side=tk.LEFT)
|
||||
if locked:
|
||||
upgrade.state(["disabled"])
|
||||
ttk.Button(button_row, text="Cancel", command=lambda: self._close(False)
|
||||
).pack(side=tk.RIGHT)
|
||||
ttk.Button(button_row, text="Next", command=lambda: self._close(True)
|
||||
).pack(side=tk.RIGHT, padx=6)
|
||||
|
||||
self.row_by_item = {}
|
||||
self._editor = None
|
||||
self.table.bind("<Button-1>", self._on_click)
|
||||
self.table.bind("<MouseWheel>", lambda _e: self._close_editor(), add="+")
|
||||
top.protocol("WM_DELETE_WINDOW", lambda: self._close(False))
|
||||
self._rebuild_table()
|
||||
|
||||
# --- the table ---
|
||||
|
||||
def _action_text(self, row):
|
||||
if row.unsupported:
|
||||
return "Not supported"
|
||||
if not row.included:
|
||||
return REMOVE_ACTION if row.device else NOT_DECLARED
|
||||
return next((label for label, c in _options(row) if c == row.use),
|
||||
_build_text(row, row.use))
|
||||
|
||||
def _values(self, row):
|
||||
latest = row.builds[0] if row.builds else None
|
||||
return (row.name, _build_text(row, row.device), _build_text(row, row.backup),
|
||||
column_label(*latest) if latest else NOT_DECLARED, self._action_text(row))
|
||||
|
||||
def _image(self, row):
|
||||
if row.unsupported:
|
||||
return self.cross_box
|
||||
return self.boxes[(row.included, not (row.required or row.locked))]
|
||||
|
||||
def _rebuild_table(self):
|
||||
self._close_editor()
|
||||
top = self.table.yview()[0]
|
||||
self.table.delete(*self.table.get_children())
|
||||
self.row_by_item.clear()
|
||||
for title in app_backup.KIT_GROUPS:
|
||||
members = [r for r in self.rows if app_backup.group(r) == title]
|
||||
if not members:
|
||||
continue
|
||||
self.table.insert("", "end", values=(title, "", "", "", ""), tags=(SECTION_TAG,))
|
||||
for row in members:
|
||||
item = self.table.insert("", "end", image=self._image(row),
|
||||
values=self._values(row))
|
||||
self.row_by_item[item] = row
|
||||
self.table.yview_moveto(top)
|
||||
|
||||
# Kits other than the device's cannot run on its kits.scode.
|
||||
changed = any(app_backup.kit_change(r) for r in self.rows)
|
||||
if changed:
|
||||
self.rebuild.set(True)
|
||||
self.rebuild_check.state(["disabled"])
|
||||
else:
|
||||
self.rebuild_check.state(["!disabled"])
|
||||
|
||||
def _changed(self):
|
||||
app_backup.settle_required(self.rows)
|
||||
self._rebuild_table()
|
||||
|
||||
# --- editing ---
|
||||
|
||||
def _close_editor(self):
|
||||
if self._editor is not None:
|
||||
self._editor.destroy()
|
||||
self._editor = None
|
||||
|
||||
def _on_click(self, event):
|
||||
self._close_editor()
|
||||
if self.table.identify_region(event.x, event.y) not in ("tree", "cell"):
|
||||
return
|
||||
item = self.table.identify_row(event.y)
|
||||
row = self.row_by_item.get(item)
|
||||
if row is None or row.locked or row.unsupported:
|
||||
return
|
||||
column = self.table.identify_column(event.x)
|
||||
if column == "#0":
|
||||
self._toggle(row)
|
||||
elif column == "#5":
|
||||
self._edit_action(item, row)
|
||||
|
||||
def _toggle(self, row):
|
||||
if row.required:
|
||||
return
|
||||
if not row.included and not row.builds:
|
||||
messagebox.showwarning("Put with Files", f"Sedona home holds no build of "
|
||||
f"'{row.name}' to add it at.", parent=self.top)
|
||||
return
|
||||
row.included = not row.included
|
||||
if row.included and not row.use:
|
||||
row.use = row.latest
|
||||
self._changed()
|
||||
|
||||
def _edit_action(self, item, row):
|
||||
options = _options(row)
|
||||
if not options:
|
||||
return
|
||||
cell = self.table.bbox(item, "action")
|
||||
if not cell:
|
||||
return
|
||||
labels = [label for label, _ in options]
|
||||
variable = tk.StringVar(value=self._action_text(row))
|
||||
combo = ttk.Combobox(self.table, textvariable=variable, values=labels,
|
||||
state="readonly")
|
||||
combo.place(x=cell[0], y=cell[1], width=cell[2], height=cell[3])
|
||||
self._editor = combo
|
||||
|
||||
def commit(_event=None):
|
||||
if variable.get() in labels:
|
||||
row.use = options[labels.index(variable.get())][1]
|
||||
row.included = True
|
||||
self._close_editor()
|
||||
self._changed()
|
||||
|
||||
combo.bind("<<ComboboxSelected>>", commit)
|
||||
combo.bind("<Escape>", lambda _e: self._close_editor())
|
||||
combo.focus_set()
|
||||
combo.after_idle(lambda: combo.event_generate("<Down>"))
|
||||
|
||||
def _upgrade_all(self):
|
||||
for row in self.rows:
|
||||
if row.included and row.latest and not row.locked:
|
||||
row.use = row.latest
|
||||
self._changed()
|
||||
|
||||
def _close(self, ok):
|
||||
self._close_editor()
|
||||
if ok:
|
||||
self.result = bool(self.rebuild.get())
|
||||
else:
|
||||
for row, (included, use) in zip(self.rows, self.original):
|
||||
row.included, row.use = included, use
|
||||
app_backup.settle_required(self.rows)
|
||||
self.top.grab_release()
|
||||
self.top.destroy()
|
||||
|
||||
|
||||
def ask(parent, rows, title="Put with Files"):
|
||||
"""Shows the kits step; returns whether to rebuild kits.scode, or None when cancelled.
|
||||
|
||||
The rows are updated in place with what was chosen.
|
||||
"""
|
||||
dialog = _Dialog(parent, rows, title)
|
||||
dialog.top.wait_window()
|
||||
return dialog.result
|
||||
+220
-1
@@ -1,4 +1,5 @@
|
||||
"""Reads a device's app.sab and writes it back out as app.sax.
|
||||
"""Reads a device's app.sab and writes it back out as app.sax, and encodes an app.sax
|
||||
into app.sab (encode_sax).
|
||||
|
||||
Ported from Py_sab2sax (sax.py/sab.py), itself a port of sedona.offline.OfflineApp
|
||||
(decodeAppBinary + encodeAppXml); its output matches `sedonac app.sab` byte for byte,
|
||||
@@ -79,6 +80,9 @@ def _resolve_slots(qname, types_by_q, cache):
|
||||
"""The type's slots, base chain first, indexed by slot id."""
|
||||
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 []
|
||||
@@ -408,3 +412,218 @@ def _path_of(c):
|
||||
parts.append(c["name"])
|
||||
c = c["parent"]
|
||||
return "/" + "/".join(reversed(parts)) if parts else "/"
|
||||
|
||||
|
||||
# --- encoding app.sax to app.sab ------------------------------------------------------
|
||||
#
|
||||
# Ported from Py_sab2sax's sab.py (sedona.offline.OfflineApp decodeAppXml +
|
||||
# encodeAppBinary), with the value parsing brought in line with the Java Value classes:
|
||||
# asStr text is unescaped as TextUtil.fromLiteral does, "nullbool"/"nullfloat" are
|
||||
# read, and integers take Integer.decode's '#' hex and leading-zero octal.
|
||||
|
||||
def _decode_int(raw):
|
||||
"""Integer.decode."""
|
||||
if raw in (None, "", "null"):
|
||||
return 0
|
||||
s = raw.strip()
|
||||
neg = s.startswith("-")
|
||||
if s[:1] in "+-":
|
||||
s = s[1:]
|
||||
if s[:2].lower() == "0x":
|
||||
v = int(s[2:], 16)
|
||||
elif s.startswith("#"):
|
||||
v = int(s[1:], 16)
|
||||
elif len(s) > 1 and s.startswith("0"):
|
||||
v = int(s[1:], 8)
|
||||
else:
|
||||
v = int(s)
|
||||
return -v if neg else v
|
||||
|
||||
|
||||
def _from_literal(s):
|
||||
"""TextUtil.fromLiteral."""
|
||||
if "\\" not in s:
|
||||
return s
|
||||
escapes = {"0": "\0", "n": "\n", "r": "\r", "t": "\t", "\\": "\\", '"': '"', "$": "$"}
|
||||
out, i = [], 0
|
||||
while i < len(s):
|
||||
c = s[i]
|
||||
if c == "\\":
|
||||
i += 1
|
||||
if i >= len(s) or s[i] not in escapes:
|
||||
raise SabError(f"unknown escape sequence in {s!r}")
|
||||
c = escapes[s[i]]
|
||||
out.append(c)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
class _Writer:
|
||||
def __init__(self):
|
||||
self.b = bytearray()
|
||||
|
||||
def u1(self, v): self.b.append(v & 0xFF)
|
||||
def u2(self, v): self.b += struct.pack(">H", v & 0xFFFF)
|
||||
def i4(self, v): self.b += struct.pack(">i", ((v + 2**31) % 2**32) - 2**31)
|
||||
def i8(self, v): self.b += struct.pack(">q", v)
|
||||
def f4(self, v): self.b += struct.pack(">f", _to_f32(v))
|
||||
def f8(self, v): self.b += struct.pack(">d", v)
|
||||
|
||||
def str_(self, s):
|
||||
self.b += s.encode("ascii")
|
||||
self.u1(0)
|
||||
|
||||
|
||||
def _encode_value(out, slot, raw):
|
||||
"""One config prop, from its sax text (or the manifest default when absent)."""
|
||||
vtype, flags = slot.type, slot.flags
|
||||
if "s" in flags: # asStr Buf: u2 len (incl NUL)
|
||||
data = _from_literal(raw or "").encode("ascii")
|
||||
out.u2(len(data) + 1)
|
||||
out.b += data
|
||||
out.u1(0)
|
||||
elif vtype == "bool":
|
||||
if raw in ("null", "nullbool"):
|
||||
out.u1(2)
|
||||
elif raw in (None, "", "false", "true"):
|
||||
out.u1(1 if raw == "true" else 0)
|
||||
else:
|
||||
raise SabError(f"invalid bool {raw!r}")
|
||||
elif vtype == "byte":
|
||||
out.u1(_decode_int(raw))
|
||||
elif vtype == "short":
|
||||
out.u2(_decode_int(raw))
|
||||
elif vtype == "int":
|
||||
out.i4(_decode_int(raw))
|
||||
elif vtype == "long":
|
||||
out.i8(_decode_int(raw))
|
||||
elif vtype in ("float", "double"):
|
||||
v = float("nan") if raw in ("null", "nullfloat", "nulldouble") else _flt(raw)
|
||||
(out.f4 if vtype == "float" else out.f8)(v)
|
||||
elif vtype in ("sys::Buf", "buf"):
|
||||
data = base64.b64decode(raw) if raw else b""
|
||||
out.u2(len(data))
|
||||
out.b += data
|
||||
else:
|
||||
raise SabError(f"unhandled slot type {vtype}")
|
||||
|
||||
|
||||
def encode_sax(sax, checksums=None, kits=None):
|
||||
"""app.sax (text or bytes) -> app.sab bytes, as `sedonac app.sax` writes it.
|
||||
|
||||
checksums, {kit name: checksum hex}, stands in for the sax's own -- how an app goes
|
||||
to a device whose kits are other builds than the ones it was saved with. kits,
|
||||
[(name, checksum hex)], replaces the schema altogether, as Ontrol's PutApp rewrites
|
||||
it for the kits chosen to put. Every kit's manifest must be in Sedona home under
|
||||
the checksum used.
|
||||
"""
|
||||
root = ET.fromstring(sax)
|
||||
schema_elem, app_elem = root.find("schema"), root.find("app")
|
||||
if schema_elem is None or app_elem is None:
|
||||
raise SabError("not a Sedona app: no <schema> or <app>")
|
||||
if kits is None:
|
||||
kits = [(k.get("name"), (checksums or {}).get(k.get("name")) or k.get("checksum"))
|
||||
for k in schema_elem.findall("kit")]
|
||||
parts = []
|
||||
for name, cks in kits:
|
||||
if not cks:
|
||||
raise SabError(f"no checksum for kit '{name}'")
|
||||
parts.append((name, f"{int(cks, 16):08x}"))
|
||||
parts.sort(key=lambda p: ("" if p[0] == "sys" else "\x01" + p[0])) # sortKits
|
||||
for name, cks in parts:
|
||||
path = schema.find_manifest_path(name, cks)
|
||||
if path is None or not path.endswith(f"-{cks}.xml"):
|
||||
raise SabError(f"no manifest for kit '{name}' {cks} in Sedona home")
|
||||
kits, types_by_q = _load_kits(parts)
|
||||
type_ids = {}
|
||||
for ki, ((kname, _), tlist) in enumerate(zip(parts, kits)):
|
||||
for t in tlist:
|
||||
type_ids[f"{kname}::{t.get('name')}"] = (ki, int(t.get("id")))
|
||||
cache = {}
|
||||
|
||||
comps = []
|
||||
|
||||
def walk(elem, qtype, parent):
|
||||
if qtype not in type_ids:
|
||||
raise SabError(f"unknown type '{qtype}' ({elem.get('name')})")
|
||||
c = {"elem": elem, "type": qtype, "parent": parent, "kids": [],
|
||||
"id": int(elem.get("id")) if elem.get("id") else None}
|
||||
comps.append(c)
|
||||
if parent is not None:
|
||||
parent["kids"].append(c)
|
||||
for kid in elem.findall("comp"):
|
||||
walk(kid, kid.get("type"), c)
|
||||
return c
|
||||
|
||||
app = walk(app_elem, "sys::App", None)
|
||||
app["id"] = 0
|
||||
used = {c["id"] for c in comps if c["id"] is not None}
|
||||
next_id = 1
|
||||
for c in comps: # ids for comps the sax gives none
|
||||
if c["id"] is None:
|
||||
while next_id in used:
|
||||
next_id += 1
|
||||
c["id"] = next_id
|
||||
used.add(next_id)
|
||||
by_id = {}
|
||||
for c in comps:
|
||||
if c["id"] in by_id:
|
||||
raise SabError(f"duplicate id: {c['id']}")
|
||||
by_id[c["id"]] = c
|
||||
|
||||
out = _Writer()
|
||||
out.i4(0x73617070) # "sapp"
|
||||
out.i4(0x0003) # version 0.3
|
||||
out.u1(len(parts))
|
||||
for name, cks in parts:
|
||||
out.str_(name)
|
||||
out.i4(int(cks, 16))
|
||||
out.u2(max(by_id))
|
||||
|
||||
for cid in sorted(by_id):
|
||||
c = by_id[cid]
|
||||
ki, ti = type_ids[c["type"]]
|
||||
out.u2(cid)
|
||||
out.u1(ki)
|
||||
out.u1(ti)
|
||||
out.str_(c["elem"].get("name", "app") if cid else "app")
|
||||
out.u2(c["parent"]["id"] if c["parent"] else 0xFFFF)
|
||||
out.u2(c["kids"][0]["id"] if c["kids"] else 0xFFFF)
|
||||
sibling = 0xFFFF
|
||||
if c["parent"]:
|
||||
kids = c["parent"]["kids"]
|
||||
i = kids.index(c)
|
||||
if i + 1 < len(kids):
|
||||
sibling = kids[i + 1]["id"]
|
||||
out.u2(sibling)
|
||||
props = {p.get("name"): p.get("val") for p in c["elem"].findall("prop")}
|
||||
for slot in _resolve_slots(c["type"], types_by_q, cache):
|
||||
if "a" in slot.flags or "c" not in slot.flags:
|
||||
continue # config props only
|
||||
_encode_value(out, slot, props.get(slot.name, slot.default))
|
||||
out.u1(ord(";"))
|
||||
out.u2(0xFFFF)
|
||||
|
||||
def ref(text):
|
||||
path, slot_name = text.rsplit(".", 1)
|
||||
cur = app
|
||||
for part in (p for p in path.split("/") if p):
|
||||
cur = next((k for k in cur["kids"] if k["elem"].get("name") == part), None)
|
||||
if cur is None:
|
||||
raise SabError(f"link to a missing component: {text}")
|
||||
names = [s.name for s in _resolve_slots(cur["type"], types_by_q, cache)]
|
||||
if slot_name not in names:
|
||||
raise SabError(f"link to a missing slot: {text}")
|
||||
return cur["id"], names.index(slot_name)
|
||||
|
||||
links = root.find("links")
|
||||
for link in (links.findall("link") if links is not None else ()):
|
||||
fc, fs = ref(link.get("from"))
|
||||
tc, ts = ref(link.get("to"))
|
||||
out.u2(fc)
|
||||
out.u1(fs)
|
||||
out.u2(tc)
|
||||
out.u1(ts)
|
||||
out.u2(0xFFFF)
|
||||
out.u1(ord("."))
|
||||
return bytes(out.b)
|
||||
|
||||
+39
-2
@@ -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()
|
||||
@@ -483,6 +483,43 @@ def rename_component(comp_element, new_name):
|
||||
return True
|
||||
|
||||
|
||||
def reorder_children(parent, new_order):
|
||||
"""Gives `parent`'s child components a new order, which is the order they run in.
|
||||
|
||||
Returns True when the order changed. On a device the new order goes there first,
|
||||
and the file is left alone if it does not.
|
||||
"""
|
||||
root = app_state.xml_root_element
|
||||
if root is None:
|
||||
return False
|
||||
old_order = [child for child in parent if child.tag == "comp"]
|
||||
new_order = list(new_order)
|
||||
if new_order == old_order:
|
||||
return False
|
||||
if (len(new_order) != len(old_order)
|
||||
or {id(c) for c in new_order} != {id(c) for c in old_order}):
|
||||
return False
|
||||
|
||||
name = parent.get("name", "app")
|
||||
if app_state.live_mode:
|
||||
if not live_hooks.live_reorder_children(parent, new_order):
|
||||
live_hooks.warn_failure("Reorder", f"{name} was not reordered on the device")
|
||||
return False
|
||||
|
||||
undo.apply_child_order(parent, new_order)
|
||||
command_class = (undo.LiveReorderChildrenCommand if app_state.live_mode
|
||||
else undo.ReorderChildrenCommand)
|
||||
undo.push(command_class(parent, old_order, new_order, label=f"reorder {name}"))
|
||||
|
||||
app_state.mark_dirty()
|
||||
refresh_treeview_from_dom(root)
|
||||
wiresheet.render_current_sheet()
|
||||
|
||||
where = " on the device" if app_state.live_mode else ""
|
||||
app_state.set_status(f"{name} reordered{where}.")
|
||||
return True
|
||||
|
||||
|
||||
def allocate_component_id():
|
||||
"""Lowest unused id, matching how this app's ids are dense with no gaps."""
|
||||
used = set()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
@@ -384,7 +386,7 @@ class SoxClient:
|
||||
# file transfer: one at a time; its chunks arrive unasked, so the receiver
|
||||
# hands them to whichever transfer is open
|
||||
self._xfer_lock = threading.Lock()
|
||||
self._xfer = None # _FileGet while a get is open
|
||||
self._xfer = None # _FileGet or _FilePut while a transfer is open
|
||||
|
||||
# ── Connect / Disconnect ────────────────────────────────────
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
@@ -989,19 +991,90 @@ class SoxClient:
|
||||
progress(file_size, file_size)
|
||||
return bytes(data)
|
||||
|
||||
def put_file(self, uri, data, progress=None):
|
||||
"""Writes `data` to the device as `uri`.
|
||||
|
||||
Ported from FileTransfer.putFile, as Ontrol's BSoxFilePut does it: an 'f'
|
||||
request opens the transfer, the chunks go out as 'k' messages -- the DASP send
|
||||
window paces them -- and the device answers the last with an unsolicited 'z'
|
||||
once the file is closed. progress(done_bytes, total_bytes) is called from this
|
||||
thread.
|
||||
"""
|
||||
if self.is_closed:
|
||||
raise SoxException("SoxClient closed")
|
||||
with self._xfer_lock:
|
||||
# 21, not FileTransfer's 18: room for Orion's message-length header too.
|
||||
chunk_size = max(16, self._session.ideal_max - 21)
|
||||
req = _prepare_request(ord('f'))
|
||||
req.write_str("p")
|
||||
req.write_str(uri)
|
||||
req.write_i4(len(data))
|
||||
req.write_u2(chunk_size)
|
||||
req.write_u1(0) # end of headers
|
||||
|
||||
xfer = _FilePut()
|
||||
self._xfer = xfer
|
||||
opened = False
|
||||
try:
|
||||
res = self.request(req)
|
||||
res.check_response('F')
|
||||
opened = True
|
||||
file_size = res.i4()
|
||||
chunk_size = res.u2()
|
||||
if file_size != len(data) or chunk_size <= 0:
|
||||
raise SoxException(
|
||||
f"The device opened {uri} for {file_size} bytes in chunks of "
|
||||
f"{chunk_size}; {len(data)} bytes were offered")
|
||||
num_chunks = max(1, -(-file_size // chunk_size))
|
||||
for num in range(num_chunks):
|
||||
if self.is_closed:
|
||||
raise SoxException(f"Session closed while writing {uri}")
|
||||
chunk = data[num * chunk_size:(num + 1) * chunk_size]
|
||||
msg = _prepare_request(ord('k'), 0)
|
||||
msg.write_u2(num)
|
||||
msg.write_u2(len(chunk))
|
||||
msg.write_bytes(chunk)
|
||||
self._session.send(msg.to_bytes())
|
||||
if progress:
|
||||
progress(min(file_size, (num + 1) * chunk_size), file_size)
|
||||
|
||||
timeout = getattr(self._session, "receive_timeout", 30.0) or 30.0
|
||||
deadline = time.monotonic() + timeout
|
||||
while not xfer.closed.wait(0.5):
|
||||
if self.is_closed:
|
||||
raise SoxException(f"Session closed while writing {uri}")
|
||||
if time.monotonic() > deadline:
|
||||
raise SoxException(f"Timed out waiting for the device to close {uri}")
|
||||
opened = False
|
||||
finally:
|
||||
self._xfer = None
|
||||
# Left open, the transfer keeps the device answering "busy" to the next.
|
||||
if opened and not self.is_closed:
|
||||
try:
|
||||
self.request(_prepare_request(ord('z')))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def rename_file(self, from_uri, to_uri):
|
||||
"""Renames a file on the device ('b'), as SoxClient.renameFile."""
|
||||
req = _prepare_request(ord('b'))
|
||||
req.write_str(from_uri)
|
||||
req.write_str(to_uri)
|
||||
self.request(req).check_response('B')
|
||||
|
||||
# ── Networking ──────────────────────────────────────────────
|
||||
|
||||
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):
|
||||
@@ -1014,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:
|
||||
@@ -1036,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
|
||||
@@ -1111,6 +1184,13 @@ class _FileGet:
|
||||
# SoxReceiver — daemon thread
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FilePut:
|
||||
"""The put in progress; `closed` is set when the device's 'z' arrives."""
|
||||
|
||||
def __init__(self):
|
||||
self.closed = threading.Event()
|
||||
|
||||
|
||||
class _SoxReceiver(threading.Thread):
|
||||
def __init__(self, client):
|
||||
super().__init__(name="SoxReceiver", daemon=True)
|
||||
@@ -1136,10 +1216,12 @@ class _SoxReceiver(threading.Thread):
|
||||
client._receive_event(msg)
|
||||
elif cmd == ord('k'):
|
||||
xfer = client._xfer
|
||||
if xfer is not None:
|
||||
if isinstance(xfer, _FileGet):
|
||||
xfer.receive_chunk(msg)
|
||||
elif cmd == ord('z'):
|
||||
pass # close of a put -- the editor only gets files
|
||||
xfer = client._xfer # the device closing a put
|
||||
if isinstance(xfer, _FilePut):
|
||||
xfer.closed.set()
|
||||
else:
|
||||
client._receive_response(msg)
|
||||
except DaspException:
|
||||
|
||||
@@ -378,3 +378,44 @@ class LiveRenameCommand(RenameComponentCommand):
|
||||
raise LiveUndoError(f"{self.old_name} was not renamed to {self.new_name} "
|
||||
"on the device")
|
||||
super().redo()
|
||||
|
||||
|
||||
def apply_child_order(parent, ordered_children):
|
||||
"""Puts `parent`'s <comp> children in the given order; its other children stay put."""
|
||||
children = list(parent)
|
||||
comp_slots = [i for i, child in enumerate(children) if child.tag == "comp"]
|
||||
for slot, child in zip(comp_slots, ordered_children):
|
||||
children[slot] = child
|
||||
parent[:] = children
|
||||
|
||||
|
||||
class ReorderChildrenCommand:
|
||||
"""A new order for a component's children."""
|
||||
|
||||
def __init__(self, parent, old_order, new_order, label="reorder"):
|
||||
self.parent = parent
|
||||
self.old_order = list(old_order)
|
||||
self.new_order = list(new_order)
|
||||
self.label = label
|
||||
|
||||
def undo(self):
|
||||
apply_child_order(self.parent, self.old_order)
|
||||
|
||||
def redo(self):
|
||||
apply_child_order(self.parent, self.new_order)
|
||||
|
||||
|
||||
class LiveReorderChildrenCommand(ReorderChildrenCommand):
|
||||
"""A reorder on the device: the old order goes back there before the DOM takes it."""
|
||||
|
||||
def undo(self):
|
||||
if not live_hooks.live_reorder_children(self.parent, self.old_order):
|
||||
raise LiveUndoError(f"The old order of {self.parent.get('name', 'app')} "
|
||||
"could not be put back on the device")
|
||||
super().undo()
|
||||
|
||||
def redo(self):
|
||||
if not live_hooks.live_reorder_children(self.parent, self.new_order):
|
||||
raise LiveUndoError(f"{self.parent.get('name', 'app')} was not reordered "
|
||||
"on the device")
|
||||
super().redo()
|
||||
|
||||
+584
-80
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user