Compare commits
142
Commits
0166be3454
...
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 | ||
|
|
9e8d737fac | ||
|
|
67fbfe03dd | ||
|
|
d0454688b3 | ||
|
|
8a0051380a | ||
|
|
0c846fcc96 | ||
|
|
91d92d6525 | ||
|
|
b074531795 | ||
|
|
f5880d1b1a | ||
|
|
d036ee206d | ||
|
|
4ab4b4d588 | ||
|
|
bc4e28d3f7 | ||
|
|
b63655462f | ||
|
|
8b21c5f389 | ||
|
|
8411f0c9ae | ||
|
|
b98fe828da | ||
|
|
fd22eb314d | ||
|
|
96a703c5d5 | ||
|
|
05ca7e9035 | ||
|
|
c8c68f7b31 | ||
|
|
5acca522bf | ||
|
|
2880c2a6f2 | ||
|
|
6b23bafffc | ||
|
|
894fdb7eb5 | ||
|
|
9b1d029b14 | ||
|
|
dbeeb78916 | ||
|
|
85b6fa62f3 | ||
|
|
f22d4852c9 | ||
|
|
088fe69964 | ||
|
|
a2d4be6ea8 | ||
|
|
f15fc42363 | ||
|
|
35ab61cefa | ||
|
|
73d168fd87 | ||
|
|
a9593de065 | ||
|
|
fd35e5c9bb | ||
|
|
6a52603951 | ||
|
|
c01a192365 | ||
|
|
138d9927be | ||
|
|
691bedd542 | ||
|
|
7b8c48f7c2 | ||
|
|
e5cb04b8cf | ||
|
|
43d4849533 | ||
|
|
f241448a5b | ||
|
|
f3446f32ad | ||
|
|
7ca029e3ea | ||
|
|
26f9abb0dd | ||
|
|
b0e769075f | ||
|
|
fd951f5b82 | ||
|
|
d60b349cac | ||
|
|
b0eb859f32 | ||
|
|
74c169a401 | ||
|
|
cbf1010c16 | ||
|
|
9e2c063880 | ||
|
|
16edd144e9 | ||
|
|
d5ef8eea6f | ||
|
|
43549046e5 | ||
|
|
190ab79bdc | ||
|
|
a60907e996 | ||
|
|
b74e4fa373 | ||
|
|
42c93bdea7 | ||
|
|
abdafef7cb | ||
|
|
2530bbd92c | ||
|
|
9a483a0b0e | ||
|
|
021361f836 | ||
|
|
05d0ac8c03 | ||
|
|
dd236dd28a | ||
|
|
55f76d60df | ||
|
|
ef5437ed36 | ||
|
|
f3692e4587 | ||
|
|
96f3be841d | ||
|
|
60c022828d | ||
|
|
7dea3d744f | ||
|
|
483edc117e | ||
|
|
c59cd22153 | ||
|
|
6cd030c7ea | ||
|
|
48671585dd | ||
|
|
ef80bf9a37 | ||
|
|
e33a6bc5d8 | ||
|
|
89aab50948 | ||
|
|
4e38847f91 | ||
|
|
4c90c1c241 | ||
|
|
599370f60c | ||
|
|
a0e5ec1a31 | ||
|
|
b4a504e57d | ||
|
|
0674f32637 | ||
|
|
d32285abe8 | ||
|
|
67c403339c | ||
|
|
1fd6e72b8c | ||
|
|
643bcdc952 | ||
|
|
4431f11d83 | ||
|
|
dafebcebda | ||
|
|
48dfd82832 | ||
|
|
f1cc15f70f | ||
|
|
d3d141cca8 | ||
|
|
db8472371e | ||
|
|
3fde5bdcc1 | ||
|
|
61888396a5 | ||
|
|
4591353453 | ||
|
|
943ec1a844 | ||
|
|
703e2faa5c | ||
|
|
78678e1e0c | ||
|
|
5045cfbe35 | ||
|
|
13b637a8b9 | ||
|
|
9944d93f74 | ||
|
|
4e89d4c83f | ||
|
|
edd1c20093 | ||
|
|
a77edead25 | ||
|
|
da0e18a9c3 | ||
|
|
1546c1c8e0 | ||
|
|
b119e02b9e | ||
|
|
56749872b4 | ||
|
|
eb5ad92015 | ||
|
|
b29af7d96a | ||
|
|
eee48eb2bb | ||
|
|
2db230dec1 | ||
|
|
5fde6473a0 | ||
|
|
ed79e9af72 | ||
|
|
a1f8059c49 | ||
|
|
ad8396ef7b | ||
|
|
781138e3dc |
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ component hierarchy as a navigation tree, paints the selected folder's children
|
||||
wiresheet of draggable component boxes, draws the links between them, and writes box
|
||||
positions back into each component's `meta` property on save.
|
||||
|
||||
Current version: **0.0.0.024** (see [Versioning](#versioning)).
|
||||
Current version: **0.0.0.053** (see [Versioning](#versioning)).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -47,15 +47,18 @@ not inside the window. Tk always hands it to the system menu bar.
|
||||
| `app_state.py` | Preferences, runtime state, widget handles, `mark_dirty`/`mark_clean` | no |
|
||||
| `sax_dom.py` | `meta` bit-packing codec, component path resolution | no |
|
||||
| `schema.py` | Kit manifest loading, slot inheritance, slot visibility rules | no |
|
||||
| `undo.py` | Undo/redo stack of reversible DOM commands | no |
|
||||
| `undo.py` | Undo stack of reversible commands (DOM, and the device in live mode) | no |
|
||||
| `wiresheet.py` | Grid, component boxes, link routing and painting | yes |
|
||||
| `properties.py` | The Properties view: every slot of one component, read and written | yes |
|
||||
| `link_view.py` | The Link View: every link touching one component, drawn around it | yes |
|
||||
| `sax_file.py` | Open/save, navigation tree, cascading delete | yes |
|
||||
| `main.py` | Widgets, menus, event handlers, dialogs, main loop | yes |
|
||||
| `run.py` | Entry point | yes |
|
||||
|
||||
Imports form a DAG — `sax_dom`/`schema` depend on nothing UI-shaped, `wiresheet` and
|
||||
`sax_file` build on them, `main` wires it together. Event handlers live in `main`,
|
||||
which is what keeps the graph acyclic.
|
||||
`sax_file` build on them, `properties` builds on `schema` and `undo`, `link_view` builds
|
||||
on `wiresheet`, and `main` wires it together. Event handlers live in `main`, which is what
|
||||
keeps the graph acyclic.
|
||||
|
||||
### Shared state
|
||||
|
||||
@@ -70,7 +73,17 @@ invisible to every other module. Every mutation of "the document changed" goes t
|
||||
`MetaMoveCommand` (a box drag, or a whole Tidy Layout as one step), `AddComponentCommand`
|
||||
(which also removes the `<kit>` entry placing it may have added) and
|
||||
`DeleteComponentCommand` (which restores the removed `<link>` entries at their original
|
||||
indices, reinserting in ascending order so the indices stay valid). Nothing in
|
||||
indices, reinserting in ascending order so the indices stay valid) and `SlotValueCommand`
|
||||
(one Save from the Properties view, however many slots it changed). There is no redo.
|
||||
|
||||
On a device the same steps are undoable, and each undo is sent there: `undo.py` has
|
||||
live subclasses for moves, slot edits and renames, and `sax_file` has
|
||||
`LiveComponentCommand` and `LiveLinkCommand`. Deleting a component first reads its
|
||||
whole subtree back from the device (config, children, links, place among its
|
||||
siblings), so undo can recreate it; the device hands out new ids, which the DOM
|
||||
elements take on. An undo that does not fully reach the device warns and clears the
|
||||
history, and so does every view change, since opening a sheet rebuilds the DOM from the
|
||||
device. Nothing in
|
||||
`undo.py` touches Tk; `main` registers a refresh callback at startup, which is what keeps
|
||||
`sax_file` and `wiresheet` free to push commands without an import cycle.
|
||||
|
||||
@@ -80,11 +93,14 @@ Undoing back to the depth of the last save clears the dirty tint rather than lea
|
||||
|---|---|
|
||||
| `Ctrl`/`Cmd` + `S` | Save (direct overwrite) |
|
||||
| `Ctrl`/`Cmd` + `Z` | Undo |
|
||||
| `Ctrl`/`Cmd` + `Y` or `Ctrl`/`Cmd` + `Shift` + `Z` | Redo |
|
||||
| `Del` / `Backspace` | Delete selected component |
|
||||
| `Ctrl`/`Cmd` + `B` | Show or hide the tree/palette column |
|
||||
| `Ctrl` + `W` | Tidy Layout: re-flow the open sheet |
|
||||
| `Alt` + `Left` / `Alt` + `Right` | Back and forward through the sheets opened this session |
|
||||
| `Ctrl`/`Cmd` + `+` / `-` | Zoom the sheet in and out |
|
||||
| `Ctrl`/`Cmd` + `0` | Zoom back to 100% |
|
||||
| `Ctrl`/`Cmd` + `9` | Zoom to Fit: show the whole sheet |
|
||||
| `Ctrl` + wheel | Zoom around the pointer |
|
||||
|
||||
Both `Command-` and `Control-` are bound for each, so the same build works on macOS and
|
||||
Windows; menu accelerators render as `Cmd` or `Ctrl` per platform. Tidy Layout is the
|
||||
@@ -96,19 +112,413 @@ exception, on `Control-` alone: `Cmd` + `W` closes the window on macOS.
|
||||
it back at its configured width. ttk has no hide flag for a pane, so the column is dropped
|
||||
from the paned window and re-inserted at the front.
|
||||
|
||||
**Back** and **Forward** walk the sheets opened this session, mirrored by the two arrow
|
||||
buttons in the toolbar. The history records the element whose children are painted -- the
|
||||
same thing `app_state.current_sheet_parent` holds -- rather than a tree item id, because
|
||||
rebuilding the tree hands out fresh ids while the elements themselves survive. Opening a
|
||||
different file retires the history; a delete or an undo drops only the entries whose
|
||||
element has left the document, keeping the position on screen. Walking the history drives
|
||||
the tree selection, which is what paints the sheet, and is suppressed from recording so a
|
||||
walk cannot rewrite the trail it is following.
|
||||
**Zoom In**, **Zoom Out** and **Zoom 100%** step a ladder of scales from 25% to 300%,
|
||||
with the zoom buttons, the percentage and the fit icon sitting beside the navigation
|
||||
arrows in the toolbar. **Zoom to Fit** is the one thing that lands between the ladder's
|
||||
rungs -- a sheet fits at whatever scale it fits at -- and stepping in or out from there
|
||||
rejoins the ladder. It measures the sheet from item coordinates rather than
|
||||
`canvas.bbox()`, which is inflated by the outline stroke: a stroke is the same two pixels
|
||||
at every zoom, so a bbox divided back to 1:1 reports a slightly smaller sheet the further
|
||||
in it is zoomed, and fitting an already fitted sheet would creep instead of standing
|
||||
still. Anything painted left of or above the canvas origin is measured from the origin
|
||||
instead, since no amount of scrolling can bring it into view. The sheet
|
||||
is painted and routed at 1:1 -- box metrics, wire stubs, lane gaps, clearances and knob
|
||||
radii are all tuned in those pixels -- and `apply_sheet_zoom` scales the finished picture,
|
||||
so a zoomed sheet keeps exactly the proportions of a 1:1 one and the router makes the same
|
||||
decisions at every scale. Text scales because the three box fonts are resized after the
|
||||
boxes were measured with them. `redraw_links()` is what a drag or a drop calls: it puts
|
||||
the boxes back to 1:1 for the routing pass and scales the result again, since the router
|
||||
reads box positions straight off the canvas. Canvas coordinates therefore carry the zoom,
|
||||
which is why the drag, release and drop handlers snap against `meta_scale * zoom`.
|
||||
|
||||
**Back** and **Forward** walk everything the **editor pane** has held this session,
|
||||
mirrored by the two arrow buttons in the toolbar -- sheets and the two panels alike. Each
|
||||
entry is a `(kind, element)` pair naming one of the three:
|
||||
|
||||
| Entry | What Back or Forward puts back |
|
||||
|---|---|
|
||||
| `(VIEW_SHEET, element)` | selects that element's tree node, which is what paints its children |
|
||||
| `(VIEW_PROPERTIES, comp)` | re-opens the Properties view of that component |
|
||||
| `(VIEW_LINKS, comp)` | re-opens the Link View of that component |
|
||||
|
||||
So opening a panel is a move like opening a sheet: **Back** from a Properties view lands
|
||||
on whatever the pane showed before it rather than skipping past to the sheet before that,
|
||||
and **Forward** brings the panel back. Following a link out of the Link View is an
|
||||
ordinary sheet opening, so Back from where it lands returns to the picture the link was
|
||||
followed from, and a second Back to the sheet that was open before that.
|
||||
|
||||
The panel's own **Close** is not recorded. It means "leave this view", and recording it
|
||||
would put a sheet entry after the panel's -- so Back would re-open the panel that had
|
||||
just been closed. Leaving the position where it is instead means Back still steps to what
|
||||
came before the panel, and Forward re-opens it, which is what the two arrows are for.
|
||||
|
||||
Entries hold elements rather than tree item ids, because rebuilding the tree hands out
|
||||
fresh ids while the elements themselves survive. Opening a different file retires the
|
||||
history. A delete or an undo drops the entries that can no longer be shown -- a sheet
|
||||
with no node left, a component out of the document -- keeping the position on screen, and
|
||||
collapses any pair that pruning has left naming the same view, since a Back that visibly
|
||||
does nothing is worse than a shorter trail. Restoring an entry is suppressed from
|
||||
recording, so a walk cannot rewrite the trail it is following, and both arrows go through
|
||||
`close_editor_panel` first -- so unsaved Properties edits are still asked about, and a
|
||||
refusal leaves both the view and the position where they were.
|
||||
|
||||
### The component popup
|
||||
|
||||
Right-clicking a component offers the same actions from the wiresheet and from the
|
||||
navigation tree -- Properties, Link View, Delete Component, Link Mark, and Link From /
|
||||
Link To naming the marked component once one is marked. `fill_component_popup` builds
|
||||
both, and its entries close over the component rather than reading a selection back,
|
||||
which is what lets each popup act on the row or box that was actually clicked. The tree's
|
||||
root row keeps its own popup.
|
||||
|
||||
### The editor pane
|
||||
|
||||
The pane to the right of the tree holds one of three things: the **wiresheet**, the
|
||||
**Properties view** or the **Link View**. The latter two take the pane over rather than
|
||||
floating above it, so a sheet and a table of one component's slots are never both half in
|
||||
view. The canvas is unpacked, never destroyed, so it keeps its items and its scroll
|
||||
position and **Close** puts it back exactly as it was. It is still painted while it is
|
||||
hidden, which costs nothing and means there is no catching up to do when it returns.
|
||||
|
||||
`_take_editor_pane` and `_give_editor_pane_back` in `main` are the hand-over, and
|
||||
`close_editor_panel` is the single way out of whichever panel has the pane: "you have not
|
||||
saved that" is therefore asked once, in one place, whether the user pressed Close, picked
|
||||
another sheet, walked the history or opened a different component. Only the Properties
|
||||
view has anything to ask about.
|
||||
|
||||
Choosing a node in the tree means "show me that sheet", so it stands a panel down rather
|
||||
than leaving the selection looking like it did nothing. So does deleting the component one
|
||||
is showing, from anywhere -- every delete in the UI goes through `delete_component`,
|
||||
because `sax_file` does its own view refresh rather than going through `refresh_all_views`.
|
||||
|
||||
All three are recorded in one history, so **Back** and **Forward** walk them together --
|
||||
see **The View menu** above.
|
||||
|
||||
### The Properties view
|
||||
|
||||
**Properties**, from either component popup, is the one place a slot's value is meant to
|
||||
be changed, and the only view here that writes to the document.
|
||||
|
||||
It lists every slot the manifest declares -- bar one kind, see **Hidden slots** below --
|
||||
in manifest order, each row numbered with the same slot id the wiresheet box uses. The
|
||||
box's visibility rules do not apply here: they are right for a diagram and wrong for the
|
||||
one place a value is meant to be changed, so `HvacControl` shows all 47 rows rather than
|
||||
the 8 its box paints.
|
||||
|
||||
The header names the component, its type and its **absolute path** -- the same path a
|
||||
`<link>` writes, and the one thing on screen that separates this `Cascade` from the three
|
||||
others in the file. It is read from the document rather than held, so a renamed parent
|
||||
shows up on the next Refresh.
|
||||
|
||||
Each row carries the slot's declared type between its name and its value, written the way
|
||||
a slot sheet writes it: the manifest stores the primitives bare and everything else already
|
||||
qualified, so `int` is shown as `sys::int` and `sys::Buf` is left as it stands.
|
||||
|
||||
A column of raw `flags=` letters was tried here and taken out again: `c`, `a`, `s` and `o`
|
||||
are the manifest's own shorthand, and a column of them told the reader nothing the row was
|
||||
not already saying -- `c` is what the white field means, `a` is what an `action` value
|
||||
means. What the flags decide is still decided by them; it is just not spelled out twice.
|
||||
|
||||
The **header row** stays. It is deliberately quiet -- row-sized, grey, over a hairline
|
||||
rule -- so it labels the table without competing with it.
|
||||
|
||||
Which rows can be typed into is the slot's own business, not the view's:
|
||||
|
||||
| Slot | Field |
|
||||
|---|---|
|
||||
| config (`c` flag) and not `@readonly` | white, editable |
|
||||
| everything else | grey, read-only |
|
||||
|
||||
A runtime slot holds a live value that no offline file carries, so nothing here could
|
||||
usefully write one; a `@readonly` config slot is the manifest saying the same about a
|
||||
value that *is* persisted. Both read the same way on screen.
|
||||
|
||||
A **dropdown is offered only where there is a choice to make**. A read-only bool or enum
|
||||
gets a plain field instead, since a disabled dropdown is an arrow that does nothing -- and
|
||||
a read-only bool is still filled by its state, because a runtime bool has a state too.
|
||||
|
||||
The dropdown is a `tk.OptionMenu` rather than a `ttk.Combobox`, for one reason: on the
|
||||
Windows themes a Combobox draws its field with a native element and **ignores
|
||||
`fieldbackground` outright**, so a bool could never be filled with its colour. The plain Tk
|
||||
widget takes the colour it is given. It reports a change through its variable rather than
|
||||
an event, which also catches a value set from anywhere else -- and means loading a row
|
||||
looks exactly like choosing one, so the load is flagged and the tint left alone until every
|
||||
row is in.
|
||||
|
||||
Each row is drawn from the slot's facets, so it reads the way the kit author meant it to:
|
||||
|
||||
- `@trueText` / `@falseText` turn a bool into its own two words -- `Disabled` / `Enabled`
|
||||
rather than false / true -- and **the field is filled by the state**: green for true,
|
||||
red for false, blue for null. It began as a coloured dot beside the field, which meant
|
||||
learning what the dot stood for, then as coloured text; a block of colour carries down a
|
||||
long list of slots where a letterform does not, and the words stay black so they read as
|
||||
easily as every other value. Dropping the dot also let a bool's field line up with the
|
||||
numbers above and below it.
|
||||
- **Null is a value, not an absence.** Sedona models it as NaN for a float and a sentinel
|
||||
for the integer types, and a config slot can genuinely hold it: `control::WriteFloat`
|
||||
pairs an `in` property with a `setNull` action, and the sample app carries
|
||||
`<prop name="in" val="null"/>` on four of them. So `null` shows against a blue dot,
|
||||
types back in as `null`, and reads as `@nullText` where a kit declared one --
|
||||
`ontrolBaseDriver::DeviceNetwork.status` calls it `Disabled`. `@allowNull false` is the
|
||||
one case that refuses it, and takes it out of a bool's dropdown.
|
||||
- `@range` turns an integer slot into a named dropdown; the file still stores the index.
|
||||
- `@precision` fixes how many decimals a float shows, `@unit` puts the symbol after the
|
||||
field (`celsius` reads `°C`).
|
||||
- `@min` / `@max` bound what Save will accept, and are shown as `[0 - 1000]`. A row prints
|
||||
a range when the manifest declared one, and otherwise only where the **type** supplies a
|
||||
narrow one: `byte` reads `[0 - 255]` and `short` reads `[0 - 65535]` unfaceted, because
|
||||
those bounds are worth knowing. An `int`, a `long` or a float prints nothing, since its
|
||||
full span means no more than "a whole number" and crowds the row to say it -- and an end
|
||||
those types leave undeclared prints as `-inf` / `+inf` rather than reaching for the
|
||||
type. This is display only: Save still holds every integer to its type, so an `int` of
|
||||
`9999999999` is rejected whether or not its row advertised the limit.
|
||||
- On a **`Buf`** those same two facets count *characters*, not magnitude, and the count is
|
||||
what the buffer can hold: `TcpDevice.ipAddr` is a `Buf(17)` declaring `@max 16`, the
|
||||
seventeenth byte being the null terminator. The row reads `[max 16 chars]` and Save
|
||||
refuses a longer string -- overrunning a Buf is not a display problem, the device gets a
|
||||
string it has no room for.
|
||||
|
||||
The value shown is the file's `<prop>`, or the manifest default when the file carries
|
||||
none, or the type's zero when the manifest declares none either. Runtime slots reach the
|
||||
view through that second and third step, which is why they read `0.0` here and `null` on
|
||||
the wiresheet: the box is reporting what the file says, and this is reporting what the
|
||||
device would start from.
|
||||
|
||||
`meta` gets a row of its own rather than an editable integer: typing 822411269 into a
|
||||
field is no way to set either of the two unrelated things it packs. The `»` beside it
|
||||
opens them apart -- the four user group bits as checkboxes, and the position as x and y,
|
||||
which a drag would otherwise be the only way to set. Both are in grid units, the figure
|
||||
`meta_scale` multiplies to reach pixels, and each is one byte, so the editor holds them to
|
||||
0-255. The row shows the groups it summarises and the position beside them.
|
||||
|
||||
Saving a changed meta writes the position first and the groups second, because each pass
|
||||
preserves what the other owns: the position pass keeps the group bits it reads, and the
|
||||
group pass keeps the position it was just handed.
|
||||
|
||||
The table is laid out at its natural width and never stretched. Every widget is gridded
|
||||
straight into one frame, so grid's own column sizing is what lines the four columns up
|
||||
across the rows, and no column carries a weight -- widening the view shows more white to
|
||||
the right of the table instead of walking the fields across it, and narrowing it scrolls
|
||||
sideways rather than squeezing them. Each scrollbar appears only while it has somewhere to
|
||||
scroll; `Shift` + wheel scrolls sideways.
|
||||
|
||||
### The file name goes red
|
||||
|
||||
While anything is unwritten, the file name on the tree's **root row** turns
|
||||
`DIRTY_NAME_COLOUR`. That row is the only place a file name appears on screen -- a Tk
|
||||
window title cannot be coloured.
|
||||
|
||||
The tag's colour is configured **once**, when the tree is built, and never changed; what
|
||||
moves is whether the row carries the tag. Reconfiguring a tag does not reliably repaint
|
||||
rows that are already drawn, so a version that recoloured the tag went red only on the
|
||||
paths that happened to rebuild the tree -- a Properties Save did, dragging a box did not,
|
||||
and the two disagreed in a way that looked like the drag was not marking anything dirty at
|
||||
all. Setting a row's tags repaints that row, so the colour cannot fall behind the state.
|
||||
|
||||
`anything_unwritten()` is the whole rule, and it counts **both**: the document being dirty,
|
||||
and the Properties view holding edits its Save has not been pressed for. A typed value is
|
||||
as unwritten as a dragged box, and a name that only went red for the second of those would
|
||||
be telling half the story. So it goes red the moment a field is edited, before anything has
|
||||
reached the document, and comes back when the edit is typed away, discarded, or saved and
|
||||
written.
|
||||
|
||||
The navigation tree runs on a style of its own, `NAV_TREE_STYLE`, for one reason: a themed
|
||||
Treeview maps the selected state to `SystemHighlightText`, and **that mapping beats a row
|
||||
tag's own foreground**. The name turned white-on-blue the moment its row was selected --
|
||||
which is exactly when the root sheet is the one being looked at, so the red was invisible
|
||||
in the most ordinary case. The style drops the selected foreground entry altogether and
|
||||
takes a light selection background instead, so a tag's colour shows in every state and
|
||||
plain black still reads on the highlight. Scoped to this tree, so the palette keeps the
|
||||
system selection.
|
||||
|
||||
`app_state.refresh_dirty_display()` is the single funnel, called from `mark_dirty` and
|
||||
`mark_clean` through a callback `main` registers -- the same shape `undo` uses for its view
|
||||
refresh, and what keeps `app_state` free of any idea that a Properties view exists.
|
||||
|
||||
### Hidden slots
|
||||
|
||||
`@hidden` is the one facet the view filters on, and only by default. niagara.html says
|
||||
hidden slots "are designed to be invisible to the user" and that interfaces should rarely
|
||||
display them; every one under Sedona home is a `void` action a kit uses internally, such
|
||||
as `HdwAI.checkType`. A **Hidden slots (n)** checkbox in the footer brings them back, and
|
||||
the answer sticks for the session rather than being asked again for every component.
|
||||
|
||||
The rows are always built -- the checkbox `grid_remove`s and re-`grid`s them, which keeps
|
||||
each widget's options so putting a row back needs no arithmetic, and an emptied grid row
|
||||
collapses rather than leaving a gap. The checkbox itself only appears on a type that has
|
||||
something to reveal, so most components carry no control for a thing they do not have.
|
||||
|
||||
### Unsaved edits in the Properties view
|
||||
|
||||
An edited field paints the whole view in **`app_state.DIRTY_TINT`** -- the same colour
|
||||
the canvas wears when the file is dirty, now one constant rather than two spellings of
|
||||
`#ffe6e6`, so "there is something here you have not written yet" looks the same wherever
|
||||
the change was made. The fields themselves keep their own white or grey: whether a value
|
||||
*can* be typed into is a different thing from whether it *has* been.
|
||||
|
||||
Dirtiness is read from the widgets rather than tracked as a flag, so typing a value and
|
||||
then typing it back leaves the view clean -- it is exactly the set of rows Save would
|
||||
write. The tint follows the fields live, and comes off on Save and on Refresh.
|
||||
|
||||
Every way of leaving the view goes through `close_properties`, so the question is asked
|
||||
once, in one place: **Close**, picking another sheet in the tree, walking **Back** or
|
||||
**Forward**, or right-clicking a different component. The box has three answers rather
|
||||
than two, because "I did not mean to leave" and "leave, I did not want those edits" are
|
||||
different things and a two-way box makes the user guess which one Cancel is. A Save that
|
||||
fails its range checks keeps the view open, since leaving then would discard exactly the
|
||||
values being complained about.
|
||||
|
||||
Refusing to leave has to put the tree back on the sheet that is still showing. The
|
||||
**element** is held for that, not the tree item id, for the same reason the history holds
|
||||
elements: a rebuild of the tree hands out fresh ids. ttk queues its selection event rather
|
||||
than dispatching it, so the flag that stops the restoring selection being read as a fresh
|
||||
choice is cleared from an `after_idle` rather than inline.
|
||||
|
||||
Nothing asks when the question has nowhere to go -- the component deleted, a different
|
||||
file opened. Those go straight to `dismiss_properties`, which is also what the panel's own
|
||||
Close calls once the question has been put and answered.
|
||||
|
||||
Discarded edits are discarded: the panel is destroyed, and the next one is built from the
|
||||
document, so it opens on the file's values with no tint.
|
||||
|
||||
**Save** writes the edited rows into the DOM and pushes them as **one** undoable step, so
|
||||
a dozen changed slots undo together. Only rows whose text actually moved are written, so a
|
||||
component keeps exactly the props the file gave it plus the ones edited here -- and if any
|
||||
edited row fails its range check, *nothing* is written and the view names every offending
|
||||
row at once. Save changes the document, not the file; `Ctrl`/`Cmd` + `S` still writes the
|
||||
`.sax`. **Refresh** re-reads every row from the DOM, discarding pending edits, which is
|
||||
also how the view catches up after an undo.
|
||||
|
||||
`Del` and `Backspace` are bound application-wide, so they also reach a field being typed
|
||||
in. `delete_selected_component` therefore ignores the key while the Properties view is up,
|
||||
while focus is in another window, and while it is in any widget that owns the keyboard --
|
||||
an `Entry`, a `TCombobox`, a `Text`. A backspace aimed at a text field must never delete a
|
||||
component. Save still checks the component is in the document before writing, since an
|
||||
undo can take it out from under the view.
|
||||
|
||||
### The Link View
|
||||
|
||||
**Link View**, from either component popup, answers one question -- what is wired to this
|
||||
component, and where does it go -- and takes over the editor pane to do it. It is
|
||||
read-only: no drag, no add, no delete, nothing that writes to the document, so a
|
||||
component's wiring can be read without the risk of rearranging it.
|
||||
|
||||
The chosen component is drawn in the middle, with the components linking **into** it on
|
||||
the left and the ones it links **out to** on the right, so every arrow points the way the
|
||||
signal travels and the picture reads left to right the way a wiresheet does. The chosen
|
||||
box wears the same dark stroke a selected box wears on the sheet. The ground is **pale
|
||||
yellow** and carries **no grid**: nothing here can be positioned, because the layout is
|
||||
derived from the links every time it is drawn and no part of it is stored in the file
|
||||
(`meta` records a component's place on its own sheet, which says nothing about where it
|
||||
belongs relative to another one).
|
||||
|
||||
Each column is stacked **in the order of the chosen component's own slots**: whatever
|
||||
feeds its first slot sits at the top, whatever feeds its fifth sits below that. Nothing
|
||||
is read from `meta`, so the view is free to put the peers wherever they read best, and
|
||||
this is the order that makes the wires readable -- see **Keeping the wires apart** below.
|
||||
|
||||
Every line is **grey**, because this is not a wiresheet and a wire-blue line would invite
|
||||
the reader to treat it as one:
|
||||
|
||||
| Link | Drawn as |
|
||||
|---|---|
|
||||
| link on the same wiresheet | solid grey line |
|
||||
| link to another wiresheet | dashed grey line, plus the grey knob that sheet paints on the slot, with the far component drawn in and its sheet named beneath it |
|
||||
|
||||
Boxes come from `wiresheet.draw_component_box` -- the same function the sheet paints
|
||||
with -- so a component reads exactly as it does on its own sheet and a wire lands on the
|
||||
same slot row. A component wired twice on one side is one box with two wires, as it would
|
||||
be on a sheet; a reference the document cannot resolve gets a dashed placeholder naming
|
||||
the path, because a link the view silently dropped would be the one thing a reader could
|
||||
not find out from here.
|
||||
|
||||
Hovering a wire darkens it and names the link in the footer; clicking a wire or a
|
||||
component follows it. `collect_links`, `sheet_display_name` and the whole of the routing
|
||||
geometry -- `segments_meet`, `routes_meet`, `column_gap`, `_slot_order_key` -- are pure
|
||||
functions, so both what the view reads out of a document and how it decides where a wire
|
||||
goes can be exercised without a window.
|
||||
|
||||
### Keeping the wires apart
|
||||
|
||||
Ordering each column by the chosen component's slots turns the set of wires on one side
|
||||
into a **ladder**: both ends run the same way down the page, so no wire has any reason to
|
||||
cross another. What is left is choosing which lane each one turns in, and `column_gap`
|
||||
reserves the room for that -- a lane per wire, so a dozen links widen the gap rather than
|
||||
crowding a dozen verticals into a 30px band.
|
||||
|
||||
For a ladder there is a lane order that cannot produce a crossing, and read off the slots
|
||||
rather than off the direction the line happens to be drawn in, it is the same on both
|
||||
sides -- two fans, each nesting outwards from the chosen component's own row:
|
||||
|
||||
| Wire | Turns nearer the focus |
|
||||
|---|---|
|
||||
| reaching a peer slot **above** its focus slot | the higher up the focus it lands |
|
||||
| reaching a peer slot **below** its focus slot | the lower down the focus it lands |
|
||||
|
||||
Take two wires of the upper fan, the higher one nested inside the lower: the lower one's
|
||||
run into the focus passes below where the higher one's vertical ends, and its run out to
|
||||
its peer passes above where that vertical begins, so it goes round it and never through
|
||||
it. The lower fan is the same argument upside down. A wire of one fan can never share a
|
||||
stretch of the page with a wire of the other -- ordering the ladder is what rules that
|
||||
out -- so the two fans reuse each other's lanes and the gap stays as narrow as the
|
||||
picture allows.
|
||||
|
||||
Two special cases sit on top of that rule. Where several wires leave the **same** slot no
|
||||
order of focus heights can separate them, so the longest branch turns first, against the
|
||||
focus, and the shorter ones carry on along the shared run beside the pin and drop away
|
||||
outside it -- which is how a wiresheet draws a fan-out too. And where two slots already
|
||||
line up, the wire is one straight line with no vertical run to place at all; those are
|
||||
settled first, because such a wire crosses the whole gap and everything else has to be
|
||||
fitted past it. The layout is rounded to whole pixels so that a pair that ought to line
|
||||
up does, rather than being drawn with a 1px jog in it.
|
||||
|
||||
Every wire is still checked against the ones already routed before it takes a lane
|
||||
(`routes_meet`), and takes the next lane out if it clashes. The argument above rests on
|
||||
the ladder being ordered, and one thing breaks that: a peer whose **own** slots run
|
||||
counter to the focus's -- linking its row 4 to the focus's row 3 and its row 3 to the
|
||||
focus's row 4. Two links with their ends inverted like that have to cross wherever they
|
||||
are drawn, and no reordering can help, because the order inside each box is the
|
||||
component's own. The check is what stops that one forced crossing from being made worse
|
||||
by the ones around it.
|
||||
|
||||
Measured over 461 linked components in four real files -- 1113 wires -- this leaves **no
|
||||
wire passing through a box at all**, and ten crossings in six components, every one of
|
||||
them an inverted pair of the kind above.
|
||||
|
||||
### Following a link to its other sheet
|
||||
|
||||
Right-clicking a wire or a knob offers **Go to Link Wire Sheet "<name>"** alongside Delete
|
||||
Link, naming the sheet the far end of that link is on -- the end a grey knob stands in
|
||||
for. The entry is left off a link with both ends on the open sheet, which has nowhere to
|
||||
go. `find_component_by_path` in `sax_dom` turns a `<link>`'s from/to reference back into
|
||||
the component it points at, the inverse of `get_component_path`, and the sheet is that
|
||||
component's parent; a parent of `<app>` is the file's root node, which
|
||||
`link_view.sheet_display_name` names after the file.
|
||||
|
||||
Clicking a link in the Link View follows it the same way, through the same
|
||||
`go_to_sheet_with_link`: it opens the sheet of the end that is **not** the component the
|
||||
view is about, so a link that stays on one sheet goes back to that sheet where both ends
|
||||
are drawn, and one that crosses opens the other sheet -- the end the view could only show
|
||||
as a knob.
|
||||
|
||||
Either way the link is selected again once the sheet arrives. It has to be found by its
|
||||
element rather than by the tag it was picked on, because a repaint hands out fresh wire
|
||||
tags -- and it has to be picked out **after** that repaint, since painting a sheet clears
|
||||
whatever link was selected. An `after_idle` callback is not late enough: ttk dispatches
|
||||
its own selection event later than one of those, so a link selected from an idle callback
|
||||
was wiped by the repaint that followed it. The link is therefore left in
|
||||
`_pending_link_selection` for `on_tree_select` to honour once it has painted, and that
|
||||
handler reads and clears the request first thing -- so a selection that turns out not to
|
||||
paint a sheet drops it rather than leaving it to be applied to some later one. The link is
|
||||
then scrolled to the middle of the view. Arriving is an ordinary sheet opening, so
|
||||
**Back** returns to whatever the pane held before it -- the sheet the wire was
|
||||
right-clicked on, or the Link View the link was clicked in.
|
||||
|
||||
### Testing without a window
|
||||
|
||||
`app_state`, `sax_dom`, `schema`, `undo` and `wiresheet` import with no display, so the
|
||||
rules can be exercised directly:
|
||||
rules can be exercised directly. `link_view.collect_links` is pure DOM work too, and reads
|
||||
a component's links without one:
|
||||
|
||||
```python
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -201,6 +611,26 @@ disturbs the rest of the value.
|
||||
Wires and knobs are painted behind the component boxes and re-routed on every drag
|
||||
step, since they are not part of the dragged canvas group.
|
||||
|
||||
### Facets this does not act on
|
||||
|
||||
Read against `SedonaDoc/niagara.html`, which is what tells Niagara -- and so this view
|
||||
-- how to model a slot. These are recognised there and deliberately not acted on here:
|
||||
|
||||
| Facet | Where it is used | Why not |
|
||||
|---|---|---|
|
||||
| `fieldWidth` | 3082 slots | A per-slot column count for the text field. Applying it would make the value column ragged; a table reads better with one width. |
|
||||
| `radix`, `showSeparators`, `multiLine`, `fieldEditor` | unused by any kit under Sedona home | Nothing to render. |
|
||||
|
||||
And two things worth knowing about the manifests themselves rather than the code:
|
||||
|
||||
- 24 slots declare **`readOnly`** with a capital O. Niagara matches flag names exactly, so
|
||||
it is not the `readonly` flag and does nothing there either -- it is stored as an
|
||||
ordinary facet. Every one of them is a runtime slot, so they are already grey here for
|
||||
the other reason, and honouring the typo would change nothing on screen.
|
||||
- 118 **config `Buf`** slots omit `@asStr`, so by the letter of `components.html` they
|
||||
serialise as base64 rather than text. Their names (`text`, `trueText`, `buttonText`) say
|
||||
otherwise, so they are edited as text here.
|
||||
|
||||
## Versioning
|
||||
|
||||
`VERSION` lives in `app_state.py` and is shown in the window title.
|
||||
@@ -211,10 +641,14 @@ step, since they are not part of the dragged canvas group.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- The yellow square in each box header is drawn but wired to nothing; it is the natural
|
||||
place for a per-box collapse/expand toggle.
|
||||
- Undo covers box moves, adds and deletes. Nothing else pushes a command, so a change made
|
||||
outside those three is not reversible.
|
||||
- `on_canvas_release` snaps using the rounded-rectangle polygon's first coordinate,
|
||||
which is `x1 + radius` rather than `x1`, so snapping is offset by the corner radius.
|
||||
- Slot values are display-only; there is no editing of a slot from the wiresheet.
|
||||
- The yellow square in each box header is a type-icon placeholder that is drawn but not yet
|
||||
functional; no icon set has been wired to it.
|
||||
- Slot values are edited from the Properties view only; a wiresheet box row is still
|
||||
display-only.
|
||||
- The Link View stacks each side in one column, so a component with a dozen peers makes a
|
||||
picture taller than the pane. It scrolls, but it does not wrap into a second column.
|
||||
- Two links between the same pair of components whose slot rows are inverted have to
|
||||
cross, since neither box's slot order is the view's to change. Six components in the
|
||||
four files measured do this.
|
||||
- `column_gap` budgets one lane per wire, but the router reuses lanes wherever it can, so
|
||||
a side that needs few of them is drawn with a gap wider than its wires require.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# TODO List
|
||||
|
||||
## High Priority
|
||||
|
||||
- [ ]
|
||||
|
||||
## Medium Priority
|
||||
|
||||
- [ ]
|
||||
|
||||
## Low Priority
|
||||
|
||||
- [ ]
|
||||
|
||||
## Done
|
||||
|
||||
+908
@@ -0,0 +1,908 @@
|
||||
"""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:
|
||||
|
||||
device/app.sab the files as the device holds them
|
||||
device/kits.scode
|
||||
device/images/<name>.bin
|
||||
device/fonts/<font file>
|
||||
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)
|
||||
system/logo.png
|
||||
|
||||
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
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
import app_state
|
||||
import kit_build
|
||||
import palette
|
||||
import sab_app
|
||||
import schema
|
||||
import sox
|
||||
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, ORION_PLATFORM_TYPE, RION_KIT
|
||||
|
||||
APP_SAB = "app.sab"
|
||||
KITS_SCODE = "kits.scode"
|
||||
|
||||
_IMAGE_SLOTS = ("imageFileName", "trueImageFileName", "falseImageFileName")
|
||||
_SOURCE_IMAGE_EXTENSIONS = tuple(e for e in IMAGE_EXTENSIONS if e != ".bin")
|
||||
_UNSAFE_FILE_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||
|
||||
|
||||
class _Platform:
|
||||
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, 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):
|
||||
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():
|
||||
return os.path.join(app_state.SEDONA_HOME, "store", "apps")
|
||||
|
||||
|
||||
def backup_file_name(device_name, app_name, when=None):
|
||||
"""'app-<device name>-<app name>-<yyyyMMdd-HHmmss>.zip', made safe for Windows."""
|
||||
stamp = (when or datetime.datetime.now()).strftime("%Y%m%d-%H%M%S")
|
||||
parts = [_UNSAFE_FILE_CHARS.sub("_", p).strip(" .") or "unnamed"
|
||||
for p in (device_name, app_name)]
|
||||
return f"app-{parts[0]}-{parts[1]}-{stamp}.zip"
|
||||
|
||||
|
||||
class MediaFile:
|
||||
"""One image or font the app names: where it lives on the device and in the bank.
|
||||
|
||||
`required` is False for guesses -- a bold font's plain cut, Orion's other font
|
||||
orientation, an indexed page image -- whose absence is not worth reporting.
|
||||
`group` ties the alternatives of one need together: the need is met when any of
|
||||
them is found.
|
||||
"""
|
||||
|
||||
def __init__(self, device_uri, bank_folder, bank_name, group, required=True):
|
||||
self.device_uri = device_uri
|
||||
self.bank_folder = bank_folder
|
||||
self.bank_name = bank_name
|
||||
self.group = group
|
||||
self.required = required
|
||||
|
||||
|
||||
def _font_stem(app, comp):
|
||||
slot = app.slot(comp, "fontName")
|
||||
names = [n.strip() for n in (slot.facets.get("range") or "Tahoma").split(",")] if slot else ["Tahoma"]
|
||||
index = app.prop(comp, "fontName", 0) or 0
|
||||
family = names[index] if 0 <= index < len(names) else names[0]
|
||||
size = app.prop(comp, "fontSize", 16)
|
||||
bold = app.prop(comp, "fontAttributes", 0) == 1
|
||||
return f"{size}pt {family}", bold
|
||||
|
||||
|
||||
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:
|
||||
seen.add(uri)
|
||||
files.append(MediaFile(uri, bank_folder, bank_name, group, required))
|
||||
|
||||
def add_image(name, required=True):
|
||||
if name:
|
||||
add(f"images/{name}.bin", platform.image_bank, name, f"image {name}", required)
|
||||
|
||||
def add_font(stem, group, required):
|
||||
for i, ext in enumerate(platform.font_extensions):
|
||||
add(f"fonts/{stem}{ext}", platform.font_bank, stem + ext, group,
|
||||
required and i == 0)
|
||||
|
||||
for comp in app.comps.values():
|
||||
for slot_name in _IMAGE_SLOTS:
|
||||
add_image(app.prop(comp, slot_name, ""))
|
||||
|
||||
prefix = app.prop(comp, "imageFileNamePrefix", "")
|
||||
if prefix:
|
||||
for i in range((app.prop(comp, "maxIndex", 1) or 0) + 1):
|
||||
add_image(f"{prefix}{i}")
|
||||
|
||||
page_image = app.prop(comp, "imageFileName", "")
|
||||
if page_image:
|
||||
for i in range(1, (app.prop(comp, "maxImageId", 0) or 0) + 1):
|
||||
add_image(f"{page_image}{i}", required=False)
|
||||
|
||||
if app.slot(comp, "fontSize") is not None:
|
||||
stem, bold = _font_stem(app, comp)
|
||||
group = f"font {'bold ' if bold else ''}{stem}"
|
||||
if bold:
|
||||
add_font(f"bold {stem}", group, True)
|
||||
add_font(stem, group, not bold)
|
||||
|
||||
add(platform.logo_uri, platform.image_bank, "logo", "logo", required=False)
|
||||
return files
|
||||
|
||||
|
||||
def _bank_source(bank_folder, bank_name, is_image):
|
||||
"""The local bank file for a device file, or None."""
|
||||
folder = os.path.join(app_state.SEDONA_HOME, "ontrol", bank_folder)
|
||||
if not is_image:
|
||||
path = os.path.join(folder, bank_name)
|
||||
return path if os.path.isfile(path) else None
|
||||
for ext in _SOURCE_IMAGE_EXTENSIONS + (".bin",):
|
||||
path = os.path.join(folder, bank_name + ext)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
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.startswith("system/"):
|
||||
return "system/logo" + os.path.splitext(file_name)[1]
|
||||
return f"{media.bank_folder}/{file_name}"
|
||||
|
||||
|
||||
def save_app(client):
|
||||
"""Invokes App.save on the device and waits for it to finish.
|
||||
|
||||
Ontrol's Niagara driver does this before every app.sab get.
|
||||
"""
|
||||
comp = client.cache_get(0) or client.load(0)
|
||||
slots = client.slot_registry.get((comp.kit_id, comp.type_id)) or []
|
||||
slot = next((s for s in slots if s["name"] == "save" and s.get("is_action")), None)
|
||||
client.invoke(0, slot["id"] if slot else 0)
|
||||
|
||||
|
||||
class BackupResult:
|
||||
def __init__(self):
|
||||
self.path = None
|
||||
self.entries = []
|
||||
self.missing_on_device = [] # media the app names that the device lacks
|
||||
self.missing_in_bank = [] # device files with no source in Sedona home
|
||||
self.sax_error = None # why app.sax could not be written
|
||||
|
||||
|
||||
def get_with_file(client, path, status=None):
|
||||
"""Reads the app and its media off the device and writes the backup zip to `path`.
|
||||
|
||||
The app is saved on the device first, so app.sab holds what is running. Runs off
|
||||
the main thread; status(text, fraction) reports progress. Returns a BackupResult;
|
||||
a failure to save, or to read app.sab or kits.scode, raises.
|
||||
"""
|
||||
def report(text, fraction=None):
|
||||
if status:
|
||||
status(text, fraction)
|
||||
|
||||
def fetch(uri, base, span):
|
||||
def progress(done, total):
|
||||
report(f"Reading {uri} ...", base + span * (done / total if total else 1.0))
|
||||
report(f"Reading {uri} ...", base)
|
||||
try:
|
||||
return client.get_file(uri, progress)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Reading {uri}: {e}") from e
|
||||
|
||||
result = BackupResult()
|
||||
entries = [] # (zip name, bytes or local path)
|
||||
|
||||
report("Saving the app on the device ...", 0.0)
|
||||
try:
|
||||
save_app(client)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Saving the app on the device: {e}") from e
|
||||
sab = fetch(APP_SAB, 0.0, 0.15)
|
||||
entries.append(("device/" + APP_SAB, sab))
|
||||
entries.append(("device/" + KITS_SCODE, fetch(KITS_SCODE, 0.15, 0.35)))
|
||||
|
||||
app = None
|
||||
try:
|
||||
app = sab_app.SabApp(sab)
|
||||
entries.append(("app.sax", app.to_sax().encode("ascii")))
|
||||
except Exception as e:
|
||||
result.sax_error = str(e)
|
||||
|
||||
platform = platform_of(app) if app else None
|
||||
media = media_files(app, platform) if platform else []
|
||||
found_groups, wanted = set(), {}
|
||||
for i, m in enumerate(media):
|
||||
report(f"Reading {m.device_uri} ...", 0.5 + 0.45 * i / max(1, len(media)))
|
||||
if m.required:
|
||||
wanted.setdefault(m.group, m.device_uri)
|
||||
try:
|
||||
data = client.get_file(m.device_uri)
|
||||
except Exception:
|
||||
if client.is_closed:
|
||||
raise
|
||||
continue
|
||||
found_groups.add(m.group)
|
||||
entries.append(("device/" + m.device_uri, data))
|
||||
source = _bank_source(m.bank_folder, m.bank_name, m.device_uri.startswith(("images/", "system/")))
|
||||
if source:
|
||||
entries.append((_source_entry(m, source), source))
|
||||
else:
|
||||
result.missing_in_bank.append(f"{m.bank_folder}/{m.bank_name}")
|
||||
result.missing_on_device = [uri for group, uri in wanted.items() if group not in found_groups]
|
||||
|
||||
report("Writing the zip ...", 0.97)
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
partial = path + ".part"
|
||||
try:
|
||||
with zipfile.ZipFile(partial, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for name, content in entries:
|
||||
if isinstance(content, bytes):
|
||||
z.writestr(name, content)
|
||||
else:
|
||||
z.write(content, name)
|
||||
os.replace(partial, path)
|
||||
finally:
|
||||
if os.path.exists(partial):
|
||||
os.remove(partial)
|
||||
|
||||
result.path = path
|
||||
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)
|
||||
+133
-9
@@ -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.032"
|
||||
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
|
||||
@@ -26,12 +26,49 @@ DEFAULT_LEFT_PANE_WIDTH = 230
|
||||
MIN_ALLOWED_LEFT_PANE_WIDTH = 120
|
||||
MAX_ALLOWED_LEFT_PANE_WIDTH = 600
|
||||
|
||||
# Called whenever "is there anything unwritten" may have changed, so the file name on
|
||||
# the tree can follow it. main registers it; anything before that is a no-op.
|
||||
dirty_display_callback = None
|
||||
|
||||
# Widget handles, filled in by main once the UI exists
|
||||
window = None
|
||||
canvas = None
|
||||
tree = None
|
||||
status_label = None # the window's bottom status bar
|
||||
|
||||
# The unsaved-state tint. The wiresheet paints it on the canvas and the Properties view
|
||||
# paints it on itself, so "there is something here you have not written yet" is one
|
||||
# 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
|
||||
# coloured.
|
||||
#
|
||||
# The tag's colour is configured once and never changed; what moves is whether the row
|
||||
# carries the tag. Reconfiguring a tag does not reliably repaint rows that are already
|
||||
# drawn -- which is why the name only ever went red on the paths that happened to rebuild
|
||||
# the tree, and stayed black after a box was dragged. Setting a row's tags repaints that
|
||||
# row, so the two states cannot drift apart.
|
||||
DIRTY_NAME_COLOUR = "#c00000"
|
||||
DIRTY_NAME_TAG = "unsaved_file"
|
||||
|
||||
# Marks the tree row a palette drag would drop into.
|
||||
TREE_DROP_TAG = "palette_drop_target"
|
||||
|
||||
# A themed Treeview maps the selected state to SystemHighlightText, and that mapping beats
|
||||
# a row tag's own foreground -- so the file name turned white-on-blue the moment its row
|
||||
# was selected, which is exactly when the root sheet is the one being looked at. The
|
||||
# navigation tree therefore runs on a style of its own that drops the selected foreground
|
||||
# and picks a light selection background instead, so a tag's colour shows in every state
|
||||
# and black text still reads on the highlight.
|
||||
NAV_TREE_STYLE = "Nav.Treeview"
|
||||
NAV_SELECTION_BG = "#cce4f7"
|
||||
|
||||
# Status bar tones. A hint that something needs attention later is worth a colour, so a
|
||||
# placement that leaves the schema out of step is not read as a plain confirmation.
|
||||
STATUS_INFO_COLOUR = "#333333"
|
||||
@@ -48,11 +85,15 @@ is_dirty = False
|
||||
current_file_path = None
|
||||
active_components_list = [] # Tracks what components are actively painted
|
||||
selected_canvas_tag = None # Tracks the currently clicked/highlighted canvas component tag
|
||||
selected_canvas_tags = set() # Every selected box, the one above included; Ctrl+click adds
|
||||
selected_tree_item_id = None # Tracks the currently right-clicked Treeview item
|
||||
link_mark_element = None # Component marked as a link endpoint via "Link Mark"
|
||||
selected_link_tag = None # Canvas tag of the currently selected (red-highlighted) link wire
|
||||
selected_link_tags = [] # Every selected link wire, in picking order; Ctrl+click adds.
|
||||
# selected_link_tag is the last of these.
|
||||
unroutable_link_count = 0 # Links drawn as knobs because no wire could avoid a component
|
||||
meta_scale = DEFAULT_GRID_SIZE # Pixels per meta coordinate step on the painted sheet
|
||||
zoom = 1.0 # Scale the open sheet is painted at; 1.0 is one pixel per pixel
|
||||
knob_link_ends = {} # wire tag -> (x1, y1, x2, y2) for a link shown as two knobs
|
||||
canvas_link_map = {} # Canvas link tag -> ET.Element (<link>)
|
||||
xml_root_element = None # Keep a persistent root reference to redraw tree layouts cleanly
|
||||
@@ -79,6 +120,9 @@ def load_preferences():
|
||||
box_width = DEFAULT_BOX_WIDTH
|
||||
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:
|
||||
@@ -90,6 +134,9 @@ def load_preferences():
|
||||
"Editor", "left_pane_width", fallback=DEFAULT_LEFT_PANE_WIDTH
|
||||
)
|
||||
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:
|
||||
@@ -110,23 +157,60 @@ 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
|
||||
return (sedona_home, grid_size, add_timestamp, box_width, left_pane_width, trace_dasp,
|
||||
lazy_loading, show_slot_numbers)
|
||||
|
||||
|
||||
def save_preferences():
|
||||
"""Writes the current preference values back to editor.properties."""
|
||||
"""Writes the current preference values back to editor.properties.
|
||||
|
||||
The file is read first so the sections other code owns ([Device], [Palette]) survive.
|
||||
"""
|
||||
config = configparser.ConfigParser()
|
||||
try:
|
||||
config.read(PROPERTIES_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
config["Editor"] = {
|
||||
"sedona.home": SEDONA_HOME,
|
||||
"grid_size": str(GRID_SIZE),
|
||||
"add_timestamp": str(ADD_TIMESTAMP),
|
||||
"box_width": str(BOX_WIDTH),
|
||||
"left_pane_width": str(LEFT_PANE_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)
|
||||
|
||||
|
||||
def load_last_palette_kit():
|
||||
"""The kit the palette dropdown showed last, or "" when none was ever chosen."""
|
||||
config = configparser.ConfigParser()
|
||||
try:
|
||||
config.read(PROPERTIES_FILE)
|
||||
return config.get("Palette", "kit", fallback="").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def save_last_palette_kit(kit_name):
|
||||
"""Remembers the palette's kit so the next start, or a kit refresh, reopens it."""
|
||||
global LAST_PALETTE_KIT
|
||||
LAST_PALETTE_KIT = kit_name
|
||||
config = configparser.ConfigParser()
|
||||
try:
|
||||
config.read(PROPERTIES_FILE)
|
||||
if "Palette" not in config:
|
||||
config["Palette"] = {}
|
||||
config["Palette"]["kit"] = kit_name
|
||||
with open(PROPERTIES_FILE, "w") as configfile:
|
||||
config.write(configfile)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def base_window_title():
|
||||
"""The window title with no file context, which handlers extend with their own suffix."""
|
||||
return f"Sedona SAX Offline Program Editor [v{VERSION}]"
|
||||
@@ -147,21 +231,61 @@ def clear_status():
|
||||
set_status("")
|
||||
|
||||
|
||||
def refresh_dirty_display():
|
||||
"""Nudges whatever shows 'there is something unwritten'. A no-op before the UI exists."""
|
||||
if dirty_display_callback is not None:
|
||||
dirty_display_callback()
|
||||
|
||||
|
||||
def mark_dirty():
|
||||
"""Single funnel for 'the document changed', including the unsaved-state tint."""
|
||||
global is_dirty
|
||||
if not is_dirty:
|
||||
is_dirty = True
|
||||
if canvas is not None:
|
||||
canvas.config(bg="#ffe6e6")
|
||||
paint_sheet_tint()
|
||||
refresh_dirty_display()
|
||||
|
||||
|
||||
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="white")
|
||||
paint_sheet_tint()
|
||||
refresh_dirty_display()
|
||||
|
||||
|
||||
SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH, LEFT_PANE_WIDTH = load_preferences()
|
||||
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
|
||||
device_host = ""
|
||||
device_port = 1876
|
||||
connection_status = "offline" # offline | connecting | connected | error
|
||||
|
||||
|
||||
def base_window_title_live():
|
||||
"""Window title for live mode."""
|
||||
return f"Sedona Live Editor [v{VERSION}] — {device_host}:{device_port}"
|
||||
|
||||
|
||||
# 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()
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 444 B |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 461 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -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()
|
||||
@@ -0,0 +1,514 @@
|
||||
"""
|
||||
Connection and discovery dialogs for live Sedona devices.
|
||||
|
||||
ConnectDialog — manual host/port/user/password entry (Device > Connect).
|
||||
DiscoverDialog — DASP broadcast discovery with device list; selecting a
|
||||
device opens a credentials prompt, then connects (Device > Discover).
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
import app_state
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# ConnectDialog (Device > Connect…)
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ConnectDialog:
|
||||
"""Modal dialog for entering device connection parameters."""
|
||||
|
||||
def __init__(self, parent, on_connect_callback, on_before_connect=None):
|
||||
self._callback = on_connect_callback
|
||||
self._before_connect = on_before_connect
|
||||
self._thread = None
|
||||
self._cancelled = False
|
||||
|
||||
self._dlg = tk.Toplevel(parent)
|
||||
self._dlg.title("Connect to Device")
|
||||
self._dlg.resizable(False, False)
|
||||
self._dlg.transient(parent)
|
||||
self._dlg.grab_set()
|
||||
|
||||
host, port, user = _load_last_connection()
|
||||
|
||||
frame = ttk.Frame(self._dlg, padding=16)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
row = 0
|
||||
ttk.Label(frame, text="Host:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
|
||||
self._host_var = tk.StringVar(value=host)
|
||||
self._host_entry = ttk.Entry(frame, textvariable=self._host_var, width=28)
|
||||
self._host_entry.grid(row=row, column=1, columnspan=2, sticky="ew", pady=4)
|
||||
|
||||
row += 1
|
||||
ttk.Label(frame, text="Port:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
|
||||
self._port_var = tk.StringVar(value=port)
|
||||
ttk.Entry(frame, textvariable=self._port_var, width=8).grid(
|
||||
row=row, column=1, sticky="w", pady=4)
|
||||
|
||||
row += 1
|
||||
ttk.Label(frame, text="Username:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
|
||||
self._user_var = tk.StringVar(value=user)
|
||||
ttk.Entry(frame, textvariable=self._user_var, width=28).grid(
|
||||
row=row, column=1, columnspan=2, sticky="ew", pady=4)
|
||||
|
||||
row += 1
|
||||
ttk.Label(frame, text="Password:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
|
||||
self._pass_var = tk.StringVar()
|
||||
ttk.Entry(frame, textvariable=self._pass_var, width=28, show="*").grid(
|
||||
row=row, column=1, columnspan=2, sticky="ew", pady=4)
|
||||
|
||||
row += 1
|
||||
self._status_label = ttk.Label(frame, text="", foreground="#666666")
|
||||
self._status_label.grid(row=row, column=0, columnspan=3, sticky="w", pady=(8, 4))
|
||||
|
||||
row += 1
|
||||
btn_frame = ttk.Frame(frame)
|
||||
btn_frame.grid(row=row, column=0, columnspan=3, sticky="e", pady=(8, 0))
|
||||
|
||||
self._btn_cancel = ttk.Button(btn_frame, text="Cancel", command=self._do_cancel)
|
||||
self._btn_cancel.pack(side=tk.RIGHT)
|
||||
|
||||
self._btn_connect = ttk.Button(btn_frame, text="Connect", command=self._do_connect)
|
||||
self._btn_connect.pack(side=tk.RIGHT, padx=(0, 8))
|
||||
|
||||
self._host_entry.focus_set()
|
||||
self._dlg.bind("<Return>", lambda e: self._do_connect())
|
||||
self._dlg.bind("<Escape>", lambda e: self._do_cancel())
|
||||
|
||||
self._dlg.update_idletasks()
|
||||
x = parent.winfo_x() + (parent.winfo_width() - self._dlg.winfo_width()) // 2
|
||||
y = parent.winfo_y() + (parent.winfo_height() - self._dlg.winfo_height()) // 2
|
||||
self._dlg.geometry(f"+{x}+{y}")
|
||||
|
||||
def _do_connect(self):
|
||||
host = self._host_var.get().strip()
|
||||
port = self._port_var.get().strip()
|
||||
user = self._user_var.get().strip()
|
||||
password = self._pass_var.get()
|
||||
|
||||
if not host:
|
||||
self._status_label.config(text="Host is required", foreground="#c00000")
|
||||
return
|
||||
if not user:
|
||||
self._status_label.config(text="Username is required", foreground="#c00000")
|
||||
return
|
||||
|
||||
try:
|
||||
port_int = int(port)
|
||||
except ValueError:
|
||||
self._status_label.config(text="Invalid port number", foreground="#c00000")
|
||||
return
|
||||
|
||||
# Runs here, on the main thread, rather than in the worker: it is where a
|
||||
# device already connected is let go, and that takes the UI down with it.
|
||||
if self._before_connect is not None:
|
||||
self._before_connect()
|
||||
|
||||
self._btn_connect.config(state="disabled")
|
||||
self._status_label.config(text="Connecting...", foreground="#666666")
|
||||
|
||||
self._cancelled = False
|
||||
self._thread = threading.Thread(
|
||||
target=self._connect_worker,
|
||||
args=(host, port_int, user, password),
|
||||
daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
self._poll_connect()
|
||||
|
||||
def _connect_worker(self, host, port, user, password):
|
||||
try:
|
||||
result = self._callback(host, port, user, password)
|
||||
if self._cancelled:
|
||||
return
|
||||
self._result = ("ok", result, host, port, user)
|
||||
except Exception as e:
|
||||
if self._cancelled:
|
||||
return
|
||||
self._result = ("error", str(e), host, port, user)
|
||||
|
||||
def _poll_connect(self):
|
||||
if self._cancelled:
|
||||
return
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
self._dlg.after(100, self._poll_connect)
|
||||
return
|
||||
|
||||
status, *rest = self._result
|
||||
if status == "ok":
|
||||
_, host, port, user = rest
|
||||
_save_last_connection(host, str(port), user)
|
||||
self._dlg.destroy()
|
||||
else:
|
||||
msg = rest[0]
|
||||
self._status_label.config(text=f"Error: {msg}", foreground="#c00000")
|
||||
self._btn_connect.config(state="normal")
|
||||
|
||||
def _do_cancel(self):
|
||||
self._cancelled = True
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
import app_state as _as
|
||||
def _cleanup():
|
||||
self._thread.join(timeout=5)
|
||||
if _as.device_session is not None and _as.live_mode:
|
||||
try:
|
||||
_as.device_session.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
_as.live_mode = False
|
||||
_as.device_session = None
|
||||
_as.connection_status = "offline"
|
||||
threading.Thread(target=_cleanup, daemon=True).start()
|
||||
self._dlg.destroy()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# DiscoverDialog (Device > Discover…)
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class DiscoverDialog:
|
||||
"""Modal dialog that broadcasts DASP DISCOVER, lists responding
|
||||
devices, and lets the user pick one to connect to."""
|
||||
|
||||
def __init__(self, parent, on_connect_callback, on_before_connect=None):
|
||||
self._parent = parent
|
||||
self._callback = on_connect_callback
|
||||
self._before_connect = on_before_connect
|
||||
self._discover_thread = None
|
||||
|
||||
self._dlg = tk.Toplevel(parent)
|
||||
self._dlg.title("Discover Devices")
|
||||
self._dlg.resizable(False, False)
|
||||
self._dlg.transient(parent)
|
||||
self._dlg.grab_set()
|
||||
|
||||
frame = ttk.Frame(self._dlg, padding=16)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# ── Header row: label + wait + discover button ─────────────
|
||||
row = 0
|
||||
hdr = ttk.Frame(frame)
|
||||
hdr.grid(row=row, column=0, sticky="ew", pady=(0, 8))
|
||||
|
||||
ttk.Label(hdr, text="Discovered Devices:").pack(side=tk.LEFT)
|
||||
|
||||
self._btn_discover = ttk.Button(
|
||||
hdr, text="Discover", command=self._do_discover, width=10)
|
||||
self._btn_discover.pack(side=tk.RIGHT)
|
||||
|
||||
self._wait_var = tk.StringVar(value="2")
|
||||
ttk.Entry(hdr, textvariable=self._wait_var, width=4, justify="center").pack(
|
||||
side=tk.RIGHT, padx=(0, 4))
|
||||
ttk.Label(hdr, text="Wait (s):").pack(side=tk.RIGHT, padx=(8, 2))
|
||||
|
||||
_, port, _ = _load_last_connection()
|
||||
self._port_var = tk.StringVar(value=port)
|
||||
ttk.Entry(hdr, textvariable=self._port_var, width=6, justify="center").pack(
|
||||
side=tk.RIGHT, padx=(0, 4))
|
||||
ttk.Label(hdr, text="Port:").pack(side=tk.RIGHT, padx=(8, 2))
|
||||
|
||||
# ── Device list ────────────────────────────────────────────
|
||||
row += 1
|
||||
list_frame = ttk.Frame(frame)
|
||||
list_frame.grid(row=row, column=0, sticky="nsew", pady=(0, 4))
|
||||
frame.rowconfigure(row, weight=1)
|
||||
|
||||
columns = ("ip", "platform")
|
||||
self._dev_tree = ttk.Treeview(
|
||||
list_frame, columns=columns, show="headings", height=6, selectmode="browse")
|
||||
self._dev_tree.heading("ip", text="IP Address")
|
||||
self._dev_tree.heading("platform", text="Platform ID")
|
||||
self._dev_tree.column("ip", width=150, minwidth=100)
|
||||
self._dev_tree.column("platform", width=220, minwidth=120)
|
||||
self._dev_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self._dev_tree.yview)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
self._dev_tree.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
self._dev_tree.bind("<Double-1>", lambda e: self._do_connect_selected())
|
||||
|
||||
# ── Status + buttons ───────────────────────────────────────
|
||||
row += 1
|
||||
self._status_label = ttk.Label(frame, text="", foreground="#666666")
|
||||
self._status_label.grid(row=row, column=0, sticky="w", pady=(8, 4))
|
||||
|
||||
row += 1
|
||||
btn_frame = ttk.Frame(frame)
|
||||
btn_frame.grid(row=row, column=0, sticky="e", pady=(8, 0))
|
||||
|
||||
ttk.Button(btn_frame, text="Cancel", command=self._dlg.destroy).pack(side=tk.RIGHT)
|
||||
self._btn_connect = ttk.Button(
|
||||
btn_frame, text="Connect", command=self._do_connect_selected)
|
||||
self._btn_connect.pack(side=tk.RIGHT, padx=(0, 8))
|
||||
|
||||
self._dlg.bind("<Escape>", lambda e: self._dlg.destroy())
|
||||
self._dlg.bind("<Return>", self._on_return)
|
||||
|
||||
self._dlg.update_idletasks()
|
||||
x = parent.winfo_x() + (parent.winfo_width() - self._dlg.winfo_width()) // 2
|
||||
y = parent.winfo_y() + (parent.winfo_height() - self._dlg.winfo_height()) // 2
|
||||
self._dlg.geometry(f"+{x}+{y}")
|
||||
self._dlg.lift()
|
||||
self._dlg.focus_force()
|
||||
self._btn_discover.focus_set()
|
||||
|
||||
# ── Discovery ──────────────────────────────────────────────────
|
||||
|
||||
def _on_return(self, event=None):
|
||||
if str(self._btn_discover.cget("state")) != "disabled":
|
||||
self._do_discover()
|
||||
return "break"
|
||||
|
||||
def _do_discover(self):
|
||||
try:
|
||||
port_int = int(self._port_var.get().strip())
|
||||
except ValueError:
|
||||
port_int = 1876
|
||||
|
||||
try:
|
||||
wait_time = max(1, min(60, int(self._wait_var.get().strip())))
|
||||
except ValueError:
|
||||
wait_time = 2
|
||||
|
||||
self._btn_discover.config(state="disabled")
|
||||
self._status_label.config(text="Discovering devices...", foreground="#666666")
|
||||
|
||||
for item in self._dev_tree.get_children():
|
||||
self._dev_tree.delete(item)
|
||||
|
||||
self._discover_thread = threading.Thread(
|
||||
target=self._discover_worker, args=(port_int, wait_time), daemon=True)
|
||||
self._discover_thread.start()
|
||||
self._poll_discover()
|
||||
|
||||
def _discover_worker(self, port, wait_time):
|
||||
from dasp import DaspSocket
|
||||
sock = None
|
||||
try:
|
||||
sock = DaspSocket()
|
||||
devices = sock.discover(port=port, wait_time=wait_time)
|
||||
self._discover_result = ("ok", devices)
|
||||
except Exception as e:
|
||||
self._discover_result = ("error", str(e))
|
||||
finally:
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _poll_discover(self):
|
||||
if self._discover_thread is not None and self._discover_thread.is_alive():
|
||||
self._dlg.after(100, self._poll_discover)
|
||||
return
|
||||
|
||||
status, data = self._discover_result
|
||||
self._btn_discover.config(state="normal")
|
||||
|
||||
if status == "ok":
|
||||
if data:
|
||||
for ip, platform_id in data:
|
||||
self._dev_tree.insert("", tk.END, values=(ip, platform_id))
|
||||
self._status_label.config(
|
||||
text=f"Found {len(data)} device(s)", foreground="#006600")
|
||||
else:
|
||||
self._status_label.config(text="No devices found", foreground="#666666")
|
||||
else:
|
||||
self._status_label.config(
|
||||
text=f"Discovery error: {data}", foreground="#c00000")
|
||||
|
||||
# ── Connect selected device ────────────────────────────────────
|
||||
|
||||
def _do_connect_selected(self):
|
||||
sel = self._dev_tree.selection()
|
||||
if not sel:
|
||||
self._status_label.config(text="Select a device first", foreground="#c00000")
|
||||
return
|
||||
values = self._dev_tree.item(sel[0], "values")
|
||||
host = values[0]
|
||||
port = self._port_var.get().strip()
|
||||
self._dlg.destroy()
|
||||
_CredentialsDialog(self._parent, self._callback, host, port, self._before_connect)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# _CredentialsDialog (username / password prompt after discovery)
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _CredentialsDialog:
|
||||
"""Small modal that asks for username and password, then connects."""
|
||||
|
||||
def __init__(self, parent, on_connect_callback, host, port, on_before_connect=None):
|
||||
self._callback = on_connect_callback
|
||||
self._before_connect = on_before_connect
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._thread = None
|
||||
self._cancelled = False
|
||||
|
||||
_, _, last_user = _load_last_connection()
|
||||
|
||||
self._dlg = tk.Toplevel(parent)
|
||||
self._dlg.title(f"Connect to {host}")
|
||||
self._dlg.resizable(False, False)
|
||||
self._dlg.transient(parent)
|
||||
self._dlg.grab_set()
|
||||
|
||||
frame = ttk.Frame(self._dlg, padding=16)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
row = 0
|
||||
ttk.Label(frame, text="Host:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
|
||||
ttk.Label(frame, text=f"{host}:{port}").grid(row=row, column=1, sticky="w", pady=4)
|
||||
|
||||
row += 1
|
||||
ttk.Label(frame, text="Username:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
|
||||
self._user_var = tk.StringVar(value=last_user)
|
||||
user_entry = ttk.Entry(frame, textvariable=self._user_var, width=24)
|
||||
user_entry.grid(row=row, column=1, sticky="ew", pady=4)
|
||||
|
||||
row += 1
|
||||
ttk.Label(frame, text="Password:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
|
||||
self._pass_var = tk.StringVar()
|
||||
ttk.Entry(frame, textvariable=self._pass_var, width=24, show="*").grid(
|
||||
row=row, column=1, sticky="ew", pady=4)
|
||||
|
||||
row += 1
|
||||
self._status_label = ttk.Label(frame, text="", foreground="#666666")
|
||||
self._status_label.grid(row=row, column=0, columnspan=2, sticky="w", pady=(8, 4))
|
||||
|
||||
row += 1
|
||||
btn_frame = ttk.Frame(frame)
|
||||
btn_frame.grid(row=row, column=0, columnspan=2, sticky="e", pady=(8, 0))
|
||||
|
||||
ttk.Button(btn_frame, text="Cancel", command=self._do_cancel).pack(side=tk.RIGHT)
|
||||
self._btn_connect = ttk.Button(btn_frame, text="Connect", command=self._do_connect)
|
||||
self._btn_connect.pack(side=tk.RIGHT, padx=(0, 8))
|
||||
|
||||
user_entry.focus_set()
|
||||
self._dlg.bind("<Return>", lambda e: self._do_connect())
|
||||
self._dlg.bind("<Escape>", lambda e: self._do_cancel())
|
||||
|
||||
self._dlg.update_idletasks()
|
||||
x = parent.winfo_x() + (parent.winfo_width() - self._dlg.winfo_width()) // 2
|
||||
y = parent.winfo_y() + (parent.winfo_height() - self._dlg.winfo_height()) // 2
|
||||
self._dlg.geometry(f"+{x}+{y}")
|
||||
|
||||
def _do_connect(self):
|
||||
user = self._user_var.get().strip()
|
||||
password = self._pass_var.get()
|
||||
|
||||
if not user:
|
||||
self._status_label.config(text="Username is required", foreground="#c00000")
|
||||
return
|
||||
|
||||
try:
|
||||
port_int = int(self._port)
|
||||
except ValueError:
|
||||
self._status_label.config(text="Invalid port", foreground="#c00000")
|
||||
return
|
||||
|
||||
# Runs here, on the main thread, rather than in the worker: it is where a
|
||||
# device already connected is let go, and that takes the UI down with it.
|
||||
if self._before_connect is not None:
|
||||
self._before_connect()
|
||||
|
||||
self._btn_connect.config(state="disabled")
|
||||
self._status_label.config(text="Connecting...", foreground="#666666")
|
||||
|
||||
self._cancelled = False
|
||||
self._thread = threading.Thread(
|
||||
target=self._connect_worker,
|
||||
args=(self._host, port_int, user, password),
|
||||
daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
self._poll_connect()
|
||||
|
||||
def _connect_worker(self, host, port, user, password):
|
||||
try:
|
||||
result = self._callback(host, port, user, password)
|
||||
if self._cancelled:
|
||||
return
|
||||
self._result = ("ok", result, host, port, user)
|
||||
except Exception as e:
|
||||
if self._cancelled:
|
||||
return
|
||||
self._result = ("error", str(e), host, port, user)
|
||||
|
||||
def _poll_connect(self):
|
||||
if self._cancelled:
|
||||
return
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
self._dlg.after(100, self._poll_connect)
|
||||
return
|
||||
|
||||
status, *rest = self._result
|
||||
if status == "ok":
|
||||
_, host, port, user = rest
|
||||
_save_last_connection(host, str(port), user)
|
||||
self._dlg.destroy()
|
||||
else:
|
||||
msg = rest[0]
|
||||
self._status_label.config(text=f"Error: {msg}", foreground="#c00000")
|
||||
self._btn_connect.config(state="normal")
|
||||
|
||||
def _do_cancel(self):
|
||||
self._cancelled = True
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
import app_state as _as
|
||||
def _cleanup():
|
||||
self._thread.join(timeout=5)
|
||||
if _as.device_session is not None and _as.live_mode:
|
||||
try:
|
||||
_as.device_session.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
_as.live_mode = False
|
||||
_as.device_session = None
|
||||
_as.connection_status = "offline"
|
||||
threading.Thread(target=_cleanup, daemon=True).start()
|
||||
self._dlg.destroy()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Persistence helpers
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_last_connection():
|
||||
"""Reads last connection params from editor.properties."""
|
||||
config = configparser.ConfigParser()
|
||||
host = "192.168.1.100"
|
||||
port = "1876"
|
||||
user = "admin"
|
||||
try:
|
||||
if app_state.PROPERTIES_FILE:
|
||||
config.read(app_state.PROPERTIES_FILE)
|
||||
host = config.get("Device", "host", fallback=host)
|
||||
port = config.get("Device", "port", fallback=port)
|
||||
user = config.get("Device", "user", fallback=user)
|
||||
except Exception:
|
||||
pass
|
||||
return host, port, user
|
||||
|
||||
|
||||
def _save_last_connection(host, port, user):
|
||||
"""Saves connection params to editor.properties."""
|
||||
config = configparser.ConfigParser()
|
||||
try:
|
||||
config.read(app_state.PROPERTIES_FILE)
|
||||
if "Device" not in config:
|
||||
config["Device"] = {}
|
||||
config["Device"]["host"] = host
|
||||
config["Device"]["port"] = port
|
||||
config["Device"]["user"] = user
|
||||
with open(app_state.PROPERTIES_FILE, "w") as f:
|
||||
config.write(f)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,620 @@
|
||||
"""
|
||||
DeviceSession — bridges SoxClient to the editor's ET.Element-based UI.
|
||||
|
||||
Handles lazy loading, DOM synthesis from cached SoxComponents,
|
||||
COV event routing, and subscription lifecycle.
|
||||
"""
|
||||
|
||||
import queue
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import app_state
|
||||
import schema
|
||||
from sox import SoxClient, SoxComponent, TREE, CONFIG, RUNTIME, LINKS, ALL
|
||||
|
||||
|
||||
def _fmt_float(val, precision=2):
|
||||
"""Format a float for display with *precision* decimal places (default 2)."""
|
||||
return f"{val:.{precision}f}"
|
||||
|
||||
|
||||
def _slot_precision(type_name, slot_name):
|
||||
"""Looks up the precision facet for a slot, returning 2 as the default."""
|
||||
reg = app_state.final_components_registry.get(type_name)
|
||||
if reg:
|
||||
for s in reg:
|
||||
if s["name"] == slot_name:
|
||||
p = s.get("facets", {}).get("precision")
|
||||
if isinstance(p, int) and 0 <= p <= 12:
|
||||
return p
|
||||
break
|
||||
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."""
|
||||
|
||||
def __init__(self):
|
||||
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 ────────────────────────────────────
|
||||
|
||||
def connect(self, host, port, user, password):
|
||||
"""DASP connect → read schema → load root. Called from background thread."""
|
||||
self.sox_client = SoxClient(host, int(port), user, password)
|
||||
self.sox_client.on_event = self._on_sox_event
|
||||
self.sox_client.checksum_resolver = schema.resolve_truncated_checksum
|
||||
self.sox_client.connect()
|
||||
|
||||
parts = self.sox_client.read_schema()
|
||||
vi = self.sox_client.read_version()
|
||||
|
||||
self._load_slot_registry(parts)
|
||||
|
||||
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, self._values_to_read(children))
|
||||
return root_comp
|
||||
|
||||
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 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]
|
||||
if comps:
|
||||
self.sox_client.unsubscribe(comps, ALL)
|
||||
except Exception:
|
||||
pass
|
||||
self._current_subs.clear()
|
||||
try:
|
||||
self.sox_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.sox_client = None
|
||||
|
||||
@property
|
||||
def is_connected(self):
|
||||
return self.sox_client is not None and not self.sox_client.is_closed
|
||||
|
||||
# ── Schema → Slot Registry ──────────────────────────────────
|
||||
|
||||
def _load_slot_registry(self, parts):
|
||||
"""Builds slot_registry from kit manifests for the device's schema."""
|
||||
registry = {}
|
||||
own_slots = {} # "kit::Type" -> declared slots
|
||||
bases = {} # "kit::Type" -> "kit::Base" or None
|
||||
for kit_id, part in enumerate(parts):
|
||||
kit_name = part.name
|
||||
checksum = part.checksum
|
||||
manifest = schema.find_manifest(kit_name, checksum)
|
||||
if manifest is None:
|
||||
continue
|
||||
self._register_kit_types(registry, kit_id, kit_name, manifest,
|
||||
own_slots, bases)
|
||||
# Flatten each type's slots through its whole base chain (e.g.
|
||||
# Component -> Service -> ontrolOx::Widget -> PushButtonImage), then
|
||||
# renumber IDs to match the wire slot index (positional in the
|
||||
# flattened inherited + own list — see sedona Type.java resolveSlots).
|
||||
for key in registry:
|
||||
qname = self.sox_client.type_name_map[key]
|
||||
chain = []
|
||||
seen = set()
|
||||
t = qname
|
||||
while t and t not in seen and t in own_slots:
|
||||
seen.add(t)
|
||||
chain.append(t)
|
||||
t = bases.get(t)
|
||||
flat = []
|
||||
for t in reversed(chain):
|
||||
flat.extend(dict(s) for s in own_slots[t])
|
||||
for i, slot in enumerate(flat):
|
||||
slot["id"] = i
|
||||
registry[key] = flat
|
||||
self.sox_client.slot_registry = registry
|
||||
|
||||
def _register_kit_types(self, registry, kit_id, kit_name, manifest,
|
||||
own_slots, bases):
|
||||
root = manifest
|
||||
for type_elem in root.iter("type"):
|
||||
type_name = type_elem.get("name", "")
|
||||
type_id_val = int(type_elem.get("id", "-1"))
|
||||
if type_id_val < 0:
|
||||
continue
|
||||
slots = []
|
||||
for slot_elem in type_elem.findall("slot"):
|
||||
slot_name = slot_elem.get("name", "")
|
||||
slot_id = int(slot_elem.get("id", "-1"))
|
||||
slot_type = slot_elem.get("type", "int")
|
||||
flags_str = slot_elem.get("flags", "")
|
||||
is_config = "c" in flags_str or "p" in flags_str
|
||||
is_runtime = "r" in flags_str
|
||||
is_action = "a" in flags_str
|
||||
is_operator = "o" in flags_str
|
||||
is_as_str = slot_type == "Str" or slot_elem.get("asStr") == "true"
|
||||
slots.append({
|
||||
"name": slot_name,
|
||||
"id": slot_id,
|
||||
"type_name": slot_type,
|
||||
"is_config": is_config,
|
||||
"is_runtime": is_runtime,
|
||||
"is_action": is_action,
|
||||
"is_operator": is_operator,
|
||||
"is_as_str": is_as_str,
|
||||
"is_prop": not is_action,
|
||||
})
|
||||
registry[(kit_id, type_id_val)] = slots
|
||||
full_name = f"{kit_name}::{type_name}"
|
||||
own_slots[full_name] = slots
|
||||
bases[full_name] = type_elem.get("base") or None
|
||||
self.sox_client.type_name_map[(kit_id, type_id_val)] = full_name
|
||||
# update type_name on any cached component of this type
|
||||
for c in self.sox_client.cache:
|
||||
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):
|
||||
"""Loads direct children of a component (cache-aware). Returns list of SoxComponent."""
|
||||
parent = self.sox_client.cache_get(comp_id)
|
||||
if parent is None:
|
||||
parent = self.sox_client.load(comp_id)
|
||||
if parent is None:
|
||||
return []
|
||||
uncached = [cid for cid in parent.children
|
||||
if self.sox_client.cache_get(cid) is None]
|
||||
if uncached:
|
||||
self.sox_client.load_ids(uncached, checked=False)
|
||||
return [self.sox_client.cache_get(cid)
|
||||
for cid in parent.children
|
||||
if self.sox_client.cache_get(cid) is not None]
|
||||
|
||||
def used_kit_names(self):
|
||||
"""Every kit a component on the device belongs to, loading the whole tree to see.
|
||||
|
||||
The DOM only holds what has been opened, so it cannot answer this on its own. Only
|
||||
the tree is read — no config or runtime.
|
||||
|
||||
Requests go one at a time. A small controller handles a single folder's burst fine,
|
||||
but a back-to-back stream of them fills its receive buffer; it stops acking, and
|
||||
DASP closes the whole session ("unacked send timeout"). Waiting for each reply
|
||||
before sending the next keeps it at the pace the device sets. Anything already
|
||||
cached costs nothing, so only the first call is slow.
|
||||
"""
|
||||
client = self.sox_client
|
||||
if client is None:
|
||||
return set()
|
||||
|
||||
pending = [0]
|
||||
loaded = 0
|
||||
while pending:
|
||||
comp_id = pending.pop()
|
||||
comp = client.cache_get(comp_id)
|
||||
if comp is None:
|
||||
comp = client.load_ids([comp_id], checked=False)[0]
|
||||
loaded += 1
|
||||
if loaded % 20 == 0:
|
||||
app_state.set_status(f"Reading components from the device… {loaded}")
|
||||
if app_state.status_label is not None:
|
||||
app_state.status_label.update_idletasks()
|
||||
if comp is not None:
|
||||
pending.extend(comp.children)
|
||||
|
||||
parts = client.schema_parts or []
|
||||
return {parts[c.kit_id].name for c in client.cache
|
||||
if c is not None and 0 <= c.kit_id < len(parts)}
|
||||
|
||||
def find_component_of_type(self, type_name, slot_name):
|
||||
"""A component of `type_name` with `slot_name` read, loading where services live.
|
||||
|
||||
Nothing is loaded until a folder is opened, so a platform service the user never
|
||||
expanded to is not cached yet. It sits one or two levels down (/service/plat), and
|
||||
those are loaded to find it rather than the whole app.
|
||||
"""
|
||||
client = self.sox_client
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
def cached():
|
||||
return next((c for c in client.cache
|
||||
if c is not None and c.type_name == type_name), None)
|
||||
|
||||
comp = cached()
|
||||
if comp is None:
|
||||
for top in self.load_children(0):
|
||||
if top.name == "service" or top.type_name == type_name:
|
||||
for child in self.load_children(top.id):
|
||||
if child.children and child.type_name != type_name:
|
||||
self.load_children(child.id)
|
||||
comp = cached()
|
||||
if comp is not None and slot_name not in comp.values:
|
||||
client.update([comp], CONFIG)
|
||||
return comp
|
||||
|
||||
# ── Wiresheet Open / Close ──────────────────────────────────
|
||||
|
||||
def open_wiresheet(self, folder_comp_id):
|
||||
"""Full wiresheet load: children, links, remote comps, config+runtime, subscribe.
|
||||
|
||||
Returns list of SoxComponent for the folder's children.
|
||||
"""
|
||||
children = self.load_children(folder_comp_id)
|
||||
if not children:
|
||||
return []
|
||||
|
||||
all_link_remote_ids = set()
|
||||
for child in children:
|
||||
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)
|
||||
|
||||
child_ids = {c.id for c in children}
|
||||
child_ids.add(folder_comp_id)
|
||||
remote_ids = [rid for rid in all_link_remote_ids
|
||||
if rid not in child_ids
|
||||
and self.sox_client.cache_get(rid) is None]
|
||||
if remote_ids:
|
||||
self.sox_client.load_ids(remote_ids, checked=False)
|
||||
|
||||
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
|
||||
to_subscribe = [c for c in to_subscribe if c is not None]
|
||||
try:
|
||||
self.sox_client.subscribe(to_subscribe, ALL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._current_subs = {c.id for c in to_subscribe}
|
||||
return children
|
||||
|
||||
def close_wiresheet(self):
|
||||
"""Unsubscribe from previously subscribed components."""
|
||||
if not self._current_subs or not self.is_connected:
|
||||
self._current_subs.clear()
|
||||
return
|
||||
comps = [self.sox_client.cache_get(cid)
|
||||
for cid in self._current_subs]
|
||||
comps = [c for c in comps if c is not None]
|
||||
if comps:
|
||||
try:
|
||||
self.sox_client.unsubscribe(comps, ALL)
|
||||
except Exception:
|
||||
pass
|
||||
self._current_subs.clear()
|
||||
|
||||
# ── Post-add / Post-delete bookkeeping ─────────────────────
|
||||
|
||||
def after_add(self, comp_id):
|
||||
"""Subscribes a newly added component and reads its initial values."""
|
||||
comp = self.sox_client.cache_get(comp_id)
|
||||
if comp is None or not self.is_connected:
|
||||
return
|
||||
try:
|
||||
self.sox_client.update([comp], CONFIG | RUNTIME)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.sox_client.subscribe([comp], ALL)
|
||||
self._current_subs.add(comp_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def after_delete(self, comp_id, descendant_ids=None):
|
||||
"""Removes a deleted component (and its descendants) from the subscription set."""
|
||||
self._current_subs.discard(comp_id)
|
||||
if descendant_ids:
|
||||
for did in descendant_ids:
|
||||
self._current_subs.discard(did)
|
||||
|
||||
# ── DOM Synthesis ───────────────────────────────────────────
|
||||
|
||||
def build_dom_tree(self, root_comp):
|
||||
"""Builds an ET.Element tree from cached SoxComponents, for the editor UI.
|
||||
|
||||
Only builds from what is currently in the cache (lazy-loaded components).
|
||||
Returns a root <sedonaApp> element.
|
||||
"""
|
||||
sax_root = ET.Element("sedonaApp")
|
||||
schema_elem = ET.SubElement(sax_root, "schema")
|
||||
if self.sox_client and self.sox_client.schema_parts:
|
||||
for part in self.sox_client.schema_parts:
|
||||
# Sox sends the checksum as a signed int; .sax files and manifest names
|
||||
# spell it as 8 hex digits, and nothing matches unless this does too.
|
||||
ET.SubElement(schema_elem, "kit",
|
||||
name=part.name, checksum=f"{part.checksum & 0xFFFFFFFF:08x}")
|
||||
|
||||
app_elem = ET.SubElement(sax_root, "app")
|
||||
if root_comp is not None:
|
||||
app_elem.set("id", str(root_comp.id))
|
||||
# The app is a component like any other, so the Properties view needs its
|
||||
# type and slot values too. A .sax file leaves both implied; here they are not.
|
||||
app_elem.set("type", root_comp.type_name)
|
||||
self._add_prop_elements(app_elem, root_comp)
|
||||
for child_id in root_comp.children:
|
||||
child_comp = self.sox_client.cache_get(child_id)
|
||||
if child_comp is not None:
|
||||
self._build_comp_element(app_elem, child_comp)
|
||||
else:
|
||||
ET.SubElement(app_elem, "comp",
|
||||
name="...", id=str(child_id), type="")
|
||||
|
||||
return sax_root
|
||||
|
||||
def _build_comp_element(self, parent_elem, sox_comp):
|
||||
type_qname = sox_comp.type_name
|
||||
attribs = {
|
||||
"name": sox_comp.name,
|
||||
"id": str(sox_comp.id),
|
||||
"type": type_qname,
|
||||
}
|
||||
comp_elem = ET.SubElement(parent_elem, "comp", **attribs)
|
||||
self._add_prop_elements(comp_elem, sox_comp)
|
||||
|
||||
for child_id in sox_comp.children:
|
||||
child_comp = self.sox_client.cache_get(child_id)
|
||||
if child_comp is not None:
|
||||
self._build_comp_element(comp_elem, child_comp)
|
||||
else:
|
||||
# Placeholder so the tree shows a + expand arrow for uncached children
|
||||
ET.SubElement(comp_elem, "comp",
|
||||
name="...", id=str(child_id), type="")
|
||||
|
||||
return comp_elem
|
||||
|
||||
def _add_prop_elements(self, comp_elem, sox_comp):
|
||||
"""Writes a <prop> for every cached non-action slot value of `sox_comp`."""
|
||||
slots = self.sox_client.slot_registry.get(
|
||||
(sox_comp.kit_id, sox_comp.type_id))
|
||||
if not slots:
|
||||
return
|
||||
for slot in slots:
|
||||
if slot.get("is_action"):
|
||||
continue
|
||||
name = slot["name"]
|
||||
if name in sox_comp.values:
|
||||
val = sox_comp.values[name]
|
||||
if isinstance(val, float):
|
||||
val_str = _fmt_float(val, _slot_precision(sox_comp.type_name, name))
|
||||
else:
|
||||
val_str = str(val)
|
||||
ET.SubElement(comp_elem, "prop", name=name, val=val_str)
|
||||
|
||||
def build_links_elements(self, sax_root):
|
||||
"""Adds <link> elements under a <links> container, matching .sax structure."""
|
||||
links_elem = sax_root.find("links")
|
||||
if links_elem is None:
|
||||
links_elem = ET.SubElement(sax_root, "links")
|
||||
|
||||
seen = set()
|
||||
for c in self.sox_client.cache:
|
||||
if c is None:
|
||||
continue
|
||||
for lnk in c.links:
|
||||
link_key = (lnk.from_comp_id, lnk.from_slot_id,
|
||||
lnk.to_comp_id, lnk.to_slot_id)
|
||||
if link_key in seen:
|
||||
continue
|
||||
seen.add(link_key)
|
||||
|
||||
from_comp = self.sox_client.cache_get(lnk.from_comp_id)
|
||||
to_comp = self.sox_client.cache_get(lnk.to_comp_id)
|
||||
if from_comp is None or to_comp is None:
|
||||
continue
|
||||
|
||||
from_slot_name = self._slot_name_by_id(
|
||||
from_comp.kit_id, from_comp.type_id, lnk.from_slot_id)
|
||||
to_slot_name = self._slot_name_by_id(
|
||||
to_comp.kit_id, to_comp.type_id, lnk.to_slot_id)
|
||||
|
||||
from_path = self._comp_path(lnk.from_comp_id)
|
||||
to_path = self._comp_path(lnk.to_comp_id)
|
||||
|
||||
if from_path and to_path and from_slot_name and to_slot_name:
|
||||
ET.SubElement(links_elem, "link",
|
||||
**{"from": f"{from_path}.{from_slot_name}",
|
||||
"to": f"{to_path}.{to_slot_name}"})
|
||||
|
||||
def _slot_name_by_id(self, kit_id, type_id_val, slot_id):
|
||||
# SOX links use absolute slot IDs across the full inheritance chain.
|
||||
type_name = self.sox_client.type_name_map.get((kit_id, type_id_val))
|
||||
if type_name and type_name in app_state.final_components_registry:
|
||||
for s in app_state.final_components_registry[type_name]:
|
||||
if s["slot_id"] == slot_id:
|
||||
return s["name"]
|
||||
# Fallback to slot_registry (flattened through the base chain, so
|
||||
# its IDs are absolute too)
|
||||
slots = self.sox_client.slot_registry.get((kit_id, type_id_val))
|
||||
if slots:
|
||||
for s in slots:
|
||||
if s["id"] == slot_id:
|
||||
return s["name"]
|
||||
return None
|
||||
|
||||
def _comp_path(self, comp_id):
|
||||
parts = []
|
||||
cid = comp_id
|
||||
while cid >= 0:
|
||||
c = self.sox_client.cache_get(cid)
|
||||
if c is None:
|
||||
break
|
||||
if c.id == 0:
|
||||
break
|
||||
parts.append(c.name)
|
||||
cid = c.parent
|
||||
if not parts:
|
||||
return None
|
||||
parts.reverse()
|
||||
return "/" + "/".join(parts)
|
||||
|
||||
# ── Update DOM from COV ─────────────────────────────────────
|
||||
|
||||
def update_element_from_cache(self, comp_id, sax_root):
|
||||
"""Updates a single comp element in the DOM from cached SoxComponent values."""
|
||||
sox_comp = self.sox_client.cache_get(comp_id)
|
||||
if sox_comp is None:
|
||||
return None
|
||||
|
||||
comp_elem = self._find_element_by_id(sax_root, comp_id)
|
||||
if comp_elem is None:
|
||||
return None
|
||||
|
||||
type_name = sox_comp.type_name
|
||||
slots = self.sox_client.slot_registry.get(
|
||||
(sox_comp.kit_id, sox_comp.type_id))
|
||||
if slots:
|
||||
existing_props = {p.get("name"): p for p in comp_elem.findall("prop")}
|
||||
for slot in slots:
|
||||
if slot.get("is_action"):
|
||||
continue
|
||||
name = slot["name"]
|
||||
if name in sox_comp.values:
|
||||
raw = sox_comp.values[name]
|
||||
if isinstance(raw, float):
|
||||
val = _fmt_float(raw, _slot_precision(type_name, name))
|
||||
else:
|
||||
val = str(raw)
|
||||
if name in existing_props:
|
||||
existing_props[name].set("val", val)
|
||||
else:
|
||||
ET.SubElement(comp_elem, "prop", name=name, val=val)
|
||||
return comp_elem
|
||||
|
||||
@staticmethod
|
||||
def _find_element_by_id(root, comp_id):
|
||||
target = str(comp_id)
|
||||
app_elem = root.find("app")
|
||||
if app_elem is not None and app_elem.get("id") == target:
|
||||
return app_elem
|
||||
for elem in root.iter("comp"):
|
||||
if elem.get("id") == target:
|
||||
return elem
|
||||
return None
|
||||
|
||||
# ── Event queue (thread-safe) ───────────────────────────────
|
||||
|
||||
def _on_sox_event(self, comp_id, what_char):
|
||||
self._event_queue.put((comp_id, what_char))
|
||||
|
||||
def poll_events(self):
|
||||
"""Drains the event queue. Returns list of (comp_id, what_char) tuples."""
|
||||
events = []
|
||||
try:
|
||||
while True:
|
||||
events.append(self._event_queue.get_nowait())
|
||||
except queue.Empty:
|
||||
pass
|
||||
return events
|
||||
+13
-1
@@ -1,6 +1,18 @@
|
||||
[Editor]
|
||||
sedona.home = C:\Users\aa\OneDrive - Ontrol\Documents\_is\NiagaraAXSedona\sedona
|
||||
sedona.home = C:\Niagara\sedona
|
||||
grid_size = 10
|
||||
add_timestamp = True
|
||||
box_width = 250
|
||||
left_pane_width = 230
|
||||
trace_dasp = True
|
||||
lazy_loading = True
|
||||
show_slot_numbers = False
|
||||
|
||||
[Device]
|
||||
host = 192.168.0.26
|
||||
port = 1876
|
||||
user = admin
|
||||
|
||||
[Palette]
|
||||
kit = control
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"0": "SOX Client Protocol", "1": "Link View Rendering", "2": "Kit Palette & Manifests", "3": "App State & File IO", "4": "SAX File Kit Management", "5": "Live Device Hooks", "6": "Property Value Logic", "7": "DOM & Undo Core", "8": "Kit Manager Panel", "9": "Connection Dialogs", "10": "Module Imports & Config", "11": "README Documentation", "12": "Main UI & Zoom", "13": "Properties Panel State", "14": "SOX Message Codec", "15": "DASP Send Window", "16": "Main Panel Management", "17": "DASP & SOX Imports", "18": "Wiresheet Layout Engine", "19": "Schema Kit Resolution", "20": "Wire Route Pathfinding", "21": "DASP Session Transport", "22": "Device Session Manager", "23": "Link Dialog & Slots", "24": "Context Menu & Navigation", "25": "DASP Socket Layer", "26": "View History Navigation", "27": "Schema Registry & Facets", "28": "Properties Header & Filter", "29": "Link Context Menu", "30": "Properties Row Building", "31": "Tree Selection Restore", "32": "Wiresheet Box Drawing", "33": "Preferences Dialog", "34": "Wiresheet Box Sizing", "35": "Schema Slot Inheritance", "36": "Device Connect & Load", "37": "Tree Scrollbar & Palette", "38": "Device DOM Builder", "39": "Canvas Click & Link Select", "40": "Canvas Drag & Redraw", "41": "Meta Property Editor", "42": "Wire Knob & Anchor", "43": "DASP Receive Window", "44": "Component Deletion", "45": "File Save Pipeline", "46": "Zoom Fit & Bounds", "47": "Slot Value Undo", "48": "Bool & Null Display", "49": "Sheet Zoom & Grid", "50": "Tooltip Widget", "51": "SOX Connection & Receiver", "52": "Add Component Undo", "53": "Add Link Undo", "54": "Delete Component Undo", "55": "Delete Link Undo", "56": "Schema Compilation", "57": "Property Label Humanise", "58": "Run Entry Point"}
|
||||
@@ -0,0 +1 @@
|
||||
C:\Users\aa\AppData\Local\Python\pythoncore-3.14-64\python.exe
|
||||
@@ -0,0 +1 @@
|
||||
C:\Users\aa\_aaSourceCodes\sedona_editor\raw
|
||||
@@ -0,0 +1,332 @@
|
||||
# Graph Report - sedona_editor (2026-09-20)
|
||||
|
||||
## Corpus Check
|
||||
- 18 files · ~55,766 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
- Unclassified: 3 file(s) not represented in the graph (top: (none) 1, .properties 1, .sax 1)
|
||||
|
||||
## Summary
|
||||
- 1033 nodes · 2000 edges · 59 communities (52 shown, 7 thin omitted)
|
||||
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 50 edges (avg confidence: 0.87)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- SOX Client Protocol
|
||||
- Link View Rendering
|
||||
- Kit Palette & Manifests
|
||||
- App State & File IO
|
||||
- SAX File Kit Management
|
||||
- Live Device Hooks
|
||||
- Property Value Logic
|
||||
- DOM & Undo Core
|
||||
- Kit Manager Panel
|
||||
- Connection Dialogs
|
||||
- Module Imports & Config
|
||||
- README Documentation
|
||||
- Main UI & Zoom
|
||||
- Properties Panel State
|
||||
- SOX Message Codec
|
||||
- DASP Send Window
|
||||
- Main Panel Management
|
||||
- DASP & SOX Imports
|
||||
- Wiresheet Layout Engine
|
||||
- Schema Kit Resolution
|
||||
- Wire Route Pathfinding
|
||||
- DASP Session Transport
|
||||
- Device Session Manager
|
||||
- Link Dialog & Slots
|
||||
- Context Menu & Navigation
|
||||
- DASP Socket Layer
|
||||
- View History Navigation
|
||||
- Schema Registry & Facets
|
||||
- Properties Header & Filter
|
||||
- Link Context Menu
|
||||
- Properties Row Building
|
||||
- Tree Selection Restore
|
||||
- Wiresheet Box Drawing
|
||||
- Preferences Dialog
|
||||
- Wiresheet Box Sizing
|
||||
- Schema Slot Inheritance
|
||||
- Device Connect & Load
|
||||
- Tree Scrollbar & Palette
|
||||
- Device DOM Builder
|
||||
- Canvas Click & Link Select
|
||||
- Canvas Drag & Redraw
|
||||
- Meta Property Editor
|
||||
- Wire Knob & Anchor
|
||||
- DASP Receive Window
|
||||
- Component Deletion
|
||||
- File Save Pipeline
|
||||
- Zoom Fit & Bounds
|
||||
- Slot Value Undo
|
||||
- Bool & Null Display
|
||||
- Sheet Zoom & Grid
|
||||
- Tooltip Widget
|
||||
- SOX Connection & Receiver
|
||||
- Add Component Undo
|
||||
- Add Link Undo
|
||||
- Delete Component Undo
|
||||
- Delete Link Undo
|
||||
- Schema Compilation
|
||||
- Property Label Humanise
|
||||
- Run Entry Point
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `SoxClient` - 51 edges
|
||||
2. `PropertiesPanel` - 37 edges
|
||||
3. `LinkViewPanel` - 32 edges
|
||||
4. `set_status()` - 30 edges
|
||||
5. `SoxMsg` - 27 edges
|
||||
6. `DeviceSession` - 25 edges
|
||||
7. `add_component()` - 22 edges
|
||||
8. `DaspSocket` - 20 edges
|
||||
9. `KitsPanel` - 20 edges
|
||||
10. `_prepare_request()` - 19 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `DiscoverDialog` --uses--> `DaspSocket` [INFERRED]
|
||||
connect_dialog.py → dasp.py
|
||||
- `_SoxReceiver` --uses--> `DaspException` [INFERRED]
|
||||
sox.py → dasp.py
|
||||
- `SoxClient` --uses--> `DaspSocket` [INFERRED]
|
||||
sox.py → dasp.py
|
||||
- `DeviceSession` --uses--> `SoxClient` [INFERRED]
|
||||
device_session.py → sox.py
|
||||
- `verify_startup_configurations()` --calls--> `save_preferences()` [EXTRACTED]
|
||||
main.py → app_state.py
|
||||
|
||||
## Import Cycles
|
||||
- None detected.
|
||||
|
||||
## Communities (59 total, 7 thin omitted)
|
||||
|
||||
### Community 0 - "SOX Client Protocol"
|
||||
Cohesion: 0.07
|
||||
Nodes (14): decode_value(), encode_value(), KitPart, Link, _make_subscribe_req(), _make_unsubscribe_req(), _make_update_req(), _prepare_request() (+6 more)
|
||||
|
||||
### Community 1 - "Link View Rendering"
|
||||
Cohesion: 0.06
|
||||
Nodes (26): build_link_view_panel(), column_gap(), group_by_peer(), LinkViewPanel, Collapses the entries of one side onto one box per peer. A component wired…, Where a peer belongs in its column: the first focus slot its links land on. A…, One component's links, drawn around it on a canvas of this view's own. Built…, Moves a scrollbar's thumb, and hides the bar when it spans the whole. (+18 more)
|
||||
|
||||
### Community 2 - "Kit Palette & Manifests"
|
||||
Cohesion: 0.07
|
||||
Nodes (44): action_options(), collect_rows(), column_label(), Every kit worth listing, as row dicts in the order they should appear. A row…, How a build is named in the Installed and Latest columns, which are narrow., The Action dropdown for one kit: (label, checksum to apply) pairs, no-op first.…, _find_type(), invalidate() (+36 more)
|
||||
|
||||
### Community 3 - "App State & File IO"
|
||||
Cohesion: 0.09
|
||||
Nodes (40): base_window_title(), clear_status(), mark_clean(), The window title with no file context, which handlers extend with their own…, Posts a one-line note to the status bar. A no-op before the UI exists., Empties the status bar, for when the note no longer describes anything on…, Clears the unsaved-state marker after a successful save or a fresh load., set_status() (+32 more)
|
||||
|
||||
### Community 4 - "SAX File Kit Management"
|
||||
Cohesion: 0.08
|
||||
Nodes (37): _coerce_value(), The value a slot holds when neither the file nor the manifest names one., type_zero(), Copies an element's children and leading text, so it can be put back wholesale.…, snapshot_children(), add_component(), allocate_component_id(), _build_config_values() (+29 more)
|
||||
|
||||
### Community 5 - "Live Device Hooks"
|
||||
Cohesion: 0.10
|
||||
Nodes (34): mark_dirty(), Nudges whatever shows 'there is something unwritten'. A no-op before the UI…, Single funnel for 'the document changed', including the unsaved-state tint., refresh_dirty_display(), _comp_id(), _find_slot(), is_live(), _kit_id() (+26 more)
|
||||
|
||||
### Community 6 - "Property Value Logic"
|
||||
Cohesion: 0.09
|
||||
Nodes (30): allows_null(), bool_texts(), build_properties_panel(), display_text(), effective_value(), enum_range(), is_editable(), null_text() (+22 more)
|
||||
|
||||
### Community 7 - "DOM & Undo Core"
|
||||
Cohesion: 0.10
|
||||
Nodes (21): copy, on_canvas_release(), find_parent_element(), Document-level helpers for a Sedona app: the packed 'meta' property and…, Rewrites only meta's four user-group bits, leaving the packed position alone.…, Traverses the XML DOM tree recursively to find the parent element of a given…, Drops a component's <prop>, for undoing a value that the file never carried., Parses 32-bit meta property integer fields via structured byte extraction rules. (+13 more)
|
||||
|
||||
### Community 8 - "Kit Manager Panel"
|
||||
Cohesion: 0.13
|
||||
Nodes (11): build_kits_panel(), build_label(), describe_changes(), KitsPanel, commit(), make_checkbox_image(), The applied-changes report, one kit per line, old build to new., The kit table, built as an embedded panel in the editor pane. (+3 more)
|
||||
|
||||
### Community 9 - "Connection Dialogs"
|
||||
Cohesion: 0.11
|
||||
Nodes (11): ConnectDialog, _cleanup(), _CredentialsDialog, DiscoverDialog, _load_last_connection(), Modal dialog that broadcasts DASP DISCOVER, lists responding devices, and lets…, Modal dialog for entering device connection parameters., Small modal that asks for username and password, then connects. (+3 more)
|
||||
|
||||
### Community 10 - "Module Imports & Config"
|
||||
Cohesion: 0.10
|
||||
Nodes (22): base_window_title_live(), load_preferences(), Shared configuration, runtime state and widget handles. Every other module…, Loads application configurations from editor.properties or initializes defaults., Writes the current preference values back to editor.properties., Window title for live mode., save_preferences(), configparser (+14 more)
|
||||
|
||||
### Community 11 - "README Documentation"
|
||||
Cohesion: 0.10
|
||||
Nodes (26): app_state.py Module, Back/Forward Navigation History, Dirty State Display Logic, Editor Pane Three-View Architecture, Link Routing and Wire Layout, link_view.py Module, Link View Design, main.py Module (+18 more)
|
||||
|
||||
### Community 12 - "Main UI & Zoom"
|
||||
Cohesion: 0.11
|
||||
Nodes (22): clamp_left_pane_split(), _destroy_drag_ghost(), do_tidy_layout(), do_zoom_in(), do_zoom_out(), do_zoom_reset(), on_canvas_wheel_zoom(), on_palette_press() (+14 more)
|
||||
|
||||
### Community 13 - "Properties Panel State"
|
||||
Cohesion: 0.12
|
||||
Nodes (12): PropertiesPanel, Writes the edited rows back, as one undoable step. Nothing is written until…, An undo can take the component out of the document while this is on screen., Asks before pending edits are thrown away. True means it is safe to leave.…, Hands back to whoever put this on screen, once pending edits are settled.…, Tears the panel's widgets down, for a caller that is done with it., One component's slots, in a scrolling list of rows, with Save, Refresh and…, Whether any row has moved away from what was last loaded or saved. Read from… (+4 more)
|
||||
|
||||
### Community 14 - "SOX Message Codec"
|
||||
Cohesion: 0.11
|
||||
Nodes (3): setter, Big-endian binary buffer with cursor, mirroring sedona.Buf., SoxMsg
|
||||
|
||||
### Community 15 - "DASP Send Window"
|
||||
Cohesion: 0.14
|
||||
Nodes (5): _Packet, Monotonic time in seconds (float)., Manages the outgoing datagram queue with reliable delivery., SendWindow, _ticks()
|
||||
|
||||
### Community 16 - "Main Panel Management"
|
||||
Cohesion: 0.12
|
||||
Nodes (22): anything_unwritten(), close_editor_panel(), dismiss_kits(), dismiss_properties(), _give_editor_pane_back(), on_tree_filled(), Whether there is a change no one has written yet, in the document or in a…, Puts the file name on the tree's root row in red, or back to its usual colour.… (+14 more)
|
||||
|
||||
### Community 17 - "DASP & SOX Imports"
|
||||
Cohesion: 0.14
|
||||
Nodes (14): DaspMsg, _put_bytes(), _put_str(), _put_u2(), DASP (Datagram Authenticated Session Protocol) transport layer. Ported from…, Binary encoder/decoder for DASP messages., hashlib, random (+6 more)
|
||||
|
||||
### Community 18 - "Wiresheet Layout Engine"
|
||||
Cohesion: 0.13
|
||||
Nodes (20): Rebuilds tree and canvas from the DOM. Registered as undo/redo's view refresh., refresh_all_views(), build_layout_items(), build_linked_slot_index(), plan_columns(), print_sheet(), Repaints whichever sheet is open, re-reading children from the DOM. Undo of a…, Walks the columns at `scale` px per meta step, without touching the DOM.… (+12 more)
|
||||
|
||||
### Community 19 - "Schema Kit Resolution"
|
||||
Cohesion: 0.13
|
||||
Nodes (17): Puts back what snapshot_children captured, whitespace and all., restore_children(), apply_kit_selections(), abandon(), Takes a <kit> entry out of the block, leaving its line breaks tidy. Whitespace…, Types placed in the app that the current registry cannot describe, sorted.…, Repins, adds and drops <kit> entries in one go. ``selections`` is an iterable…, remove_kit_element() (+9 more)
|
||||
|
||||
### Community 20 - "Wire Route Pathfinding"
|
||||
Cohesion: 0.13
|
||||
Nodes (20): channel_candidates(), free_lane(), free_rows(), hug_lane(), path_is_clear(), True if an axis-aligned segment overlaps any box rectangle., Smallest horizontal distance from the vertical run at cx to any box it passes.…, Vertical channel positions to try, best first. A run hugs the box it leaves or… (+12 more)
|
||||
|
||||
### Community 21 - "DASP Session Transport"
|
||||
Cohesion: 0.22
|
||||
Nodes (4): DaspException, DaspSession, Exception, Client endpoint of a DASP session. Handles the handshake, keepalive, reliable…
|
||||
|
||||
### Community 22 - "Device Session Manager"
|
||||
Cohesion: 0.12
|
||||
Nodes (7): DeviceSession, Unsubscribe from previously subscribed components., Subscribes a newly added component and reads its initial values., Removes a deleted component (and its descendants) from the subscription set., Adds <link> elements under a <links> container, matching .sax structure., High-level session manager for a live Sedona device connection., Drains the event queue. Returns list of (comp_id, what_char) tuples.
|
||||
|
||||
### Community 23 - "Link Dialog & Slots"
|
||||
Cohesion: 0.17
|
||||
Nodes (18): collect_links(), Every link with an end on `comp`, in the order the file lists them. Each entry…, open_link_dialog(), on_ok(), _on_src_change(), on_swap(), _on_tgt_change(), populate_panel() (+10 more)
|
||||
|
||||
### Community 24 - "Context Menu & Navigation"
|
||||
Cohesion: 0.13
|
||||
Nodes (18): dismiss_link_view(), fill_component_popup(), on_tree_right_click(), open_link_from_link_view(), Fills a popup with the actions that apply to one component. Shared by the…, Adds a freshly opened view to the history, dropping anything ahead of it. Every…, Hides the sheet so a panel can have the pane. The canvas is only unpacked,…, Puts one component's slots in the editor pane, in place of the wiresheet. (+10 more)
|
||||
|
||||
### Community 25 - "DASP Socket Layer"
|
||||
Cohesion: 0.14
|
||||
Nodes (4): DaspSocket, Manages a UDP socket shared by DASP sessions. Provides receiver and…, Send DASP DISCOVER broadcast and collect responding devices. Returns a list of…, _trace()
|
||||
|
||||
### Community 26 - "View History Navigation"
|
||||
Cohesion: 0.15
|
||||
Nodes (17): _component_in_document(), _entry_is_reachable(), go_back(), go_forward(), _prune_view_history(), The tree item whose sheet is this element's children, or None if it is gone. A…, Whether a history entry still names something that could be shown. A sheet…, Drops entries that can no longer be shown, keeping the position on screen. (+9 more)
|
||||
|
||||
### Community 27 - "Schema Registry & Facets"
|
||||
Cohesion: 0.15
|
||||
Nodes (14): DeviceSession — bridges SoxClient to the editor's ET.Element-based UI. Handles…, queue, find_manifest(), find_manifest_path(), get_all_slots_for_type(), get_full_slots_for_type(), Kit manifest loading, slot inheritance, and which slots are worth showing., The manifest file describing a kit, or None when Sedona home holds none. A… (+6 more)
|
||||
|
||||
### Community 28 - "Properties Header & Filter"
|
||||
Cohesion: 0.15
|
||||
Nodes (7): is_hidden(), Re-reads every value from the DOM, discarding edits that were not saved. In…, Reads fresh config+runtime values from the device into the DOM element., The '@hidden' facet: a slot a kit keeps for itself. niagara.html puts it…, Takes hidden rows out of the grid, or puts them back. grid_remove keeps each…, The checkbox: remember the answer for the session, then re-filter the rows., The component's absolute path, as <link> references write it.
|
||||
|
||||
### Community 29 - "Link Context Menu"
|
||||
Cohesion: 0.16
|
||||
Nodes (14): What to call the wiresheet an element sits on. A folder is named by itself; the…, sheet_display_name(), go_to_link_sheet(), go_to_sheet_with_link(), link_endpoint_components(), link_target_sheet(), Rebuilds the link popup, naming the sheet the far end of the link is on. A link…, The two components a link joins, resolved from its from/to references. (+6 more)
|
||||
|
||||
### Community 30 - "Properties Row Building"
|
||||
Cohesion: 0.18
|
||||
Nodes (8): qualified_type(), Moves a scrollbar's thumb, and hides the bar entirely when it spans the whole., int' -> 'sys::int', the way a slot sheet names it. A manifest writes the…, Registers a widget to follow the tint, and hands it straight back., A canvas-scrolled frame; ttk has no scrollable container of its own. The rows…, Names the columns. Deliberately quiet -- the same size as the rows, in grey,…, The small ring each row is tagged with, drawn rather than set in a glyph font., meta is one integer holding a position and four group bits, so it gets its own…
|
||||
|
||||
### Community 31 - "Tree Selection Restore"
|
||||
Cohesion: 0.17
|
||||
Nodes (13): _clear_restoring_flag(), _bg(), _ui(), on_tree_select(), Puts the tree back on the sheet that is actually showing, after a refused…, Triggered when a branch node is clicked in the Navigation view., Scrolls the sheet so the tagged items sit in the middle of the view., Selects whichever wire or knob now stands for this link, and scrolls it into… (+5 more)
|
||||
|
||||
### Community 32 - "Wiresheet Box Drawing"
|
||||
Cohesion: 0.18
|
||||
Nodes (12): math, sys, tkinter_font, column_gap_for(), create_round_rectangle(), draw_component_box(), fit_text(), Canvas painting: the grid, component boxes, and the routed link wires. (+4 more)
|
||||
|
||||
### Community 33 - "Preferences Dialog"
|
||||
Cohesion: 0.17
|
||||
Nodes (11): apply_left_column_visibility(), apply_left_pane_width(), open_preferences_dialog(), apply_preferences(), Pins the tree/palette column to the configured width. Setting the sash before…, Shows or hides the tree/palette column to match the View menu's checkbutton.…, Keyboard route to the same toggle. The menu flips the variable for itself., Update the toggle button icon to reflect the pane state. (+3 more)
|
||||
|
||||
### Community 34 - "Wiresheet Box Sizing"
|
||||
Cohesion: 0.17
|
||||
Nodes (12): Checks for configuration gaps, triggers an alert, and opens setup if empty., verify_startup_configurations(), get_slots_for_type(), Returns the slots worth painting for a given type, prefixed with double-digit…, measure_box(), ontrolControl::HvacControl' -> 'HvacControl'. The kit prefix alone cost ~40px., Works out a component's box without drawing it. Returns (width, height,…, Smallest multiple of grid_size that is >= value; grid lines sit on multiples.… (+4 more)
|
||||
|
||||
### Community 35 - "Schema Slot Inheritance"
|
||||
Cohesion: 0.17
|
||||
Nodes (11): get_slots_with_inheritance(), MissingManifestError, Exception, A kit the file needs has no manifest under the Sedona home folder. Raised…, Reads a slot's '@readonly' facet. Absent means writable., Recursively resolves the slots for a given type by traversing its base chain., Reads a slot's '@summary' facet. Absent means summary, matching manifest…, Every facet on a slot, as {name: value} with each one typed by its own tag. The… (+3 more)
|
||||
|
||||
### Community 36 - "Device Connect & Load"
|
||||
Cohesion: 0.18
|
||||
Nodes (5): Extracts base slots from sys::Component that all types inherit., Loads direct children of a component (cache-aware). Returns list of…, Full wiresheet load: children, links, remote comps, config+runtime, subscribe.…, DASP connect → read schema → load root. Called from background thread., Builds slot_registry from kit manifests for the device's schema.
|
||||
|
||||
### Community 37 - "Tree Scrollbar & Palette"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): attach_tree_scrollbars(), _on_tree_open(), fit_tree_column(), measure(), _live_tree_expand(), populate_palette_types(), Sizes a Treeview's tree column to its widest visible row. ttk derives the…, Puts a Treeview in its container with scrollbars that show only when they help.… (+2 more)
|
||||
|
||||
### Community 38 - "Device DOM Builder"
|
||||
Cohesion: 0.22
|
||||
Nodes (6): _fmt_float(), Format a float for display with *precision* decimal places (default 2)., Looks up the precision facet for a slot, returning 2 as the default., Builds an ET.Element tree from cached SoxComponents, for the editor UI. Only…, Updates a single comp element in the DOM from cached SoxComponent values., _slot_precision()
|
||||
|
||||
### Community 39 - "Canvas Click & Link Select"
|
||||
Cohesion: 0.28
|
||||
Nodes (9): on_canvas_click(), on_canvas_right_click(), Detects right-clicks on nodes to instantly show context-sensitive action…, deselect_link(), Pulls both ends of a knob-to-knob line back to the knob rims. Drawn centre to…, Highlights a link in red, remembering what each of its items looked like. The…, Puts the selected link back exactly as it was drawn., select_link() (+1 more)
|
||||
|
||||
### Community 40 - "Canvas Drag & Redraw"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): on_canvas_drag(), get_box_origin(), layout_margin(), Brings the cached knob-to-knob ends onto the painted scale. They are recorded…, Re-routes the wires of a sheet that is already painted, at whatever zoom it is…, The top/left edge offset a box may not encroach on, snapped onto the grid., Exact (left, top) of a component's box, or None if it is not on the canvas., redraw_links() (+1 more)
|
||||
|
||||
### Community 41 - "Meta Property Editor"
|
||||
Cohesion: 0.25
|
||||
Nodes (7): group_label(), meta_summary(), apply_meta(), read_coordinate(), The 'Group [1,3]' text, from the four user-group bits in order., meta unpacked into what its row and its editor need: (label, groups, (x, y))., The two things meta actually holds, side by side: the groups and the position.…
|
||||
|
||||
### Community 42 - "Wire Knob & Anchor"
|
||||
Cohesion: 0.25
|
||||
Nodes (8): draw_knob(), draw_links(), get_box_bounds(), The rectangle a component's box was actually drawn at, valid after drags.…, Returns (box_bounds, y) for a slot row, centring on the box when the row is…, Marks a slot whose links cannot be shown as a visible wire. Painted in the wire…, Paints wires between slots on this sheet and knobs for links leaving it., resolve_slot_anchor()
|
||||
|
||||
### Community 43 - "DASP Receive Window"
|
||||
Cohesion: 0.25
|
||||
Nodes (3): Manages incoming message sequence numbers and acknowledgements., ReceiveWindow, _to_ack_more()
|
||||
|
||||
### Community 44 - "Component Deletion"
|
||||
Cohesion: 0.25
|
||||
Nodes (8): delete_component(), delete_selected_component(), delete_selected_link(), editor_panel_showing(), Deletes a component, taking a view of it down with it if one is showing. Every…, Whether something other than the wiresheet has the editor pane., Removes the currently selected link from the DOM and repaints., Deletes the highlighted canvas component (or selected link) and all its…
|
||||
|
||||
### Community 45 - "File Save Pipeline"
|
||||
Cohesion: 0.25
|
||||
Nodes (8): do_download_to_sax(), do_save(), In live mode, saves the current device DOM snapshot as a .sax file., Internal helper to clean up deleted elements and sync coordinates into the root…, Writes the current DOM to a .sax file at the given path., sync_live_metadata_to_dom(), sync_and_prune_tree(), write_sax_to_path()
|
||||
|
||||
### Community 46 - "Zoom Fit & Bounds"
|
||||
Cohesion: 0.29
|
||||
Nodes (8): do_zoom_fit(), Puts the painted scale on the toolbar as a percentage., Zoom so the whole sheet is visible, and scroll it into view. The fitted scale…, update_zoom_label(), fit_zoom_level(), The 1:1 rectangle the painted sheet occupies, or None if nothing is painted.…, The scale at which the whole painted sheet fits the visible canvas, or None.…, sheet_content_bounds()
|
||||
|
||||
### Community 47 - "Slot Value Undo"
|
||||
Cohesion: 0.32
|
||||
Nodes (4): Writes a component's <prop>, creating it in the block the other props sit in. A…, set_prop_value(), One Save from the Properties dialog: every slot it changed, as one undoable…, SlotValueCommand
|
||||
|
||||
### Community 48 - "Bool & Null Display"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): bool_state(), is_null(), Which of the three a value is in: 'true', 'false' or 'null'., Whether a value is Sedona's null, as an absent prop or as the literal it writes., The fill a row's field takes: a bool's state, or its editability.
|
||||
|
||||
### Community 49 - "Sheet Zoom & Grid"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): apply_sheet_zoom(), draw_grid(), Repaints the open sheet at this scale. Returns the level actually applied., Draws a grid onto the Canvas based on the scrollable region. Drawn at the…, Scales the finished sheet to app_state.zoom and sizes the canvas to match.…, set_zoom()
|
||||
|
||||
### Community 56 - "Schema Compilation"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): _load_live_schema(), Loads schema kits from the device's schema parts into the editor registry., compile_schema_registry(), Loads and maps types across multiple kit files, ensuring all dependencies exist.
|
||||
|
||||
### Community 57 - "Property Label Humanise"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): humanise(), controlSignalForMin_1' -> 'Control Signal For Min _1', the label style the rows…, 14. Proportional Band' — the manifest slot id, so rows match the box's…
|
||||
|
||||
## Knowledge Gaps
|
||||
- **3 isolated node(s):** `run.py Entry Point`, `SAX File Format`, `Tkinter GUI Framework`
|
||||
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 441 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
|
||||
- **7 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `LinkViewPanel` connect `Link View Rendering` to `Module Imports & Config`?**
|
||||
_High betweenness centrality (0.109) - this node is a cross-community bridge._
|
||||
- **Why does `PropertiesPanel` connect `Properties Panel State` to `Property Value Logic`, `Meta Property Editor`, `Bool & Null Display`, `Property Label Humanise`, `Properties Header & Filter`, `Properties Row Building`?**
|
||||
_High betweenness centrality (0.085) - this node is a cross-community bridge._
|
||||
- **Why does `SoxClient` connect `SOX Client Protocol` to `Device Connect & Load`, `DASP & SOX Imports`, `SOX Connection & Receiver`, `Device Session Manager`, `DASP Socket Layer`, `Schema Registry & Facets`?**
|
||||
_High betweenness centrality (0.078) - this node is a cross-community bridge._
|
||||
- **Are the 2 inferred relationships involving `SoxClient` (e.g. with `DeviceSession` and `DaspSocket`) actually correct?**
|
||||
_`SoxClient` has 2 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `run.py Entry Point`, `SAX File Format`, `Tkinter GUI Framework` to the rest of the system?**
|
||||
_3 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `SOX Client Protocol` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.07067307692307692 - nodes in this community are weakly interconnected._
|
||||
- **Should `Link View Rendering` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.05519480519480519 - nodes in this community are weakly interconnected._
|
||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "$graphify-root$_run_py", "label": "run.py", "file_type": "code", "source_file": "run.py", "source_location": "L1"}, {"id": "$graphify-root$_run_rationale_1", "label": "Entry point. Run this to start the editor: cd sedona_editor\u2026", "file_type": "rationale", "source_file": "run.py", "source_location": "L1"}], "edges": [{"source": "$graphify-root$_run_py", "target": "main", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "run.py", "source_location": "L7", "weight": 1.0}, {"source": "$graphify-root$_run_rationale_1", "target": "$graphify-root$_run_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "run.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
1789885668.073165
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"README.md":{"size":38029,"mtime_ns":1789207627368739100,"indexed_at_ns":1789883955303060000,"word_count":6506,"hashes":{"readme.md":"b7a613b47df2c61767fc8d886ec32aa3e1d0ac3f6da0206daca19ba2a6ed99b4"}},"app_state.py":{"size":9571,"mtime_ns":1789851139833784500,"indexed_at_ns":1789883951449195400,"word_count":1175,"hashes":{"app_state.py":"791852fb4837e590ef426f9abce9429a6346706b708de102d41f9c4dab866911"}},"connect_dialog.py":{"size":20263,"mtime_ns":1789816628226091600,"indexed_at_ns":1789883951451225900,"word_count":1303,"hashes":{"connect_dialog.py":"b5c6e3294defe12d1c53d4b8c76552d8bf6f38a6e204a1963ed2c7f3a83bcc2a"}},"dasp.py":{"size":38310,"mtime_ns":1789816759608783800,"indexed_at_ns":1789883951452569700,"word_count":3186,"hashes":{"dasp.py":"2d4d1af35c6bc9ef11ecb68b9f3a73a13aca45ca5dc41190cebe19edda562fdc"}},"device_session.py":{"size":19193,"mtime_ns":1789845314830797500,"indexed_at_ns":1789883951454302400,"word_count":1491,"hashes":{"device_session.py":"d6914c06960e42c8d3a5a9f8dc6c03d196091806d3ac7be50379cee9061c21e3"}},"link_view.py":{"size":42505,"mtime_ns":1788797603117476700,"indexed_at_ns":1789883951455805600,"word_count":5281,"hashes":{"link_view.py":"59253dabb1373764731835d88e413f18b457efa6090be9e30518fa34cce5da71"}},"live_hooks.py":{"size":8666,"mtime_ns":1789842974776334300,"indexed_at_ns":1789883951457207800,"word_count":763,"hashes":{"live_hooks.py":"438073d492b5db25195c193e30ab87ca64f9de3a704628990ea37f2fc9fff857"}},"main.py":{"size":103415,"mtime_ns":1789833846648041800,"indexed_at_ns":1789883951458596800,"word_count":9837,"hashes":{"main.py":"269e122ee65a0b878df028b878c2e484ae6d2308f10e11d1d5c0880d408f047f"}},"manage_kits.py":{"size":22083,"mtime_ns":1789125426796493100,"indexed_at_ns":1789883951460107200,"word_count":2109,"hashes":{"manage_kits.py":"6bd531d476672ea3885194f97400246fb1f78abe6637117d616d0e68f426d593"}},"palette.py":{"size":14334,"mtime_ns":1789125197416496800,"indexed_at_ns":1789883951461413400,"word_count":1379,"hashes":{"palette.py":"2cf5e6d8720b940f6b3af4bb23e3fc8a09f4f6505d53ba45edcec4088a7f4f5c"}},"properties.py":{"size":55199,"mtime_ns":1789851137229094800,"indexed_at_ns":1789883951462723600,"word_count":5705,"hashes":{"properties.py":"3ce59cd34a35b1faf50cf7030d297f12e63c296e9e60d05e76bc6163c64d18a1"}},"run.py":{"size":206,"mtime_ns":1786651536896245400,"indexed_at_ns":1789883951464114400,"word_count":29,"hashes":{"run.py":"e44ca204fa54bf4a1c595c6521561d5d659d0b7d4a7c6af70e46c1b46260d5d5"}},"sax_dom.py":{"size":6246,"mtime_ns":1788633481822201000,"indexed_at_ns":1789883951465359100,"word_count":757,"hashes":{"sax_dom.py":"28a6dad3abd45f43c38e91665e722a12e97f08c18700b431e0b61fd000f45840"}},"sax_file.py":{"size":38816,"mtime_ns":1789850168933008700,"indexed_at_ns":1789883951466630500,"word_count":3547,"hashes":{"sax_file.py":"1f01e07194406808dbbfd58bae7a1285b3a0211c0620b6a90669aab1e9890a22"}},"schema.py":{"size":12967,"mtime_ns":1789207615670902300,"indexed_at_ns":1789883951467970000,"word_count":1289,"hashes":{"schema.py":"2f8c0c4be49e18f5db16b6bf14bc03a43e94223d2fa08ff70c7ef59531664067"}},"sox.py":{"size":37209,"mtime_ns":1789845325490420400,"indexed_at_ns":1789883951469441700,"word_count":2786,"hashes":{"sox.py":"93489b69fc8dc751b2074435dc68dfb25bb6be6c3c9fe9b947824d71a692129c"}},"undo.py":{"size":7951,"mtime_ns":1788633491738614700,"indexed_at_ns":1789883951470831500,"word_count":790,"hashes":{"undo.py":"928e8b02554620bf0315b3c1a3144d6bb922f44d21a6ff92e9b8ff65ff94080e"}},"wiresheet.py":{"size":63434,"mtime_ns":1789237260350999800,"indexed_at_ns":1789883951472387100,"word_count":7833,"hashes":{"wiresheet.py":"fe92f9a17ba675e43f8bf92dd10b0750044e13b02f30cfd1796875a4a9bbb1fb"}}}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"runs": [
|
||||
{
|
||||
"date": "2026-09-20T06:03:44.075478+00:00",
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"files": 18
|
||||
}
|
||||
],
|
||||
"total_input_tokens": 0,
|
||||
"total_output_tokens": 0
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+35107
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"app_state.py": {
|
||||
"mtime": 1789851139.8337846,
|
||||
"seen": 1789884224.0655024,
|
||||
"ast_hash": "437802315be3d28e6cbc41f1d5a95876",
|
||||
"semantic_hash": "437802315be3d28e6cbc41f1d5a95876"
|
||||
},
|
||||
"connect_dialog.py": {
|
||||
"mtime": 1789816628.2260916,
|
||||
"seen": 1789884224.0655105,
|
||||
"ast_hash": "c2d9a0b738557e6c72f1dea4d44d02ea",
|
||||
"semantic_hash": "c2d9a0b738557e6c72f1dea4d44d02ea"
|
||||
},
|
||||
"dasp.py": {
|
||||
"mtime": 1789816759.6087837,
|
||||
"seen": 1789884224.0655322,
|
||||
"ast_hash": "d3d63a00cc8eb08e5dc82ee2cfd1365c",
|
||||
"semantic_hash": "d3d63a00cc8eb08e5dc82ee2cfd1365c"
|
||||
},
|
||||
"device_session.py": {
|
||||
"mtime": 1789845314.8307974,
|
||||
"seen": 1789884224.0655339,
|
||||
"ast_hash": "eeb732ccf012badb72f93850955b6c8a",
|
||||
"semantic_hash": "eeb732ccf012badb72f93850955b6c8a"
|
||||
},
|
||||
"link_view.py": {
|
||||
"mtime": 1788797603.1174767,
|
||||
"seen": 1789884224.0655358,
|
||||
"ast_hash": "7f341dc2d6c8598c2fc978d5a0b95f02",
|
||||
"semantic_hash": "7f341dc2d6c8598c2fc978d5a0b95f02"
|
||||
},
|
||||
"live_hooks.py": {
|
||||
"mtime": 1789842974.7763343,
|
||||
"seen": 1789884224.0655372,
|
||||
"ast_hash": "a9a092c511390c8d64f5bc50e08627a1",
|
||||
"semantic_hash": "a9a092c511390c8d64f5bc50e08627a1"
|
||||
},
|
||||
"main.py": {
|
||||
"mtime": 1789833846.6480417,
|
||||
"seen": 1789884224.065539,
|
||||
"ast_hash": "8bc836c383a496c8ad30d0da462a8d61",
|
||||
"semantic_hash": "8bc836c383a496c8ad30d0da462a8d61"
|
||||
},
|
||||
"manage_kits.py": {
|
||||
"mtime": 1789125426.796493,
|
||||
"seen": 1789884224.06554,
|
||||
"ast_hash": "8e9135cf8c5ddcb34f6f7886e1fa85e0",
|
||||
"semantic_hash": "8e9135cf8c5ddcb34f6f7886e1fa85e0"
|
||||
},
|
||||
"palette.py": {
|
||||
"mtime": 1789125197.4164968,
|
||||
"seen": 1789884224.0655417,
|
||||
"ast_hash": "f1b78c1843fb98d44720d60281314b98",
|
||||
"semantic_hash": "f1b78c1843fb98d44720d60281314b98"
|
||||
},
|
||||
"properties.py": {
|
||||
"mtime": 1789851137.2290947,
|
||||
"seen": 1789884224.065543,
|
||||
"ast_hash": "a1edfcee7af5fe436576776e7bebb95d",
|
||||
"semantic_hash": "a1edfcee7af5fe436576776e7bebb95d"
|
||||
},
|
||||
"run.py": {
|
||||
"mtime": 1786651536.8962455,
|
||||
"seen": 1789884224.0655444,
|
||||
"ast_hash": "d798ee28ae1eeb09d6d12d46fb7fe9be",
|
||||
"semantic_hash": "d798ee28ae1eeb09d6d12d46fb7fe9be"
|
||||
},
|
||||
"sax_dom.py": {
|
||||
"mtime": 1788633481.822201,
|
||||
"seen": 1789884224.0655463,
|
||||
"ast_hash": "afbcd877bba75c075fc98eccf7fb71f2",
|
||||
"semantic_hash": "afbcd877bba75c075fc98eccf7fb71f2"
|
||||
},
|
||||
"sax_file.py": {
|
||||
"mtime": 1789850168.9330087,
|
||||
"seen": 1789884224.0655475,
|
||||
"ast_hash": "226e49aa7b5206b80e6f77b2fa1ff58f",
|
||||
"semantic_hash": "226e49aa7b5206b80e6f77b2fa1ff58f"
|
||||
},
|
||||
"schema.py": {
|
||||
"mtime": 1789207615.6709023,
|
||||
"seen": 1789884224.0655491,
|
||||
"ast_hash": "d2878c3e573f7fdea07c22347f7eed0a",
|
||||
"semantic_hash": "d2878c3e573f7fdea07c22347f7eed0a"
|
||||
},
|
||||
"sox.py": {
|
||||
"mtime": 1789845325.4904203,
|
||||
"seen": 1789884224.0655506,
|
||||
"ast_hash": "2e6fb50d0f1153ee41bf44a05d73e10b",
|
||||
"semantic_hash": "2e6fb50d0f1153ee41bf44a05d73e10b"
|
||||
},
|
||||
"undo.py": {
|
||||
"mtime": 1788633491.7386148,
|
||||
"seen": 1789884224.0655518,
|
||||
"ast_hash": "52c86b6138566209df75ea8006499596",
|
||||
"semantic_hash": "52c86b6138566209df75ea8006499596"
|
||||
},
|
||||
"wiresheet.py": {
|
||||
"mtime": 1789237260.3509998,
|
||||
"seen": 1789884224.0655534,
|
||||
"ast_hash": "9e3fa2735688770ef38f61ea4b045fcf",
|
||||
"semantic_hash": "9e3fa2735688770ef38f61ea4b045fcf"
|
||||
},
|
||||
"README.md": {
|
||||
"mtime": 1789207627.3687391,
|
||||
"seen": 1789884224.0655546,
|
||||
"ast_hash": "8b472abda0e7ab506fc251a0382458b8",
|
||||
"semantic_hash": "8b472abda0e7ab506fc251a0382458b8"
|
||||
}
|
||||
}
|
||||
+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()
|
||||
+584
@@ -0,0 +1,584 @@
|
||||
"""Linking on the wiresheet with the mouse: slot hover, drag-out rubber band, drop.
|
||||
|
||||
Hovering a slot row highlights the half of it the pointer is in and swaps the cursor for
|
||||
a horizontal arrow: the right half of a row is where a link leaves a slot, so it points
|
||||
right, and the left half is where one arrives, so it points left. Pressing either
|
||||
button on a slot and dragging pulls a rubber-band line out of that side; letting go on
|
||||
another slot makes the link, and letting go on a component's title bar opens the Link
|
||||
dialog for the two components with the dragged slot already picked. Started on the right half, the slot under the pointer is
|
||||
the target; started on the left half, it is the source -- so a link can be drawn from
|
||||
either end. While dragging, the cursor points along the flow of the link over a slot that
|
||||
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 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.
|
||||
"""
|
||||
|
||||
import os
|
||||
import struct
|
||||
import tempfile
|
||||
import tkinter as tk
|
||||
|
||||
import app_state
|
||||
import sax_file
|
||||
import wiresheet
|
||||
from sax_dom import get_component_path
|
||||
|
||||
NO_ENTRY_PNG = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"assets", "cursors", "no_entry.png")
|
||||
DRAG_THRESHOLD = 4 # pixels the pointer must travel before a press is a drag
|
||||
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)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- cursors
|
||||
|
||||
def _write_cur(path, pixels, hotspot):
|
||||
"""Writes a 32x32 32-bit Windows .cur from rows of (r, g, b, a) tuples."""
|
||||
size = 32
|
||||
xor = bytearray()
|
||||
for row in reversed(pixels): # DIBs are stored bottom-up
|
||||
for r, g, b, a in row:
|
||||
xor += bytes((b, g, r, a))
|
||||
mask = bytearray()
|
||||
for row in reversed(pixels):
|
||||
bits = 0
|
||||
for x, (_, _, _, a) in enumerate(row):
|
||||
if a == 0:
|
||||
bits |= 1 << (31 - x)
|
||||
mask += struct.pack(">I", bits)
|
||||
header = struct.pack("<IiiHHIIiiII", 40, size, size * 2, 1, 32, 0,
|
||||
len(xor) + len(mask), 0, 0, 0, 0)
|
||||
image = header + bytes(xor) + bytes(mask)
|
||||
icondir = struct.pack("<HHH", 0, 2, 1)
|
||||
entry = struct.pack("<BBBBHHII", size, size, 0, 0, hotspot[0], hotspot[1],
|
||||
len(image), 6 + 16)
|
||||
with open(path, "wb") as f:
|
||||
f.write(icondir + entry + image)
|
||||
|
||||
|
||||
def _inside_polygon(px, py, poly):
|
||||
inside = False
|
||||
j = len(poly) - 1
|
||||
for i in range(len(poly)):
|
||||
xi, yi = poly[i]
|
||||
xj, yj = poly[j]
|
||||
if (yi > py) != (yj > py) and px < (xj - xi) * (py - yi) / (yj - yi) + xi:
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def _arrow_pixels(pointing_right):
|
||||
"""A hollow block arrow: white body, black rim -- the Workbench link cursor."""
|
||||
poly = [(9, 14), (16, 14), (16, 10), (23, 16), (16, 22), (16, 18), (9, 18)]
|
||||
if not pointing_right:
|
||||
poly = [(32 - x, y) for x, y in poly]
|
||||
inside = [[_inside_polygon(x + 0.5, y + 0.5, poly) for x in range(32)]
|
||||
for y in range(32)]
|
||||
rows = []
|
||||
for y in range(32):
|
||||
row = []
|
||||
for x in range(32):
|
||||
if not inside[y][x]:
|
||||
row.append((0, 0, 0, 0))
|
||||
continue
|
||||
rim = any(not (0 <= x + dx < 32 and 0 <= y + dy < 32 and inside[y + dy][x + dx])
|
||||
for dx in (-1, 0, 1) for dy in (-1, 0, 1))
|
||||
row.append((0, 0, 0, 255) if rim else (255, 255, 255, 255))
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _no_entry_pixels():
|
||||
"""assets/cursors/no_entry.png, centred on a 32x32 cursor; None if unreadable.
|
||||
|
||||
Read with PIL when it is installed, which keeps the image's soft edges; otherwise
|
||||
with Tk's own PNG reader, which only knows whether a pixel is transparent or not.
|
||||
"""
|
||||
if not os.path.exists(NO_ENTRY_PNG):
|
||||
return None
|
||||
try:
|
||||
from PIL import Image
|
||||
image = Image.open(NO_ENTRY_PNG).convert("RGBA")
|
||||
width, height = image.size
|
||||
|
||||
def pixel(x, y):
|
||||
return image.getpixel((x, y))
|
||||
except ImportError:
|
||||
image = tk.PhotoImage(file=NO_ENTRY_PNG)
|
||||
width, height = image.width(), image.height()
|
||||
|
||||
def pixel(x, y):
|
||||
if image.transparency_get(x, y):
|
||||
return (0, 0, 0, 0)
|
||||
return tuple(image.get(x, y)) + (255,)
|
||||
if width > 32 or height > 32:
|
||||
return None
|
||||
left, top = 16 - width // 2, 16 - height // 2
|
||||
rows = [[(0, 0, 0, 0)] * 32 for _ in range(32)]
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
rows[top + y][left + x] = pixel(x, y)
|
||||
return rows
|
||||
|
||||
|
||||
def _cursor(role):
|
||||
"""The Tk cursor for 'right', 'left' or 'no', drawn once and cached."""
|
||||
if role in _cursors:
|
||||
return _cursors[role]
|
||||
fallback = {"right": "sb_right_arrow", "left": "sb_left_arrow", "no": "X_cursor"}[role]
|
||||
spec = fallback
|
||||
if os.name == "nt":
|
||||
try:
|
||||
folder = os.path.join(tempfile.gettempdir(), "sedona_editor_cursors")
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
path = os.path.join(folder, f"link_{role}.cur")
|
||||
if role == "right":
|
||||
_write_cur(path, _arrow_pixels(True), (23, 16))
|
||||
elif role == "left":
|
||||
_write_cur(path, _arrow_pixels(False), (9, 16))
|
||||
else:
|
||||
pixels = _no_entry_pixels()
|
||||
if pixels is None:
|
||||
raise OSError("no_entry.png missing or unreadable")
|
||||
_write_cur(path, pixels, (16, 16))
|
||||
candidate = "{@" + path.replace("\\", "/") + "}"
|
||||
app_state.canvas.configure(cursor=candidate) # proves Tk can load it
|
||||
spec = candidate
|
||||
except (OSError, tk.TclError):
|
||||
spec = "no" if role == "no" else fallback
|
||||
_cursors[role] = spec
|
||||
return spec
|
||||
|
||||
|
||||
def _set_cursor(role):
|
||||
canvas = app_state.canvas
|
||||
try:
|
||||
canvas.configure(cursor=_cursor(role) if role else "")
|
||||
except tk.TclError:
|
||||
canvas.configure(cursor="")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- hit test
|
||||
|
||||
def slot_at(cx, cy):
|
||||
"""The slot row under a canvas point, as a dict, or None.
|
||||
|
||||
Picks the topmost box under the point, so a box lying over another wins, and says
|
||||
which half of the row the point is in.
|
||||
"""
|
||||
canvas = app_state.canvas
|
||||
for item in reversed(canvas.find_overlapping(cx, cy, cx, cy)):
|
||||
tags = canvas.gettags(item)
|
||||
if "component" not in tags:
|
||||
continue
|
||||
comp_tag = next((t for t in tags if t.startswith("comp_group_")), None)
|
||||
if comp_tag is None:
|
||||
continue
|
||||
box = wiresheet.get_box_bounds(comp_tag)
|
||||
if box is None:
|
||||
return None
|
||||
zoom = app_state.zoom
|
||||
half_row = wiresheet.SLOT_ROW_HEIGHT * zoom / 2
|
||||
for slot_name, offset in app_state.component_slot_offsets.get(comp_tag, {}).items():
|
||||
row_y = box[1] + offset * zoom
|
||||
if row_y - half_row <= cy < row_y + half_row:
|
||||
return {
|
||||
"comp_tag": comp_tag,
|
||||
"comp": app_state.canvas_comp_map.get(comp_tag),
|
||||
"slot": slot_name,
|
||||
"side": "right" if cx >= (box[0] + box[2]) / 2 else "left",
|
||||
"box": box,
|
||||
"row_y": row_y,
|
||||
"half_row": half_row,
|
||||
}
|
||||
return None # over a header or footer, not a slot
|
||||
return None
|
||||
|
||||
|
||||
def title_at(cx, cy):
|
||||
"""The comp tag whose title bar is topmost under a canvas point, or None."""
|
||||
canvas = app_state.canvas
|
||||
for item in reversed(canvas.find_overlapping(cx, cy, cx, cy)):
|
||||
tags = canvas.gettags(item)
|
||||
if "component" not in tags:
|
||||
continue
|
||||
comp_tag = next((t for t in tags if t.startswith("comp_group_")), None)
|
||||
if comp_tag is None:
|
||||
continue
|
||||
box = wiresheet.get_box_bounds(comp_tag)
|
||||
if box and box[1] <= cy <= box[1] + wiresheet.HEADER_HEIGHT * app_state.zoom:
|
||||
return comp_tag
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _slot_info(comp, slot_name):
|
||||
for slot in app_state.final_components_registry.get(comp.get("type", ""), []):
|
||||
if slot["name"] == slot_name:
|
||||
return slot
|
||||
return None
|
||||
|
||||
|
||||
def _check_link(source, target):
|
||||
"""Returns (ok, reason) for a link from one hit slot to another."""
|
||||
if source["comp"] is None or target["comp"] is None:
|
||||
return False, "No component there."
|
||||
if source["comp"] is target["comp"] and source["slot"] == target["slot"]:
|
||||
return False, "A slot cannot be linked to itself."
|
||||
src = _slot_info(source["comp"], source["slot"])
|
||||
tgt = _slot_info(target["comp"], target["slot"])
|
||||
if src is None or tgt is None:
|
||||
return False, "Slot not found in the kit manifest."
|
||||
for slot in (src, tgt):
|
||||
if "a" in slot["flags"] or slot["type"] == "void":
|
||||
return False, f"'{slot['name']}' is an action and cannot be linked."
|
||||
if tgt["readonly"]:
|
||||
return False, f"'{tgt['name']}' is read-only and cannot be a link target."
|
||||
if src["type"] != tgt["type"]:
|
||||
return False, (f"Type mismatch: {source['slot']} is {src['type']}, "
|
||||
f"{target['slot']} is {tgt['type']}.")
|
||||
from_ref, to_ref = _refs(source, target)
|
||||
links = app_state.xml_root_element.find("links")
|
||||
if links is not None:
|
||||
for link in links.findall("link"):
|
||||
if link.get("from") == from_ref and link.get("to") == to_ref:
|
||||
return False, "That link already exists."
|
||||
return True, f"Link {from_ref} → {to_ref}"
|
||||
|
||||
|
||||
def _refs(source, target):
|
||||
root = app_state.xml_root_element
|
||||
return (f"{get_component_path(source['comp'], root)}.{source['slot']}",
|
||||
f"{get_component_path(target['comp'], root)}.{target['slot']}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- drawing
|
||||
|
||||
def _highlight(hit, color):
|
||||
"""Rings the half of a slot row the pointer is over, with a bar on its outer edge."""
|
||||
canvas = app_state.canvas
|
||||
canvas.delete("slot_hover")
|
||||
if hit is None:
|
||||
return
|
||||
left, _, right, _ = hit["box"]
|
||||
mid = (left + right) / 2
|
||||
top, bottom = hit["row_y"] - hit["half_row"], hit["row_y"] + hit["half_row"]
|
||||
x1, x2 = (mid, right - 1) if hit["side"] == "right" else (left + 1, mid)
|
||||
canvas.create_rectangle(x1, top, x2, bottom, outline=color, width=2,
|
||||
tags=("slot_hover",))
|
||||
edge = right - 2 if hit["side"] == "right" else left + 2
|
||||
canvas.create_line(edge, top, edge, bottom, fill=color, width=4, tags=("slot_hover",))
|
||||
canvas.tag_raise("slot_hover")
|
||||
|
||||
|
||||
def _highlight_title(comp_tag, color):
|
||||
"""Rings a box's title bar: the drop that opens the Link dialog for that box."""
|
||||
canvas = app_state.canvas
|
||||
canvas.delete("slot_hover")
|
||||
left, top, right, _ = wiresheet.get_box_bounds(comp_tag)
|
||||
canvas.create_rectangle(left + 1, top + 1, right - 1,
|
||||
top + wiresheet.HEADER_HEIGHT * app_state.zoom,
|
||||
outline=color, width=2, tags=("slot_hover",))
|
||||
canvas.tag_raise("slot_hover")
|
||||
|
||||
|
||||
def _anchor(hit):
|
||||
left, _, right, _ = hit["box"]
|
||||
return (right if hit["side"] == "right" else left), hit["row_y"]
|
||||
|
||||
|
||||
def clear():
|
||||
"""Drops any hover ring, rubber band and drag state; called on every repaint."""
|
||||
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)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- events
|
||||
|
||||
def on_motion(event):
|
||||
"""Plain pointer movement: ring the slot half under the pointer."""
|
||||
if _drag is not None or app_state.drag_data.get("tag"):
|
||||
return
|
||||
canvas = app_state.canvas
|
||||
hit = slot_at(canvas.canvasx(event.x), canvas.canvasy(event.y))
|
||||
_highlight(hit, HOVER_COLOR)
|
||||
_set_cursor(hit["side"] if hit else None)
|
||||
|
||||
|
||||
def on_leave(_event):
|
||||
if _drag is None:
|
||||
app_state.canvas.delete("slot_hover")
|
||||
_set_cursor(None)
|
||||
|
||||
|
||||
def _start_press(event):
|
||||
"""Records a press on a slot row as a link drag in waiting; None if not on a slot."""
|
||||
global _press
|
||||
canvas = app_state.canvas
|
||||
hit = slot_at(canvas.canvasx(event.x), canvas.canvasy(event.y))
|
||||
if hit is None or hit["comp"] is None:
|
||||
_press = None
|
||||
return None
|
||||
_press = {"hit": hit, "x": event.x, "y": event.y}
|
||||
return _press
|
||||
|
||||
|
||||
def _drag_motion(event):
|
||||
"""Pulls the rubber band to the pointer and judges the slot under it, if any."""
|
||||
global _drag
|
||||
canvas = app_state.canvas
|
||||
if _drag is None:
|
||||
if _press is None:
|
||||
return
|
||||
if abs(event.x - _press["x"]) < DRAG_THRESHOLD and abs(event.y - _press["y"]) < DRAG_THRESHOLD:
|
||||
return
|
||||
_drag = {"hit": _press["hit"], "anchor": _anchor(_press["hit"])}
|
||||
app_state.set_status("Drop on a slot to link it; release elsewhere to cancel.")
|
||||
|
||||
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
|
||||
start = _drag["hit"]
|
||||
ax, ay = _drag["anchor"]
|
||||
canvas.delete("link_rubber")
|
||||
canvas.create_line(ax, ay, cx, cy, fill=RUBBER_COLOR, width=2, dash=(4, 2),
|
||||
tags=("link_rubber",))
|
||||
canvas.tag_raise("link_rubber")
|
||||
|
||||
outgoing = start["side"] == "right"
|
||||
hit = slot_at(cx, cy)
|
||||
if hit is None:
|
||||
title_tag = title_at(cx, cy)
|
||||
if title_tag is not None:
|
||||
# Its own title too: a component may link one of its slots to another.
|
||||
_highlight_title(title_tag, DROP_OK_COLOR)
|
||||
_set_cursor("right" if outgoing else "left")
|
||||
app_state.set_status("Drop to pick the other slot in the Link dialog.")
|
||||
return
|
||||
if hit is None or (hit["comp_tag"] == start["comp_tag"] and hit["slot"] == start["slot"]):
|
||||
canvas.delete("slot_hover")
|
||||
_set_cursor(None)
|
||||
return
|
||||
ok, reason = _check_link(start, hit) if outgoing else _check_link(hit, start)
|
||||
# Ring the side of the drop slot the link would attach to, not the side under the
|
||||
# pointer: a link always arrives on the left of a row and leaves from its right.
|
||||
shown = dict(hit, side="left" if outgoing else "right")
|
||||
_highlight(shown, DROP_OK_COLOR if ok else DROP_BAD_COLOR)
|
||||
_set_cursor(("right" if outgoing else "left") if ok else "no")
|
||||
app_state.set_status(reason, warn=not ok)
|
||||
|
||||
|
||||
def _finish(event):
|
||||
"""Ends a press. Returns 'link' after a drag (made or refused), 'click' after a
|
||||
press that never moved, None if no press on a slot was pending."""
|
||||
global _press, _drag
|
||||
canvas = app_state.canvas
|
||||
press, drag = _press, _drag
|
||||
_press = _drag = None
|
||||
canvas.delete("link_rubber")
|
||||
canvas.delete("slot_hover")
|
||||
_set_cursor(None)
|
||||
|
||||
if drag is None:
|
||||
return "click" if press is not None else None
|
||||
|
||||
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
|
||||
hit = slot_at(cx, cy)
|
||||
start = drag["hit"]
|
||||
title_tag = title_at(cx, cy) if hit is None else None
|
||||
if title_tag is not None:
|
||||
other = app_state.canvas_comp_map.get(title_tag)
|
||||
if other is None:
|
||||
app_state.set_status("Link cancelled.")
|
||||
elif start["side"] == "right":
|
||||
_link_dialog(start["comp"], other, start["slot"], None)
|
||||
else:
|
||||
_link_dialog(other, start["comp"], None, start["slot"])
|
||||
return "link"
|
||||
if hit is None or (hit["comp_tag"] == start["comp_tag"] and hit["slot"] == start["slot"]):
|
||||
app_state.set_status("Link cancelled.")
|
||||
return "link"
|
||||
source, target = (start, hit) if start["side"] == "right" else (hit, start)
|
||||
ok, reason = _check_link(source, target)
|
||||
if not ok:
|
||||
app_state.set_status(f"Cannot link: {reason}", warn=True)
|
||||
return "link"
|
||||
from_ref, to_ref = _refs(source, target)
|
||||
sax_file.add_link(from_ref, to_ref)
|
||||
app_state.set_status(f"Linked {from_ref} → {to_ref}")
|
||||
return "link"
|
||||
|
||||
|
||||
# Right button: on a slot it drags a link, and a press that never moves is the menu.
|
||||
|
||||
def on_right_press(event):
|
||||
if _start_press(event) is None:
|
||||
_menu_callback(event)
|
||||
|
||||
|
||||
def on_right_motion(event):
|
||||
_drag_motion(event)
|
||||
|
||||
|
||||
def on_right_release(event):
|
||||
if _finish(event) == "click":
|
||||
_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.
|
||||
|
||||
def _on_title_bar(comp_tag, cy):
|
||||
box = wiresheet.get_box_bounds(comp_tag)
|
||||
return box is not None and box[1] <= cy <= box[1] + wiresheet.HEADER_HEIGHT * app_state.zoom
|
||||
|
||||
|
||||
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")
|
||||
comp_tag = app_state.drag_data.get("tag")
|
||||
if comp_tag and not _on_title_bar(comp_tag, app_state.canvas.canvasy(event.y)):
|
||||
app_state.drag_data["tag"] = None # selected, but not picked up
|
||||
|
||||
|
||||
def on_left_motion(event):
|
||||
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 _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,
|
||||
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;
|
||||
select_boxes makes a list of comp tags the selection after a rectangle drag.
|
||||
"""
|
||||
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="+")
|
||||
canvas.bind("<ButtonPress-1>", on_left_press)
|
||||
canvas.bind("<B1-Motion>", on_left_motion)
|
||||
canvas.bind("<ButtonRelease-1>", on_left_release)
|
||||
canvas.bind("<ButtonPress-3>", on_right_press)
|
||||
canvas.bind("<B3-Motion>", on_right_motion)
|
||||
canvas.bind("<ButtonRelease-3>", on_right_release)
|
||||
+880
@@ -0,0 +1,880 @@
|
||||
"""The Link View: every link touching one component, drawn around it.
|
||||
|
||||
Like the Properties view it takes the editor pane over from the canvas rather than
|
||||
floating above it, so the sheet and this picture are the same piece of screen and only
|
||||
one of them is ever the thing being looked at. `main` is what hides the canvas and puts
|
||||
this in its place; this module knows only the frame it was handed.
|
||||
|
||||
It answers one question -- "what is wired to this component, and where does it go?" -- and
|
||||
nothing else. Nothing here writes to the document: there is no drag, no add, no delete,
|
||||
so a component's wiring can be read without the risk of rearranging it. The one action is
|
||||
following a link, which hands back to the wiresheet.
|
||||
|
||||
What it draws:
|
||||
|
||||
the chosen component in the middle
|
||||
a component linking into it on the left, feeding its slot
|
||||
a component it links out to on the right, fed from its slot
|
||||
a link on the same wiresheet a solid grey line
|
||||
a link to another wiresheet a dashed grey line, and the grey knob the sheet
|
||||
itself shows on that slot, with the far sheet named
|
||||
|
||||
The pale yellow ground is the one thing that says at a glance that this is not a
|
||||
wiresheet. There is no grid, because nothing here can be positioned: the layout is
|
||||
derived from the links every time it is drawn and no part of it is stored in the file.
|
||||
|
||||
Boxes come from `wiresheet.draw_component_box`, so a component reads exactly as it does
|
||||
on its own sheet -- same header, same slot rows, same values -- and the wire lands on the
|
||||
same slot row a wire would land on there.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont, ttk
|
||||
|
||||
import app_state
|
||||
import wiresheet
|
||||
from sax_dom import find_component_by_path, find_parent_element, get_component_path
|
||||
|
||||
# The ground. Pale yellow rather than the sheet's white so a glance at the pane says
|
||||
# which of the two views is on screen, without a caption having to say it.
|
||||
SHEET_BG = "#fbf6d9"
|
||||
|
||||
# Links are grey here whatever they are: this view is not a wiresheet, and the blue of a
|
||||
# real wire would invite the reader to treat it as one. Only the pointer's own hover
|
||||
# darkens a line, which is how it says a line can be clicked.
|
||||
WIRE_COLOUR = "#8a8a8a"
|
||||
WIRE_HOVER_COLOUR = "#3c3c3c"
|
||||
WIRE_WIDTH = 1
|
||||
WIRE_HOVER_WIDTH = 2
|
||||
OFF_SHEET_DASH = (5, 3) # a link whose far end is on another wiresheet
|
||||
|
||||
# The Properties view's header, so the two views wear the same band.
|
||||
HEADER_BG = "#f2f2f2"
|
||||
PATH_COLOUR = "#7a7a7a"
|
||||
|
||||
LABEL_COLOUR = "#6b6b6b" # the "on <sheet>" note under an off-sheet component
|
||||
MISSING_COLOUR = "#b06060" # a link naming a component this document does not have
|
||||
|
||||
# The chosen component wears the same stroke the wiresheet puts on a selected box, so
|
||||
# "this is the one you picked" is said the same way in both views. Its own outline is
|
||||
# restyled rather than a ring being drawn around it: a ring would sit exactly where
|
||||
# every wire meets the box and would nick each arrowhead.
|
||||
FOCUS_OUTLINE = "#1f4e79"
|
||||
FOCUS_OUTLINE_WIDTH = 3
|
||||
|
||||
# Layout, in pixels.
|
||||
MARGIN = 40
|
||||
COLUMN_GAP = 110 # the least room a column of wires is given
|
||||
ROW_GAP = 26
|
||||
STUB = 14 # straight run leaving a slot before the wire may turn
|
||||
LANE_GAP = 9 # between the vertical runs of two wires in one gap
|
||||
SELF_LOOP_DROP = 18 # how far below its box a component's link to itself runs
|
||||
LABEL_GAP = 6 # between a box's bottom and the note under it
|
||||
|
||||
EPSILON = 0.5 # two coordinates this close are the same coordinate
|
||||
|
||||
|
||||
def column_gap(wire_count):
|
||||
"""How wide the gap beside a column has to be to hold its wires.
|
||||
|
||||
A wire that cannot run straight turns in a vertical run of its own, and two runs on
|
||||
the same line are one line as far as a reader is concerned -- so the gap is sized to
|
||||
the number of wires rather than fixed, and a component with a dozen links gets a
|
||||
wider gap instead of a dozen wires in a 30px band. COLUMN_GAP is the floor, for the
|
||||
few links that would otherwise be drawn closer together than the boxes they join.
|
||||
"""
|
||||
return max(COLUMN_GAP, 2 * STUB + LANE_GAP * (wire_count + 1))
|
||||
|
||||
|
||||
def spans_overlap(first, second):
|
||||
"""Whether two 1-D spans share more than a single point."""
|
||||
low = max(min(first), min(second))
|
||||
high = min(max(first), max(second))
|
||||
return high - low > EPSILON
|
||||
|
||||
|
||||
def strictly_within(value, span):
|
||||
"""Whether a coordinate is inside a span rather than at either end of it."""
|
||||
return min(span) + EPSILON < value < max(span) - EPSILON
|
||||
|
||||
|
||||
def segments_meet(first, second):
|
||||
"""Whether two axis-aligned segments make a mark a reader would read as a tangle.
|
||||
|
||||
Two kinds count. A vertical run crossing a horizontal one is a crossing, and two
|
||||
vertical runs sharing a line over the same stretch are one line where there should
|
||||
be two.
|
||||
|
||||
Two *horizontal* runs sharing a line deliberately do not count: every wire leaving
|
||||
one output slot leaves it at the same height, so a fan-out shares the run beside its
|
||||
pin. That is how a wiresheet draws a fan-out too, and forcing those apart would move
|
||||
a wire off the slot it belongs to.
|
||||
"""
|
||||
(a1, a2), (b1, b2) = first, second
|
||||
a_vertical = abs(a1[0] - a2[0]) < EPSILON
|
||||
b_vertical = abs(b1[0] - b2[0]) < EPSILON
|
||||
|
||||
if a_vertical and b_vertical:
|
||||
return (abs(a1[0] - b1[0]) < EPSILON
|
||||
and spans_overlap((a1[1], a2[1]), (b1[1], b2[1])))
|
||||
if not a_vertical and not b_vertical:
|
||||
return False
|
||||
|
||||
(v1, v2), (h1, h2) = (first, second) if a_vertical else (second, first)
|
||||
return (strictly_within(v1[0], (h1[0], h2[0]))
|
||||
and strictly_within(h1[1], (v1[1], v2[1])))
|
||||
|
||||
|
||||
def routes_meet(first, second):
|
||||
"""Whether two wires, each a list of corner points, meet anywhere along their way."""
|
||||
first_segments = list(zip(first, first[1:]))
|
||||
second_segments = list(zip(second, second[1:]))
|
||||
return any(segments_meet(a, b) for a in first_segments for b in second_segments)
|
||||
|
||||
|
||||
def sheet_display_name(sheet_element):
|
||||
"""What to call the wiresheet an element sits on.
|
||||
|
||||
A folder is named by itself; the app element has no name of its own and is the row
|
||||
the navigation tree shows under the file's name, so it is named after the file.
|
||||
"""
|
||||
if sheet_element is not None:
|
||||
name = sheet_element.get("name")
|
||||
if name:
|
||||
return name
|
||||
return os.path.basename(app_state.current_file_path or "app")
|
||||
|
||||
|
||||
def collect_links(comp):
|
||||
"""Every link with an end on `comp`, in the order the file lists them.
|
||||
|
||||
Each entry is a dict describing one `<link>` from this component's point of view:
|
||||
|
||||
side 'in' the peer feeds a slot of comp, so it belongs on the left
|
||||
'out' comp feeds a slot of the peer, so it belongs on the right
|
||||
'self' both ends are comp
|
||||
comp_slot the slot on comp; for a self-link, the slot fed
|
||||
peer_slot the slot on the peer; for a self-link, the slot read
|
||||
peer the peer component, or None when the reference names one the
|
||||
document does not have
|
||||
peer_path the path the reference actually names, which is all a broken one has
|
||||
same_sheet whether the peer is a child of the same parent, which is what makes
|
||||
a link drawable as a wire on one sheet
|
||||
|
||||
Pure DOM work, so the reading of a document's links is testable without a window.
|
||||
"""
|
||||
root = app_state.xml_root_element
|
||||
if root is None or comp is None:
|
||||
return []
|
||||
|
||||
links_elem = root.find("links")
|
||||
if links_elem is None:
|
||||
return []
|
||||
|
||||
comp_path = get_component_path(comp, root)
|
||||
here = find_parent_element(root, comp)
|
||||
entries = []
|
||||
|
||||
for link in links_elem.findall("link"):
|
||||
from_ref = link.get("from", "")
|
||||
to_ref = link.get("to", "")
|
||||
if "." not in from_ref or "." not in to_ref:
|
||||
continue
|
||||
|
||||
from_path, from_slot = from_ref.rsplit(".", 1)
|
||||
to_path, to_slot = to_ref.rsplit(".", 1)
|
||||
|
||||
# A link is read from this component's side: the end that is not comp is the
|
||||
# peer, and which end comp is on is what puts the peer left or right.
|
||||
if from_path == comp_path and to_path == comp_path:
|
||||
side, comp_slot, peer_slot, peer_path = "self", to_slot, from_slot, comp_path
|
||||
elif to_path == comp_path:
|
||||
side, comp_slot, peer_slot, peer_path = "in", to_slot, from_slot, from_path
|
||||
elif from_path == comp_path:
|
||||
side, comp_slot, peer_slot, peer_path = "out", from_slot, to_slot, to_path
|
||||
else:
|
||||
continue
|
||||
|
||||
peer = comp if side == "self" else find_component_by_path(root, peer_path)
|
||||
peer_sheet = find_parent_element(root, peer) if peer is not None else None
|
||||
|
||||
entries.append({
|
||||
"link": link,
|
||||
"side": side,
|
||||
"comp_slot": comp_slot,
|
||||
"peer_slot": peer_slot,
|
||||
"peer": peer,
|
||||
"peer_path": peer_path,
|
||||
"peer_sheet": peer_sheet,
|
||||
"same_sheet": peer is not None and peer_sheet is here,
|
||||
})
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def group_by_peer(entries):
|
||||
"""Collapses the entries of one side onto one box per peer.
|
||||
|
||||
A component wired twice into the same slot block is one box with two wires, exactly
|
||||
as it would be on a sheet -- drawing it twice would say there are two of it.
|
||||
Grouped by the path rather than the element, so a broken reference still groups with
|
||||
itself instead of collapsing every unresolved link into one box.
|
||||
"""
|
||||
groups = []
|
||||
index = {}
|
||||
for entry in entries:
|
||||
key = entry["peer_path"]
|
||||
if key not in index:
|
||||
index[key] = {"peer": entry["peer"], "peer_path": key,
|
||||
"peer_sheet": entry["peer_sheet"],
|
||||
"same_sheet": entry["same_sheet"], "entries": []}
|
||||
groups.append(index[key])
|
||||
index[key]["entries"].append(entry)
|
||||
return groups
|
||||
|
||||
|
||||
def _slot_order_key(group, focus_row_of):
|
||||
"""Where a peer belongs in its column: the first focus slot its links land on.
|
||||
|
||||
A peer wired to several of the chosen component's slots is placed by the topmost of
|
||||
them, and the path breaks a tie so that two peers on one slot keep a stable order
|
||||
from one draw to the next.
|
||||
"""
|
||||
unknown = len(focus_row_of)
|
||||
rows = [focus_row_of.get(entry["comp_slot"], unknown) for entry in group["entries"]]
|
||||
return (min(rows), group["peer_path"])
|
||||
|
||||
|
||||
class LinkViewPanel:
|
||||
"""One component's links, drawn around it on a canvas of this view's own.
|
||||
|
||||
Built into a frame the caller supplies and does nothing with it afterwards: packing
|
||||
that frame where the canvas was, and putting the canvas back on Close, is `main`'s
|
||||
business. Nothing here reaches for the wiresheet canvas or the window.
|
||||
|
||||
The picture is drawn once, from the document, and never edited -- so there is no
|
||||
dirty state, nothing to save, and Close asks nothing. `on_open_link` is how following
|
||||
a link gets back to the wiresheet; this module does not know how that is done.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, comp, on_open_link=None, on_close=None):
|
||||
self.comp = comp
|
||||
self.on_open_link = on_open_link
|
||||
self.on_close = on_close
|
||||
|
||||
# Nothing here can be edited, so there is never anything unwritten. `main` reads
|
||||
# this the way it reads the Properties view's, which is what lets one caller
|
||||
# treat either panel as "the thing in the editor pane".
|
||||
self.dirty = False
|
||||
|
||||
# Read by draw() rather than here, so a redraw after an undo or a delete shows
|
||||
# the links the document has now rather than the ones it had when this opened.
|
||||
self.entries = []
|
||||
|
||||
# tag -> the link that tag stands for, and what a hover has to put back. Rebuilt
|
||||
# by every draw, since a repaint hands out fresh tags.
|
||||
self.link_by_tag = {}
|
||||
self.hover_restore = {}
|
||||
|
||||
# Numbers the wires of one draw, so every line has a tag of its own even when
|
||||
# two of them stand for links between the same pair of components.
|
||||
self._wire_seq = 0
|
||||
|
||||
self.frame = tk.Frame(parent)
|
||||
|
||||
# The footer is packed first and against the bottom: pack hands out leftover
|
||||
# space in order, so an expanding area packed first leaves a later sibling none.
|
||||
self._build_header()
|
||||
self._build_footer()
|
||||
self._build_canvas_area()
|
||||
self.draw()
|
||||
|
||||
# -- construction --
|
||||
|
||||
def _build_header(self):
|
||||
header = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG)
|
||||
header.pack(fill=tk.X)
|
||||
|
||||
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
|
||||
plain_font = tkfont.Font(family="Segoe UI", size=9)
|
||||
|
||||
tk.Label(header, text=self.comp.get("name", "?"), font=name_font,
|
||||
bg=HEADER_BG).pack(side=tk.LEFT)
|
||||
tk.Label(header, text=f"({self.comp.get('type', 'sys::Component')})",
|
||||
font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.LEFT,
|
||||
padx=(8, 0))
|
||||
|
||||
# The path is what a <link> names this component by, so it is the one thing on
|
||||
# screen that says which of four identically named boxes this picture is about.
|
||||
root = app_state.xml_root_element
|
||||
path = get_component_path(self.comp, root) if root is not None else ""
|
||||
tk.Label(header, text=path, font=plain_font, fg=PATH_COLOUR,
|
||||
bg=HEADER_BG).pack(side=tk.LEFT, padx=(16, 0))
|
||||
|
||||
# Filled in by draw(), which is what counts the links.
|
||||
self.count_var = tk.StringVar(value="")
|
||||
tk.Label(header, textvariable=self.count_var, font=plain_font, fg="#555555",
|
||||
bg=HEADER_BG).pack(side=tk.RIGHT)
|
||||
|
||||
def _build_canvas_area(self):
|
||||
area = tk.Frame(self.frame)
|
||||
area.pack(fill=tk.BOTH, expand=True)
|
||||
area.grid_rowconfigure(0, weight=1)
|
||||
area.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.canvas = tk.Canvas(area, bg=SHEET_BG, highlightthickness=0)
|
||||
self.canvas.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
self.vertical_bar = ttk.Scrollbar(area, orient="vertical",
|
||||
command=self.canvas.yview)
|
||||
self.vertical_bar.grid(row=0, column=1, sticky="ns")
|
||||
|
||||
self.horizontal_bar = ttk.Scrollbar(area, orient="horizontal",
|
||||
command=self.canvas.xview)
|
||||
self.horizontal_bar.grid(row=1, column=0, sticky="ew")
|
||||
|
||||
# Each bar is shown only while it has somewhere to scroll, so a pane wide enough
|
||||
# for the picture carries neither.
|
||||
self.canvas.configure(
|
||||
yscrollcommand=lambda first, last: self._sync_bar(
|
||||
self.vertical_bar, first, last),
|
||||
xscrollcommand=lambda first, last: self._sync_bar(
|
||||
self.horizontal_bar, first, last),
|
||||
)
|
||||
|
||||
# Bound on this frame, not bind_all: the wiresheet canvas has wheel bindings of
|
||||
# its own, and an application-wide one here would scroll this from there.
|
||||
self.frame.bind("<MouseWheel>", self._on_wheel)
|
||||
self.frame.bind("<Shift-MouseWheel>", self._on_shift_wheel)
|
||||
|
||||
def _build_footer(self):
|
||||
footer = tk.Frame(self.frame, padx=8, pady=6)
|
||||
footer.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
|
||||
# A legend, because the difference between the two line styles is the whole
|
||||
# point of the picture and is not self-evident from one line.
|
||||
legend = tk.Canvas(footer, width=210, height=16, highlightthickness=0,
|
||||
bg=footer.cget("bg"))
|
||||
legend.pack(side=tk.LEFT, padx=(0, 10))
|
||||
legend.create_line(2, 8, 26, 8, fill=WIRE_COLOUR, width=WIRE_WIDTH)
|
||||
legend.create_text(30, 8, anchor="w", text="this sheet", fill=LABEL_COLOUR,
|
||||
font=("Segoe UI", 8))
|
||||
legend.create_line(96, 8, 120, 8, fill=WIRE_COLOUR, width=WIRE_WIDTH,
|
||||
dash=OFF_SHEET_DASH)
|
||||
legend.create_text(124, 8, anchor="w", text="another sheet", fill=LABEL_COLOUR,
|
||||
font=("Segoe UI", 8))
|
||||
|
||||
self.status_var = tk.StringVar(value="")
|
||||
tk.Label(footer, textvariable=self.status_var, anchor="w",
|
||||
fg="#333333").pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
|
||||
ttk.Button(footer, text="Close", width=9,
|
||||
command=self.close).pack(side=tk.RIGHT)
|
||||
|
||||
def _sync_bar(self, bar, first, last):
|
||||
"""Moves a scrollbar's thumb, and hides the bar when it spans the whole."""
|
||||
bar.set(first, last)
|
||||
if float(first) <= 0.0 and float(last) >= 1.0:
|
||||
bar.grid_remove()
|
||||
else:
|
||||
bar.grid()
|
||||
|
||||
def _on_wheel(self, event):
|
||||
if self.canvas.winfo_exists():
|
||||
self.canvas.yview_scroll(-1 * (event.delta // 120), "units")
|
||||
|
||||
def _on_shift_wheel(self, event):
|
||||
if self.canvas.winfo_exists():
|
||||
self.canvas.xview_scroll(-1 * (event.delta // 120), "units")
|
||||
|
||||
# -- drawing --
|
||||
|
||||
def draw(self):
|
||||
"""Lays the picture out from the links and paints it.
|
||||
|
||||
Positions are worked out here rather than read from the file: `meta` carries a
|
||||
component's place on its own wiresheet, which says nothing about where it
|
||||
belongs relative to this one. Both columns are measured before anything is
|
||||
drawn, because the middle box's position depends on the taller of them.
|
||||
"""
|
||||
canvas = self.canvas
|
||||
canvas.delete("all")
|
||||
self.link_by_tag.clear()
|
||||
self.hover_restore.clear()
|
||||
self._wire_seq = 0
|
||||
|
||||
self.entries = collect_links(self.comp)
|
||||
count = len(self.entries)
|
||||
self.count_var.set(f"{count} link{'' if count == 1 else 's'}")
|
||||
|
||||
fonts = wiresheet.sheet_fonts()
|
||||
# The same index the sheet paints with, so a linked slot has a row here too and
|
||||
# a wire has the same row to land on.
|
||||
linked_slots = wiresheet.build_linked_slot_index()
|
||||
|
||||
incoming = group_by_peer([e for e in self.entries if e["side"] == "in"])
|
||||
outgoing = group_by_peer([e for e in self.entries if e["side"] == "out"])
|
||||
self_links = [e for e in self.entries if e["side"] == "self"]
|
||||
|
||||
focus_width, focus_height, focus_rows = wiresheet.measure_box(
|
||||
self.comp, fonts, linked_slots)[:3]
|
||||
focus_size = (focus_width, focus_height)
|
||||
|
||||
# Which row of the chosen component each of its slots is on. Peers are put in
|
||||
# that order, top to bottom, so the wires form a ladder instead of a weave: a
|
||||
# component feeding the first slot sits above one feeding the fifth, and neither
|
||||
# has any reason to cross the other on the way in. Read from the measurement
|
||||
# rather than from the drawn box, because the order has to be settled before
|
||||
# there is anything drawn to read it off.
|
||||
focus_row_of = {raw: index for index, (raw, _label, _value)
|
||||
in enumerate(focus_rows)}
|
||||
incoming.sort(key=lambda group: _slot_order_key(group, focus_row_of))
|
||||
outgoing.sort(key=lambda group: _slot_order_key(group, focus_row_of))
|
||||
|
||||
left_sizes = [self._group_size(group, fonts, linked_slots) for group in incoming]
|
||||
right_sizes = [self._group_size(group, fonts, linked_slots) for group in outgoing]
|
||||
|
||||
left_width = max([size[0] for size in left_sizes], default=0)
|
||||
right_width = max([size[0] for size in right_sizes], default=0)
|
||||
left_height = self._column_height(left_sizes)
|
||||
right_height = self._column_height(right_sizes)
|
||||
|
||||
content_height = max(left_height, right_height, focus_size[1])
|
||||
|
||||
# A link a component makes to itself loops out through both gaps, so it is
|
||||
# counted in each of them.
|
||||
left_gap = column_gap(sum(1 for e in self.entries if e["side"] != "out"))
|
||||
right_gap = column_gap(sum(1 for e in self.entries if e["side"] != "in"))
|
||||
|
||||
focus_x = MARGIN + (left_width + left_gap if incoming else 0)
|
||||
right_x = focus_x + focus_size[0] + right_gap
|
||||
|
||||
# Rounded to whole pixels. Every height and slot offset here is an integer, so
|
||||
# rounding the one division in the layout is what lets two slots that ought to
|
||||
# line up line up exactly -- and an exactly aligned pair is a wire drawn as one
|
||||
# straight line rather than one with a 1px jog in it.
|
||||
focus_y = MARGIN + round((content_height - focus_size[1]) / 2)
|
||||
|
||||
focus_box = self._draw_box(self.comp, focus_x, focus_y, fonts, linked_slots,
|
||||
("lv_focus",), outline_tags=("lv_focus_outline",))
|
||||
canvas.itemconfig("lv_focus_outline", outline=FOCUS_OUTLINE,
|
||||
width=FOCUS_OUTLINE_WIDTH)
|
||||
|
||||
# Every box is placed before any wire is drawn, so a box can never be laid over
|
||||
# a wire that was already there.
|
||||
jobs = {"in": [], "out": []}
|
||||
columns = ((incoming, left_sizes, left_height, left_width, MARGIN, "in"),
|
||||
(outgoing, right_sizes, right_height, right_width, right_x, "out"))
|
||||
for column, sizes, column_height, column_width, column_x, side in columns:
|
||||
y = MARGIN + round((content_height - column_height) / 2)
|
||||
for group, (width, height) in zip(column, sizes):
|
||||
# Boxes are right-aligned on the left and left-aligned on the right, so
|
||||
# every wire crosses the same gap however wide its own box turned out.
|
||||
x = column_x + (column_width - width) if side == "in" else column_x
|
||||
box = self._draw_group_box(group, x, y, fonts, linked_slots)
|
||||
for entry in group["entries"]:
|
||||
jobs[side].append(self._wire_ends(entry, side, focus_box, box))
|
||||
y += height + ROW_GAP
|
||||
|
||||
# Each side is routed as a set, not wire by wire: which lane a wire may turn in
|
||||
# depends on where the others turn, so they cannot be settled one at a time.
|
||||
for side, side_jobs in jobs.items():
|
||||
self._route_side(side_jobs, focus_box, side)
|
||||
for job in side_jobs:
|
||||
self._draw_wire(job)
|
||||
|
||||
# A self-link crosses both gaps, so it takes the lane beyond every wire that has
|
||||
# a peer to reach, on each side.
|
||||
for offset, entry in enumerate(self_links):
|
||||
self._draw_self_wire(entry, focus_box,
|
||||
len(jobs["in"]) + offset,
|
||||
len(jobs["out"]) + offset)
|
||||
|
||||
# Wires sit behind the boxes, as they do on a wiresheet, so a run that has to
|
||||
# pass a component -- a link a component makes to itself, at the far end of its
|
||||
# loop -- can never be mistaken for something drawn on top of that box. Knobs
|
||||
# are not lowered with them: a knob marks a slot and belongs on top of it.
|
||||
self.canvas.tag_lower("lv_wire")
|
||||
|
||||
if not self.entries:
|
||||
canvas.create_text(
|
||||
focus_x + focus_size[0] / 2, focus_y + focus_size[1] + 24,
|
||||
text="Nothing is linked to this component.",
|
||||
fill=LABEL_COLOUR, font=("Segoe UI", 9)
|
||||
)
|
||||
self.status_var.set("No links touch this component.")
|
||||
else:
|
||||
# An unresolved reference is not on another wiresheet -- it is on no sheet at
|
||||
# all -- so the two are counted apart rather than read as the same thing.
|
||||
off_sheet = sum(1 for e in self.entries
|
||||
if e["peer"] is not None and not e["same_sheet"])
|
||||
broken = sum(1 for e in self.entries if e["peer"] is None)
|
||||
note = f"{count} link{'' if count == 1 else 's'}"
|
||||
if off_sheet:
|
||||
note += f", {off_sheet} on another wiresheet"
|
||||
if broken:
|
||||
note += f", {broken} naming a component this document does not have"
|
||||
self.status_var.set(f"{note}. Click a link or a component to go to it.")
|
||||
|
||||
self._fit_scrollregion()
|
||||
|
||||
def _group_size(self, group, fonts, linked_slots):
|
||||
"""How much room one peer box takes, note included."""
|
||||
if group["peer"] is None:
|
||||
return self._missing_box_size(group["peer_path"])
|
||||
width, height = wiresheet.measure_box(group["peer"], fonts, linked_slots)[:2]
|
||||
if not group["same_sheet"]:
|
||||
# The "on <sheet>" note sits under the box and is part of what the row
|
||||
# has to clear, or the next box would be drawn over it.
|
||||
height += LABEL_GAP + 14
|
||||
return width, height
|
||||
|
||||
def _missing_box_size(self, peer_path):
|
||||
note_font = tkfont.Font(family="Segoe UI", size=8)
|
||||
return max(120, note_font.measure(peer_path) + 20), 34
|
||||
|
||||
def _column_height(self, sizes):
|
||||
if not sizes:
|
||||
return 0
|
||||
return sum(size[1] for size in sizes) + ROW_GAP * (len(sizes) - 1)
|
||||
|
||||
def _draw_box(self, comp, x, y, fonts, linked_slots, tags, outline_tags=()):
|
||||
"""Paints a component box and returns what a wire needs to reach its slots."""
|
||||
width, height, slot_offsets = wiresheet.draw_component_box(
|
||||
self.canvas, comp, x, y, fonts, linked_slots, tags, outline_tags
|
||||
)
|
||||
return {"x": x, "y": y, "width": width, "height": height,
|
||||
"slots": slot_offsets}
|
||||
|
||||
def _draw_group_box(self, group, x, y, fonts, linked_slots):
|
||||
"""One peer's box, its sheet named under it when that sheet is not this one.
|
||||
|
||||
The box carries the group's own tag so clicking it follows the first of its
|
||||
links, which is the same thing clicking that wire does. A reference the document
|
||||
cannot resolve gets a placeholder instead: there is no component to draw, and
|
||||
drawing nothing would lose the link entirely.
|
||||
"""
|
||||
tag = f"lv_peer_{id(group)}"
|
||||
entries = group["entries"]
|
||||
|
||||
if group["peer"] is None:
|
||||
width, height = self._missing_box_size(group["peer_path"])
|
||||
self.canvas.create_rectangle(
|
||||
x, y, x + width, y + height, fill="#f4eccd", outline=MISSING_COLOUR,
|
||||
dash=OFF_SHEET_DASH, tags=(tag,)
|
||||
)
|
||||
self.canvas.create_text(
|
||||
x + width / 2, y + 12, text=group["peer_path"], fill=MISSING_COLOUR,
|
||||
font=("Segoe UI", 8), tags=(tag,)
|
||||
)
|
||||
self.canvas.create_text(
|
||||
x + width / 2, y + 24, text="not in this document",
|
||||
fill=MISSING_COLOUR, font=("Segoe UI", 8), tags=(tag,)
|
||||
)
|
||||
box = {"x": x, "y": y, "width": width, "height": height, "slots": {}}
|
||||
else:
|
||||
box = self._draw_box(group["peer"], x, y, fonts, linked_slots, (tag,))
|
||||
if not group["same_sheet"]:
|
||||
self.canvas.create_text(
|
||||
x + box["width"] / 2, y + box["height"] + LABEL_GAP,
|
||||
anchor="n", text=f"on {sheet_display_name(group['peer_sheet'])}",
|
||||
fill=LABEL_COLOUR, font=("Segoe UI", 8), tags=(tag,)
|
||||
)
|
||||
|
||||
# A box stands for its links, so clicking it follows one. Registered against the
|
||||
# first, and the status line says so when there is more than one to choose from.
|
||||
self._make_followable(tag, entries[0]["link"], len(entries))
|
||||
return box
|
||||
|
||||
def _slot_y(self, box, slot_name):
|
||||
"""Where a wire meets a box: its slot's row, or the box's middle if it has none."""
|
||||
offset = box["slots"].get(slot_name)
|
||||
if offset is None:
|
||||
return box["y"] + box["height"] / 2
|
||||
return box["y"] + offset
|
||||
|
||||
def _lane_x(self, focus_box, side, rank):
|
||||
"""Where a wire's vertical run stands, measured out from the chosen component.
|
||||
|
||||
Out from the focus rather than in from the peer, because every wire on one side
|
||||
leaves the same box edge while the boxes it reaches are all different heights:
|
||||
anchoring to the shared edge is what lets the lanes nest. `column_gap` sized the
|
||||
gap to hold one lane per wire, so a rank inside that count lands inside the gap.
|
||||
"""
|
||||
if side == "in":
|
||||
return focus_box["x"] - STUB - (rank + 1) * LANE_GAP
|
||||
return focus_box["x"] + focus_box["width"] + STUB + (rank + 1) * LANE_GAP
|
||||
|
||||
def _wire_ends(self, entry, side, focus_box, peer_box):
|
||||
"""Where one link's wire has to start and finish, before it is routed.
|
||||
|
||||
Drawn peer-to-focus on the left and focus-to-peer on the right, so every arrow
|
||||
points the way the signal travels and the picture reads left to right the way a
|
||||
wiresheet does. A link whose far end is on another sheet keeps the grey knob its
|
||||
own sheet paints on that slot, so the slot looks the same here as it does there,
|
||||
and the wire stands off far enough to leave that marker its own space.
|
||||
"""
|
||||
focus_y = self._slot_y(focus_box, entry["comp_slot"])
|
||||
peer_y = self._slot_y(peer_box, entry["peer_slot"])
|
||||
off_sheet = not entry["same_sheet"]
|
||||
standoff = 2 * wiresheet.KNOB_RADIUS if off_sheet else 0
|
||||
|
||||
if side == "in":
|
||||
start = (peer_box["x"] + peer_box["width"], peer_y)
|
||||
end = (focus_box["x"] - standoff, focus_y)
|
||||
knob_x = focus_box["x"] - wiresheet.KNOB_RADIUS
|
||||
else:
|
||||
start = (focus_box["x"] + focus_box["width"] + standoff, focus_y)
|
||||
end = (peer_box["x"], peer_y)
|
||||
knob_x = focus_box["x"] + focus_box["width"] + wiresheet.KNOB_RADIUS
|
||||
|
||||
# The two heights are kept apart from the two ends: which end is the focus's
|
||||
# swaps between the sides, and the lane rule is written in terms of the slots
|
||||
# rather than the direction the line happens to be drawn in.
|
||||
return {"entry": entry, "side": side, "start": start, "end": end,
|
||||
"focus_y": focus_y, "peer_y": peer_y,
|
||||
"knob": (knob_x, focus_y) if off_sheet else None,
|
||||
"off_sheet": off_sheet, "route": None}
|
||||
|
||||
def _route_side(self, side_jobs, focus_box, side):
|
||||
"""Chooses a lane for every wire on one side so that no two of them meet.
|
||||
|
||||
The peers are already stacked in the order of the focus slots their links land
|
||||
on, so both ends of the set run the same way down the page -- a ladder, not a
|
||||
weave. For a ladder there is a lane order that cannot produce a crossing, and it
|
||||
is two fans rather than one order. Reading it off the slots, not off the
|
||||
direction the line is drawn in, it is the same on both sides:
|
||||
|
||||
a wire reaching a peer slot *above* its focus slot turns nearer the focus
|
||||
the higher up the focus it lands
|
||||
|
||||
a wire reaching a peer slot *below* its focus slot turns nearer the focus
|
||||
the lower down the focus it lands
|
||||
|
||||
So each fan nests outwards from the focus's own row. Take two wires of the upper
|
||||
fan, the higher one nested inside the lower: the lower one's run into the focus
|
||||
passes below where the higher one's vertical ends, and its run out to its peer
|
||||
passes above where that vertical begins, so it goes round it and never through
|
||||
it. The lower fan is the same argument upside down. And a wire of one fan can
|
||||
never share a stretch of the page with a wire of the other -- ordering the
|
||||
ladder is what rules that out -- so the two fans reuse each other's lanes and
|
||||
the gap stays as narrow as the picture allows.
|
||||
|
||||
Each fan is walked from the lane against the focus outwards, and every wire
|
||||
still has to pass the collision check before it takes a lane. The argument above
|
||||
rests on the ladder being ordered, and a peer whose own slots run counter to the
|
||||
focus's is not quite a ladder; the check is what makes that case come out tidy
|
||||
anyway rather than trusting the reasoning to cover it.
|
||||
"""
|
||||
straight, above, below = [], [], []
|
||||
for job in side_jobs:
|
||||
offset = job["peer_y"] - job["focus_y"]
|
||||
if abs(offset) < EPSILON:
|
||||
straight.append(job)
|
||||
elif offset < 0:
|
||||
above.append(job)
|
||||
else:
|
||||
below.append(job)
|
||||
|
||||
# Within a fan, the focus slot each wire lands on is the order that matters --
|
||||
# except where several wires leave the *same* slot, which no order of focus
|
||||
# heights can separate. That is a fan-out, and there the longest branch has to
|
||||
# turn first, against the focus: the shorter ones then carry on along the shared
|
||||
# run beside the pin and drop away outside it, which is how a wiresheet draws a
|
||||
# fan-out and the only order that keeps a branch from crossing its neighbour.
|
||||
def fan_order(job, direction):
|
||||
return (direction * job["focus_y"],
|
||||
-abs(job["peer_y"] - job["focus_y"]))
|
||||
|
||||
above.sort(key=lambda job: fan_order(job, 1))
|
||||
below.sort(key=lambda job: fan_order(job, -1))
|
||||
|
||||
routed = []
|
||||
for job in straight:
|
||||
# The slots already line up: one straight line, no vertical run to place.
|
||||
# This is what ordering the peers buys, and it is settled first because such
|
||||
# a wire crosses the whole gap and everything else has to be fitted past it.
|
||||
job["route"] = [job["start"], job["end"]]
|
||||
routed.append(job)
|
||||
|
||||
for job in above + below:
|
||||
for rank in range(len(side_jobs)):
|
||||
candidate = self._route(job["start"], job["end"],
|
||||
self._lane_x(focus_box, side, rank))
|
||||
if not any(routes_meet(candidate, other["route"])
|
||||
for other in routed):
|
||||
job["route"] = candidate
|
||||
break
|
||||
else:
|
||||
# No lane in the gap is clear. Rather than leave the link off the
|
||||
# picture it is drawn in the outermost lane and the crossing is
|
||||
# accepted -- a crossed wire says more than a missing one.
|
||||
job["route"] = self._route(
|
||||
job["start"], job["end"],
|
||||
self._lane_x(focus_box, side, len(side_jobs) - 1)
|
||||
)
|
||||
routed.append(job)
|
||||
|
||||
def _draw_wire(self, job):
|
||||
"""Paints one routed wire, and the knob of a link that leaves the sheet."""
|
||||
entry = job["entry"]
|
||||
self._wire_seq += 1
|
||||
tag = f"lv_link_{id(entry['link'])}_{self._wire_seq}"
|
||||
self._draw_route(job["route"], tag, job["off_sheet"])
|
||||
|
||||
if job["knob"] is not None:
|
||||
# The knob belongs to the slot, not to the wire, so it is drawn outside the
|
||||
# wire's tag: hovering the line must not recolour the slot's marker.
|
||||
wiresheet.draw_knob(self.canvas, job["knob"][0], job["knob"][1], False, True)
|
||||
|
||||
self._make_followable(tag, entry["link"], 1)
|
||||
|
||||
def _route(self, start, end, lane_x):
|
||||
"""A wire's corners: out of one slot, across the gap, into the other.
|
||||
|
||||
Three segments rather than a diagonal, because that is how the wiresheet draws
|
||||
one and a picture of the same links should not change shape between the two.
|
||||
Two slots already at the same height need no turn at all.
|
||||
"""
|
||||
(sx, sy), (ex, ey) = start, end
|
||||
if abs(sy - ey) < EPSILON:
|
||||
return [(sx, sy), (ex, ey)]
|
||||
return [(sx, sy), (lane_x, sy), (lane_x, ey), (ex, ey)]
|
||||
|
||||
def _draw_self_wire(self, entry, focus_box, in_rank, out_rank):
|
||||
"""A component linked to itself: out of one of its slots and back into another.
|
||||
|
||||
It has no peer to stand opposite, so the wire loops under the box, using the
|
||||
next lane out on each side so it clears every wire that has a peer. Rare, and
|
||||
drawn rather than dropped because a link the view did not show would be the one
|
||||
thing a reader could not find out from here.
|
||||
"""
|
||||
out_y = self._slot_y(focus_box, entry["peer_slot"])
|
||||
in_y = self._slot_y(focus_box, entry["comp_slot"])
|
||||
right_lane = self._lane_x(focus_box, "out", out_rank)
|
||||
left_lane = self._lane_x(focus_box, "in", in_rank)
|
||||
below = (focus_box["y"] + focus_box["height"] + SELF_LOOP_DROP
|
||||
+ out_rank * LANE_GAP)
|
||||
|
||||
points = [(focus_box["x"] + focus_box["width"], out_y), (right_lane, out_y),
|
||||
(right_lane, below), (left_lane, below),
|
||||
(left_lane, in_y), (focus_box["x"], in_y)]
|
||||
|
||||
self._wire_seq += 1
|
||||
tag = f"lv_link_{id(entry['link'])}_{self._wire_seq}"
|
||||
self._draw_route(points, tag, not entry["same_sheet"])
|
||||
self._make_followable(tag, entry["link"], 1)
|
||||
|
||||
def _draw_route(self, points, tag, off_sheet):
|
||||
flat = [coord for point in points for coord in point]
|
||||
self.canvas.create_line(
|
||||
*flat, fill=WIRE_COLOUR, width=WIRE_WIDTH, arrow=tk.LAST,
|
||||
arrowshape=(8, 9, 3), dash=OFF_SHEET_DASH if off_sheet else None,
|
||||
tags=("lv_wire", tag)
|
||||
)
|
||||
|
||||
# -- following a link --
|
||||
|
||||
def _make_followable(self, tag, link_elem, link_count):
|
||||
"""Makes everything under one tag say where it goes, and go there when clicked.
|
||||
|
||||
Bound per tag rather than per item, so a box's dozen items behave as the one
|
||||
thing they draw.
|
||||
"""
|
||||
self.link_by_tag[tag] = (link_elem, link_count)
|
||||
self.canvas.tag_bind(tag, "<Enter>", lambda _e, t=tag: self._on_enter(t))
|
||||
self.canvas.tag_bind(tag, "<Leave>", lambda _e, t=tag: self._on_leave(t))
|
||||
self.canvas.tag_bind(tag, "<Button-1>", lambda _e, t=tag: self._on_click(t))
|
||||
|
||||
def _on_enter(self, tag):
|
||||
link_elem, link_count = self.link_by_tag[tag]
|
||||
self.canvas.configure(cursor="hand2")
|
||||
|
||||
# Only the wire is recoloured; a box under the pointer keeps its own colours, so
|
||||
# the component still reads as the component it is.
|
||||
for item in self.canvas.find_withtag(tag):
|
||||
if self.canvas.type(item) != "line" or "lv_wire" not in self.canvas.gettags(item):
|
||||
continue
|
||||
self.hover_restore[item] = (self.canvas.itemcget(item, "fill"),
|
||||
self.canvas.itemcget(item, "width"))
|
||||
self.canvas.itemconfig(item, fill=WIRE_HOVER_COLOUR, width=WIRE_HOVER_WIDTH)
|
||||
|
||||
message = f"{link_elem.get('from', '?')} → {link_elem.get('to', '?')}"
|
||||
if link_count > 1:
|
||||
message += f" (first of {link_count} links to this component)"
|
||||
self.status_var.set(message)
|
||||
|
||||
def _on_leave(self, tag):
|
||||
self.canvas.configure(cursor="")
|
||||
for item in list(self.hover_restore):
|
||||
fill, width = self.hover_restore.pop(item)
|
||||
try:
|
||||
self.canvas.itemconfig(item, fill=fill, width=width)
|
||||
except tk.TclError:
|
||||
pass # the item went away with a redraw
|
||||
self.status_var.set("")
|
||||
|
||||
def _on_click(self, tag):
|
||||
link_elem = self.link_by_tag[tag][0]
|
||||
if self.on_open_link is not None:
|
||||
self.on_open_link(link_elem)
|
||||
|
||||
# -- leaving --
|
||||
|
||||
def _fit_scrollregion(self):
|
||||
"""Sizes the canvas to the picture and puts the chosen component in view.
|
||||
|
||||
The region is the drawn extent with a margin, rather than a fixed span: this
|
||||
picture is only ever as big as the links make it.
|
||||
"""
|
||||
bounds = self.canvas.bbox("all")
|
||||
if bounds is None:
|
||||
self.canvas.configure(scrollregion=(0, 0, 1, 1))
|
||||
return
|
||||
|
||||
region = (min(0, bounds[0] - MARGIN), min(0, bounds[1] - MARGIN),
|
||||
bounds[2] + MARGIN, bounds[3] + MARGIN)
|
||||
self.canvas.configure(scrollregion=region)
|
||||
# A picture wider or taller than the pane opens on the middle box, which is the
|
||||
# one thing the view is about.
|
||||
self.canvas.after_idle(self._centre_on_focus)
|
||||
|
||||
def _centre_on_focus(self):
|
||||
if not self.canvas.winfo_exists():
|
||||
return
|
||||
box = self.canvas.bbox("lv_focus")
|
||||
region = str(self.canvas.cget("scrollregion")).split()
|
||||
if box is None or len(region) != 4:
|
||||
return
|
||||
|
||||
left, top, right, bottom = (float(value) for value in region)
|
||||
span_x = (right - left) or 1.0
|
||||
span_y = (bottom - top) or 1.0
|
||||
middle_x = (box[0] + box[2]) / 2 - self.canvas.winfo_width() / 2
|
||||
middle_y = (box[1] + box[3]) / 2 - self.canvas.winfo_height() / 2
|
||||
self.canvas.xview_moveto(max(0.0, (middle_x - left) / span_x))
|
||||
self.canvas.yview_moveto(max(0.0, (middle_y - top) / span_y))
|
||||
|
||||
def close(self):
|
||||
"""Hands the pane back. Nothing to ask: this view has never changed anything."""
|
||||
if self.on_close is not None:
|
||||
self.on_close()
|
||||
return True
|
||||
|
||||
def destroy(self):
|
||||
"""Tears the panel's widgets down, for a caller that is done with it."""
|
||||
if self.frame.winfo_exists():
|
||||
self.frame.destroy()
|
||||
|
||||
|
||||
def build_link_view_panel(parent, comp, on_open_link=None, on_close=None):
|
||||
"""A Link View for one component, built into `parent` but not yet placed.
|
||||
|
||||
`on_open_link` is handed the `<link>` element a click chose, and is what takes the
|
||||
view down and opens the wiresheet it leads to; `on_close` is what the Close button
|
||||
asks for. The caller packs `panel.frame` wherever it wants it.
|
||||
"""
|
||||
if comp is None or parent is None:
|
||||
return None
|
||||
return LinkViewPanel(parent, comp, on_open_link, on_close)
|
||||
+615
@@ -0,0 +1,615 @@
|
||||
"""
|
||||
Write-through interception layer for live device mode.
|
||||
|
||||
Checks app_state.live_mode and routes mutations through DeviceSession/SoxClient.
|
||||
In offline mode, falls through to the original DOM-only code paths.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from tkinter import messagebox
|
||||
|
||||
import app_state
|
||||
from sox import BUF_ID, CONFIG, STR_ID, type_id_for_slot
|
||||
|
||||
|
||||
# Why the last live_* call failed, in words the user can act on. Every failing call sets
|
||||
# it, so a caller can put it in front of the user with warn_failure().
|
||||
last_error = ""
|
||||
|
||||
|
||||
def is_live():
|
||||
return (app_state.live_mode
|
||||
and app_state.device_session is not None
|
||||
and app_state.device_session.is_connected)
|
||||
|
||||
|
||||
def _fail(reason):
|
||||
global last_error
|
||||
last_error = reason
|
||||
return None
|
||||
|
||||
|
||||
def _session():
|
||||
"""The connected DeviceSession, or None with last_error saying why not."""
|
||||
if not is_live():
|
||||
_fail("The device is not connected.")
|
||||
return None
|
||||
return app_state.device_session
|
||||
|
||||
|
||||
def warn_failure(title, what, detail=None):
|
||||
"""Tells the user, in a dialog and the status bar, that `what` did not reach the device.
|
||||
|
||||
`detail` defaults to last_error. Nothing retries a write that failed, so the dialog
|
||||
is the only record the user gets that the editor and the device may now disagree.
|
||||
"""
|
||||
reason = detail if detail is not None else last_error
|
||||
app_state.set_status(f"{what}: {reason}", warn=True)
|
||||
messagebox.showwarning(title, f"{what}.\n\n{reason}", parent=app_state.window)
|
||||
|
||||
|
||||
# ── Property Write ──────────────────────────────────────────────
|
||||
|
||||
def live_set_prop_value(element, slot_name, value_str):
|
||||
"""In live mode, sends a Sox write to the device. Returns True on success."""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
_fail(f"'{element.get('name', '?')}' has no component id.")
|
||||
return False
|
||||
|
||||
slot_info = _find_slot(ds, comp_id, slot_name)
|
||||
if slot_info is None:
|
||||
return False
|
||||
|
||||
type_id = slot_info["type_id"]
|
||||
try:
|
||||
coerced = _coerce_value(type_id, value_str)
|
||||
except (TypeError, ValueError, binascii.Error) as e:
|
||||
_fail(f"'{value_str}' is not a valid value for {slot_name}: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
ds.sox_client.write_prop(comp_id, slot_info["id"], type_id, coerced)
|
||||
return True
|
||||
except Exception as e:
|
||||
_fail(f"The device rejected the write to {slot_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Add Component ───────────────────────────────────────────────
|
||||
|
||||
def live_add_component(parent_element, kit_name, type_name, comp_name, config_values=None):
|
||||
"""In live mode, sends a Sox add to the device. Returns new comp_id or None."""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return None
|
||||
parent_id = _comp_id(parent_element)
|
||||
if parent_id is None:
|
||||
return _fail(f"The parent '{parent_element.get('name', '?')}' has no component id.")
|
||||
|
||||
kit_id = _kit_id(ds, kit_name)
|
||||
if kit_id is None:
|
||||
return _fail(f"Kit '{kit_name}' is not installed on the device.")
|
||||
type_id_val = _type_id(ds, kit_id, type_name)
|
||||
if type_id_val is None:
|
||||
return _fail(f"The device's '{kit_name}' kit has no type '{type_name}'.")
|
||||
|
||||
try:
|
||||
new_comp = ds.sox_client.add(parent_id, kit_id, type_id_val, comp_name,
|
||||
list(config_values or []))
|
||||
ds.after_add(new_comp.id)
|
||||
return new_comp.id
|
||||
except Exception as e:
|
||||
return _fail(f"The device rejected the add: {e}")
|
||||
|
||||
|
||||
# ── Delete Component ────────────────────────────────────────────
|
||||
|
||||
def live_delete_component(element):
|
||||
"""In live mode, sends a Sox delete to the device. Returns True on success."""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
_fail(f"'{element.get('name', '?')}' has no component id.")
|
||||
return False
|
||||
|
||||
descendant_ids = []
|
||||
for desc in element.iter():
|
||||
if desc.tag == "comp" and desc is not element:
|
||||
did = _comp_id(desc)
|
||||
if did is not None:
|
||||
descendant_ids.append(did)
|
||||
|
||||
try:
|
||||
ds.sox_client.delete(comp_id)
|
||||
ds.after_delete(comp_id, descendant_ids)
|
||||
return True
|
||||
except Exception as e:
|
||||
_fail(f"The device rejected the delete: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Rename Component ────────────────────────────────────────────
|
||||
|
||||
def live_rename_component(element, new_name):
|
||||
"""In live mode, sends a Sox rename to the device. Returns True on success."""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
_fail(f"'{element.get('name', '?')}' has no component id.")
|
||||
return False
|
||||
|
||||
try:
|
||||
ds.sox_client.rename(comp_id, new_name)
|
||||
return True
|
||||
except Exception as e:
|
||||
_fail(f"The device rejected the rename: {e}")
|
||||
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):
|
||||
"""(from_id, from_slot_id, to_id, to_slot_id), or None with last_error set."""
|
||||
if from_element is None or to_element is None:
|
||||
return _fail("One end of the link is not a component in the loaded tree.")
|
||||
from_id = _comp_id(from_element)
|
||||
to_id = _comp_id(to_element)
|
||||
if from_id is None or to_id is None:
|
||||
return _fail("One end of the link has no component id.")
|
||||
from_slot = _find_slot(ds, from_id, from_slot_name)
|
||||
if from_slot is None:
|
||||
return None
|
||||
to_slot = _find_slot(ds, to_id, to_slot_name)
|
||||
if to_slot is None:
|
||||
return None
|
||||
return from_id, from_slot["id"], to_id, to_slot["id"]
|
||||
|
||||
|
||||
def live_add_link(from_element, from_slot_name, to_element, to_slot_name):
|
||||
"""In live mode, sends a Sox link command. Returns True on success."""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
ends = _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name)
|
||||
if ends is None:
|
||||
return False
|
||||
try:
|
||||
ds.sox_client.link(*ends)
|
||||
return True
|
||||
except Exception as e:
|
||||
_fail(f"The device rejected the link: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def live_delete_link(from_element, from_slot_name, to_element, to_slot_name):
|
||||
"""In live mode, sends a Sox unlink command. Returns True on success."""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
ends = _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name)
|
||||
if ends is None:
|
||||
return False
|
||||
try:
|
||||
ds.sox_client.unlink(*ends)
|
||||
return True
|
||||
except Exception as e:
|
||||
_fail(f"The device rejected the unlink: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Invoke Action ───────────────────────────────────────────────
|
||||
|
||||
def live_invoke(element, slot_name, arg=None):
|
||||
"""In live mode, sends a Sox invoke. Returns True on success."""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
_fail(f"'{element.get('name', '?')}' has no component id.")
|
||||
return False
|
||||
|
||||
slot_info = _find_slot(ds, comp_id, slot_name)
|
||||
if slot_info is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
ds.sox_client.invoke(comp_id, slot_info["id"])
|
||||
return True
|
||||
except Exception as e:
|
||||
_fail(f"The device rejected the invoke of {slot_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Undo: read a subtree back, put it back ──────────────────────
|
||||
|
||||
def read_subtree(element):
|
||||
"""Everything it takes to put `element` and all it holds back on the device.
|
||||
|
||||
Read from the device, not the DOM: the DOM only holds what has been opened, and a
|
||||
placeholder child carries neither config nor children of its own. Requests go one
|
||||
component at a time, at the pace the device sets (see used_kit_names).
|
||||
|
||||
Returns {"tree", "links", "siblings"}, or None with last_error set. A node is
|
||||
{"id", "kit_id", "type_id", "type_name", "name", "values", "element", "children"},
|
||||
where "element" is the DOM element showing it, if any, so a new id can reach it.
|
||||
A link end, and each of "siblings" (the parent's children, in order), is ("node",
|
||||
node), ("elem", element) or ("id", comp_id): a component outside the subtree is
|
||||
followed by its element when it has one, because its id changes if it is itself
|
||||
deleted and put back.
|
||||
"""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return None
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
return _fail(f"'{element.get('name', '?')}' has no component id.")
|
||||
|
||||
client = ds.sox_client
|
||||
root = app_state.xml_root_element
|
||||
elements_by_id = {}
|
||||
if root is not None:
|
||||
for elem in root.iter("comp"):
|
||||
if elem.get("id") is not None:
|
||||
elements_by_id.setdefault(elem.get("id"), elem)
|
||||
|
||||
nodes_by_id = {}
|
||||
|
||||
def read(comp):
|
||||
if comp.type_name not in app_state.final_components_registry:
|
||||
raise _SnapshotError(f"No kit manifest describes {comp.type_name}, so "
|
||||
f"'{comp.name}' could not be put back as it was.")
|
||||
children = ds.load_children(comp.id)
|
||||
if len(children) != len(comp.children):
|
||||
raise _SnapshotError(f"Not every child of '{comp.name}' could be read.")
|
||||
client.update([comp], CONFIG)
|
||||
values = dict(comp.values)
|
||||
# The cache reads every Buf as a Str, which loses bytes of a binary one (a
|
||||
# user's cred); those are read again as what they are.
|
||||
for slot in app_state.final_components_registry[comp.type_name]:
|
||||
if ("c" in slot["flags"] and slot["type"] != "void"
|
||||
and _wire_type(slot["type"], slot["facets"].get("asStr") is True) == BUF_ID):
|
||||
values[slot["name"]] = client.read_prop(comp.id, slot["slot_id"], BUF_ID)
|
||||
node = {
|
||||
"id": comp.id,
|
||||
"kit_id": comp.kit_id,
|
||||
"type_id": comp.type_id,
|
||||
"type_name": comp.type_name,
|
||||
"name": comp.name,
|
||||
"values": values,
|
||||
"element": elements_by_id.get(str(comp.id)),
|
||||
"children": [],
|
||||
}
|
||||
nodes_by_id[comp.id] = node
|
||||
node["children"] = [read(child) for child in children]
|
||||
return node
|
||||
|
||||
try:
|
||||
top = client.load(comp_id)
|
||||
if top is None:
|
||||
return _fail(f"'{element.get('name', '?')}' could not be read from the device.")
|
||||
tree = read(top)
|
||||
|
||||
links = []
|
||||
seen = set()
|
||||
for cid in list(nodes_by_id):
|
||||
for lnk in client.read_links(cid):
|
||||
key = (lnk.from_comp_id, lnk.from_slot_id, lnk.to_comp_id, lnk.to_slot_id)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
links.append((_link_end(lnk.from_comp_id, nodes_by_id, elements_by_id),
|
||||
lnk.from_slot_id,
|
||||
_link_end(lnk.to_comp_id, nodes_by_id, elements_by_id),
|
||||
lnk.to_slot_id))
|
||||
except _SnapshotError as e:
|
||||
return _fail(str(e))
|
||||
except Exception as e:
|
||||
return _fail(f"'{element.get('name', '?')}' could not be read from the device: {e}")
|
||||
|
||||
parent = client.cache_get(top.parent)
|
||||
siblings = [_link_end(cid, nodes_by_id, elements_by_id)
|
||||
for cid in (parent.children if parent is not None else [])]
|
||||
return {"tree": tree, "links": links, "siblings": siblings}
|
||||
|
||||
|
||||
class _SnapshotError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _link_end(comp_id, nodes_by_id, elements_by_id):
|
||||
if comp_id in nodes_by_id:
|
||||
return ("node", nodes_by_id[comp_id])
|
||||
elem = elements_by_id.get(str(comp_id))
|
||||
if elem is not None:
|
||||
return ("elem", elem)
|
||||
return ("id", comp_id)
|
||||
|
||||
|
||||
def _link_end_id(end):
|
||||
kind, ref = end
|
||||
if kind == "node":
|
||||
return ref["id"]
|
||||
if kind == "elem":
|
||||
return _comp_id(ref)
|
||||
return ref
|
||||
|
||||
|
||||
def recreate_subtree(parent_element, snapshot, build_config):
|
||||
"""Adds a subtree from read_subtree back under `parent_element`, with its links.
|
||||
|
||||
The device hands out new ids. Each node, and the DOM element showing it, takes its
|
||||
new id, so whatever refers to them later still finds them. The component goes back
|
||||
to its old place among its siblings.
|
||||
|
||||
`build_config(type_name, device_values=...)` makes an add command's config values.
|
||||
Returns (top_created, complete); when not complete, last_error says what is missing.
|
||||
"""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False, False
|
||||
parent_id = _comp_id(parent_element)
|
||||
if parent_id is None:
|
||||
_fail(f"The parent '{parent_element.get('name', '?')}' has no component id.")
|
||||
return False, False
|
||||
|
||||
client = ds.sox_client
|
||||
tree = snapshot["tree"]
|
||||
|
||||
def create(node, under_id):
|
||||
config = build_config(node["type_name"], device_values=node["values"])
|
||||
comp = client.add(under_id, node["kit_id"], node["type_id"], node["name"], config)
|
||||
node["id"] = comp.id
|
||||
if node["element"] is not None:
|
||||
node["element"].set("id", str(comp.id))
|
||||
|
||||
def create_children(node):
|
||||
for child in node["children"]:
|
||||
create(child, node["id"])
|
||||
create_children(child)
|
||||
|
||||
try:
|
||||
create(tree, parent_id)
|
||||
except Exception as e:
|
||||
_fail(f"The device rejected the add of '{tree['name']}': {e}")
|
||||
return False, False
|
||||
|
||||
try:
|
||||
create_children(tree)
|
||||
for from_end, from_slot, to_end, to_slot in snapshot["links"]:
|
||||
client.link(_link_end_id(from_end), from_slot, _link_end_id(to_end), to_slot)
|
||||
|
||||
# The add put it last; the device's order is the order execution runs in.
|
||||
parent = client.cache_get(parent_id)
|
||||
wanted = [_link_end_id(end) for end in snapshot["siblings"]]
|
||||
if (parent is not None and set(wanted) == set(parent.children)
|
||||
and wanted != parent.children):
|
||||
client.reorder(parent_id, wanted)
|
||||
complete = True
|
||||
except Exception as e:
|
||||
_fail(f"'{tree['name']}' is back on the device, but not all of it: {e}")
|
||||
complete = False
|
||||
|
||||
ds.after_add(tree["id"])
|
||||
return True, complete
|
||||
|
||||
|
||||
def duplicate_subtrees(plans, links, build_config):
|
||||
"""Adds copies of read_subtree trees to the device, then the links among them.
|
||||
|
||||
`plans` is a list of (parent_element, node, new_name, new_meta): the node is a
|
||||
read_subtree tree, copied under `parent_element` with its top renamed and moved to
|
||||
`new_meta` (None keeps it where it was). Children keep their names, which stay
|
||||
unique because their parent is new. `links` holds (from_id, from_slot, to_id,
|
||||
to_slot) between components in the trees, by their original ids.
|
||||
|
||||
Nothing in a node is changed; the device hands out new ids for the copies.
|
||||
Returns (created, new_ids, complete): `created` lists (parent_element, new_top_id)
|
||||
for every top the device took, `new_ids` maps each original id to its copy's, and
|
||||
when not complete, last_error says what is missing.
|
||||
"""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return [], {}, False
|
||||
client = ds.sox_client
|
||||
new_ids = {}
|
||||
created = []
|
||||
|
||||
def create(node, under_id, name, meta):
|
||||
values = dict(node["values"])
|
||||
if meta is not None:
|
||||
values["meta"] = meta
|
||||
config = build_config(node["type_name"], device_values=values)
|
||||
comp = client.add(under_id, node["kit_id"], node["type_id"], name, config)
|
||||
new_ids[node["id"]] = comp.id
|
||||
for child in node["children"]:
|
||||
create(child, comp.id, child["name"], None)
|
||||
|
||||
complete = True
|
||||
for parent_element, node, new_name, new_meta in plans:
|
||||
parent_id = _comp_id(parent_element)
|
||||
if parent_id is None:
|
||||
_fail(f"The parent '{parent_element.get('name', '?')}' has no component id.")
|
||||
complete = False
|
||||
break
|
||||
try:
|
||||
create(node, parent_id, new_name, new_meta)
|
||||
except Exception as e:
|
||||
if node["id"] in new_ids:
|
||||
created.append((parent_element, new_ids[node["id"]]))
|
||||
_fail(f"'{new_name}' was added to the device, but not all of it: {e}")
|
||||
else:
|
||||
_fail(f"The device rejected the add of '{new_name}': {e}")
|
||||
complete = False
|
||||
break
|
||||
created.append((parent_element, new_ids[node["id"]]))
|
||||
|
||||
if complete:
|
||||
for from_id, from_slot, to_id, to_slot in links:
|
||||
try:
|
||||
client.link(new_ids[from_id], from_slot, new_ids[to_id], to_slot)
|
||||
except Exception as e:
|
||||
_fail(f"The copies are on the device, but not every link between them: {e}")
|
||||
complete = False
|
||||
break
|
||||
|
||||
top_ids = {top_id for _, top_id in created}
|
||||
for top_id in top_ids:
|
||||
ds.after_add(top_id)
|
||||
# after_add reads only the top; the children's values are read too, so the DOM
|
||||
# built from the cache carries their slots.
|
||||
children = [client.cache_get(i) for i in new_ids.values() if i not in top_ids]
|
||||
children = [c for c in children if c is not None]
|
||||
if children:
|
||||
try:
|
||||
client.update(children, CONFIG)
|
||||
except Exception:
|
||||
pass
|
||||
return created, new_ids, complete
|
||||
|
||||
|
||||
def wire_value(type_id, raw):
|
||||
"""A value as the device sent it, in the form encode_value wants for `type_id`."""
|
||||
if type_id == BUF_ID:
|
||||
return bytes(raw) if isinstance(raw, (bytes, bytearray)) else str(raw).encode("utf-8")
|
||||
if type_id == STR_ID:
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
return bytes(raw).decode("utf-8", errors="replace").rstrip("\0")
|
||||
return str(raw)
|
||||
return _coerce_value(type_id, str(raw))
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _comp_id(element):
|
||||
if element.tag == "app":
|
||||
return 0
|
||||
cid = element.get("id")
|
||||
if cid is None:
|
||||
return None
|
||||
try:
|
||||
return int(cid)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _find_slot(ds, comp_id, slot_name):
|
||||
"""{name, id, type_id} for a slot of a cached component, or None with last_error set.
|
||||
|
||||
A Buf goes over the wire as a Buf only when it is binary; an asStr Buf travels as a
|
||||
Str, which is how the rest of the editor already reads and writes it.
|
||||
"""
|
||||
comp = ds.sox_client.cache_get(comp_id)
|
||||
if comp is None:
|
||||
return _fail(f"Component {comp_id} is not loaded from the device.")
|
||||
type_name = ds.sox_client.type_name_map.get((comp.kit_id, comp.type_id))
|
||||
if type_name and type_name in app_state.final_components_registry:
|
||||
for s in app_state.final_components_registry[type_name]:
|
||||
if s["name"] == slot_name:
|
||||
slot_type = s.get("type") or "int"
|
||||
as_str = (s.get("facets") or {}).get("asStr") is True
|
||||
return {"name": s["name"], "id": s["slot_id"],
|
||||
"type_id": _wire_type(slot_type, as_str)}
|
||||
return _fail(f"{type_name} has no slot '{slot_name}'.")
|
||||
slots = ds.sox_client.slot_registry.get((comp.kit_id, comp.type_id))
|
||||
if slots is None:
|
||||
return _fail(f"No kit manifest describes {type_name or 'this component'}'s slots, "
|
||||
"so the slot cannot be addressed on the device.")
|
||||
for s in slots:
|
||||
if s["name"] == slot_name:
|
||||
return {"name": s["name"], "id": s["id"],
|
||||
"type_id": _wire_type(s.get("type_name") or "int", s.get("is_as_str"))}
|
||||
return _fail(f"{type_name or 'This component'} has no slot '{slot_name}'.")
|
||||
|
||||
|
||||
def _wire_type(slot_type, as_str):
|
||||
if slot_type in ("sys::Buf", "Buf", "buf") and not as_str:
|
||||
return BUF_ID
|
||||
return type_id_for_slot(slot_type)
|
||||
|
||||
|
||||
def _kit_id(ds, kit_name):
|
||||
if ds.sox_client.schema_parts:
|
||||
for i, part in enumerate(ds.sox_client.schema_parts):
|
||||
if part.name == kit_name:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def _type_id(ds, kit_id, type_name):
|
||||
if kit_id is None:
|
||||
return None
|
||||
for (kid, tid), full_name in ds.sox_client.type_name_map.items():
|
||||
if kid == kit_id and full_name.endswith(f"::{type_name}"):
|
||||
return tid
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_value(type_id, value_str):
|
||||
"""The Python value encode_value wants for `value_str` in document form.
|
||||
|
||||
Raises ValueError (or binascii.Error) when the text does not fit the type.
|
||||
"""
|
||||
from sox import BOOL_ID, BYTE_ID, SHORT_ID, INT_ID, LONG_ID, FLOAT_ID, DOUBLE_ID, STR_ID
|
||||
if type_id == BOOL_ID:
|
||||
return value_str.lower() in ("true", "1", "yes")
|
||||
elif type_id in (BYTE_ID, SHORT_ID, INT_ID, LONG_ID):
|
||||
try:
|
||||
return int(value_str)
|
||||
except ValueError:
|
||||
return int(value_str, 0) # meta and flags can be written as 0x...
|
||||
elif type_id in (FLOAT_ID, DOUBLE_ID):
|
||||
return float(value_str)
|
||||
elif type_id == STR_ID:
|
||||
return str(value_str)
|
||||
elif type_id == BUF_ID:
|
||||
if isinstance(value_str, (bytes, bytearray)):
|
||||
return bytes(value_str)
|
||||
return base64.b64decode(value_str or "", validate=True) # the .sax form
|
||||
return value_str
|
||||
+246
-153
@@ -15,8 +15,10 @@ declared can be unticked, which turns its Action into REMOVE. An installed kit t
|
||||
declared starts unticked, and ticking it adds it at its newest build.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
import app_state
|
||||
import palette
|
||||
@@ -35,8 +37,14 @@ TICK_BLOCKS = [
|
||||
(2, 5), (3, 6), (4, 7), (5, 6), (6, 5), (7, 4), (8, 3), (9, 2),
|
||||
]
|
||||
|
||||
# The cross (X), as filled 2x2 blocks: two diagonal strokes.
|
||||
CROSS_BLOCKS = [
|
||||
(2, 2), (3, 3), (4, 4), (5, 5), (6, 6), (7, 7), (8, 8), (9, 9),
|
||||
(9, 2), (8, 3), (7, 4), (6, 5), (5, 6), (4, 7), (3, 8), (2, 9),
|
||||
]
|
||||
|
||||
def make_checkbox_image(checked, enabled):
|
||||
|
||||
def make_checkbox_image(checked, enabled, cross=False):
|
||||
"""A checkbox drawn pixel by pixel, so a locked tick can be greyed out.
|
||||
|
||||
Tk has no disabled state for a cell, and a row tag would grey the whole row including
|
||||
@@ -54,7 +62,10 @@ def make_checkbox_image(checked, enabled):
|
||||
image.put(border, to=(0, 0, 1, CHECKBOX_SIZE))
|
||||
image.put(border, to=(CHECKBOX_SIZE - 1, 0, CHECKBOX_SIZE, CHECKBOX_SIZE))
|
||||
|
||||
if checked:
|
||||
if cross:
|
||||
for x, y in CROSS_BLOCKS:
|
||||
image.put("#b04040", to=(x, y, x + 2, y + 2))
|
||||
elif checked:
|
||||
for x, y in TICK_BLOCKS:
|
||||
image.put(ink, to=(x, y, x + 2, y + 2))
|
||||
|
||||
@@ -71,6 +82,11 @@ def column_label(version, checksum):
|
||||
return version if version else checksum
|
||||
|
||||
|
||||
def same_checksum(a, b):
|
||||
"""Whether two checksums name the same build, whatever their zero-padding."""
|
||||
return bool(a) and bool(b) and a.lower().lstrip("0") == b.lower().lstrip("0")
|
||||
|
||||
|
||||
def action_options(kit_name, current_checksum, is_declared, builds):
|
||||
"""The Action dropdown for one kit: (label, checksum to apply) pairs, no-op first.
|
||||
|
||||
@@ -80,7 +96,7 @@ def action_options(kit_name, current_checksum, is_declared, builds):
|
||||
"""
|
||||
installed_version = None
|
||||
for version, checksum in builds:
|
||||
if checksum == current_checksum:
|
||||
if same_checksum(checksum, current_checksum):
|
||||
installed_version = version
|
||||
break
|
||||
|
||||
@@ -98,7 +114,7 @@ def action_options(kit_name, current_checksum, is_declared, builds):
|
||||
installed_rank = palette.version_sort_key(installed_version) if installed_version else None
|
||||
|
||||
for version, checksum in builds:
|
||||
if is_declared and checksum == current_checksum:
|
||||
if is_declared and same_checksum(checksum, current_checksum):
|
||||
continue
|
||||
|
||||
label = build_label(version, checksum)
|
||||
@@ -132,6 +148,10 @@ def collect_rows():
|
||||
# clearest place to see that the file points at something missing.
|
||||
others = [name for name in installed if name not in declared_names]
|
||||
|
||||
bad = palette.unsupported_kits()
|
||||
compatible = [name for name in others if name not in bad]
|
||||
incompatible = [name for name in others if name in bad]
|
||||
|
||||
groups = [
|
||||
("Installed kits", [
|
||||
(name, checksum) for name, checksum in declared if name in required
|
||||
@@ -139,7 +159,8 @@ def collect_rows():
|
||||
("Installed but not used kits", [
|
||||
(name, checksum) for name, checksum in declared if name not in required
|
||||
]),
|
||||
("Kits to be installed", [(name, None) for name in others]),
|
||||
("Kits available on the computer", [(name, None) for name in compatible]),
|
||||
("Not supported", [(name, None) for name in incompatible]),
|
||||
]
|
||||
|
||||
rows = []
|
||||
@@ -151,6 +172,7 @@ def collect_rows():
|
||||
builds = palette.kit_builds(name)
|
||||
latest = builds[0] if builds else None
|
||||
is_declared = name in declared_names
|
||||
is_unsupported = name in bad
|
||||
options = action_options(name, checksum, is_declared, builds)
|
||||
|
||||
if not is_declared:
|
||||
@@ -159,13 +181,14 @@ def collect_rows():
|
||||
installed_text = NO_CHECKSUM
|
||||
else:
|
||||
installed_text = column_label(
|
||||
next((v for v, c in builds if c == checksum), ""), checksum
|
||||
next((v for v, c in builds if same_checksum(c, checksum)), ""), checksum
|
||||
)
|
||||
|
||||
rows.append({
|
||||
"kit": name,
|
||||
"declared": is_declared,
|
||||
"required": name in required,
|
||||
"unsupported": is_unsupported,
|
||||
"checksum": checksum,
|
||||
"builds": builds,
|
||||
"latest": latest,
|
||||
@@ -173,7 +196,6 @@ def collect_rows():
|
||||
"latest_text": column_label(*latest) if latest else NOT_DECLARED,
|
||||
"options": options,
|
||||
"choice": 0,
|
||||
# The tick reflects the file as it stands: declared kits start ticked.
|
||||
"checked": is_declared,
|
||||
})
|
||||
return rows
|
||||
@@ -203,128 +225,212 @@ def describe_changes(changes):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def open_dialog(on_applied=None):
|
||||
"""Opens the kit table. ``on_applied`` is called after the schema really changed."""
|
||||
window = app_state.window
|
||||
class KitsPanel:
|
||||
"""The kit table, built as an embedded panel in the editor pane."""
|
||||
|
||||
if app_state.xml_root_element is None:
|
||||
messagebox.showinfo("Manage Kits", "Open a SAX file first.")
|
||||
return
|
||||
if app_state.xml_root_element.find("schema") is None:
|
||||
messagebox.showwarning("Manage Kits", "This file has no <schema> block to manage.")
|
||||
return
|
||||
def __init__(self, parent, on_applied, on_close):
|
||||
self.on_applied = on_applied
|
||||
self.on_close = on_close
|
||||
|
||||
dialog = tk.Toplevel(window)
|
||||
dialog.title("Kits")
|
||||
dialog.transient(window)
|
||||
dialog.grab_set()
|
||||
dialog.geometry(f"820x520+{window.winfo_x() + 60}+{window.winfo_y() + 40}")
|
||||
dialog.minsize(640, 320)
|
||||
self.frame = ttk.Frame(parent, padding=10)
|
||||
|
||||
frame = ttk.Frame(dialog, padding=10)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
COLUMNS = ("name", "installed", "latest", "action")
|
||||
|
||||
COLUMNS = ("name", "installed", "latest", "action")
|
||||
# 'tree headings' keeps the icon column, which is the only place a real checkbox image
|
||||
# can live; the data columns follow it.
|
||||
table = ttk.Treeview(frame, columns=COLUMNS, show="tree headings", selectmode="browse")
|
||||
table.heading("#0", text="")
|
||||
table.column("#0", width=32, minwidth=32, stretch=False, anchor="center")
|
||||
for column, heading, width, anchor in [
|
||||
("name", "Kit name", 200, "w"),
|
||||
("installed", "Installed", 105, "w"),
|
||||
("latest", "Latest", 105, "w"),
|
||||
("action", "Action", 300, "w"),
|
||||
]:
|
||||
table.heading(column, text=heading, anchor=anchor)
|
||||
table.column(column, width=width, anchor=anchor, stretch=(column == "action"))
|
||||
table_frame = ttk.Frame(self.frame)
|
||||
table_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=table.yview)
|
||||
table.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
table.pack(fill=tk.BOTH, expand=True)
|
||||
self.table = ttk.Treeview(
|
||||
table_frame, columns=COLUMNS, show="tree headings", selectmode="browse",
|
||||
)
|
||||
self.table.heading("#0", text="")
|
||||
self.table.column("#0", width=32, minwidth=32, stretch=False, anchor="center")
|
||||
for column, heading, width, anchor in [
|
||||
("name", "Kit name", 190, "w"),
|
||||
("installed", "Installed", 105, "w"),
|
||||
("latest", "Latest", 105, "w"),
|
||||
("action", "Action", 195, "w"),
|
||||
]:
|
||||
self.table.heading(column, text=heading, anchor=anchor)
|
||||
self.table.column(column, width=width, minwidth=width, anchor=anchor, stretch=False)
|
||||
|
||||
table.tag_configure(SECTION_TAG, background="#dcdcdc")
|
||||
vscroll = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.table.yview)
|
||||
hscroll = ttk.Scrollbar(table_frame, orient=tk.HORIZONTAL, command=self.table.xview)
|
||||
self.table.configure(yscrollcommand=vscroll.set, xscrollcommand=hscroll.set)
|
||||
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
hscroll.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
self.table.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Held on the dialog: a PhotoImage is dropped as soon as nothing references it.
|
||||
boxes = {
|
||||
(True, True): make_checkbox_image(True, True),
|
||||
(False, True): make_checkbox_image(False, True),
|
||||
(True, False): make_checkbox_image(True, False),
|
||||
}
|
||||
self.table.tag_configure(SECTION_TAG, background="#dcdcdc")
|
||||
|
||||
hint = ttk.Label(
|
||||
frame,
|
||||
text="A tick means the schema declares the kit. Grey ticks are needed by the app "
|
||||
"and cannot be removed; untick another to remove it. Click the Action cell to "
|
||||
"choose a build. Nothing reaches the file until you save it.",
|
||||
foreground="#666666",
|
||||
wraplength=780,
|
||||
)
|
||||
hint.pack(anchor="w", pady=(8, 0))
|
||||
self.boxes = {
|
||||
(True, True): make_checkbox_image(True, True),
|
||||
(False, True): make_checkbox_image(False, True),
|
||||
(True, False): make_checkbox_image(True, False),
|
||||
(False, False): make_checkbox_image(False, False),
|
||||
}
|
||||
self.cross_box = make_checkbox_image(False, False, cross=True)
|
||||
|
||||
button_row = ttk.Frame(frame)
|
||||
button_row.pack(fill=tk.X, pady=(10, 0))
|
||||
hint = ttk.Label(
|
||||
self.frame,
|
||||
text="A tick means the schema declares the kit. Grey ticks are needed by the app "
|
||||
"and cannot be removed; untick another to remove it. Click the Action cell to "
|
||||
"choose a build. Nothing reaches the file until you save it.",
|
||||
foreground="#666666",
|
||||
wraplength=780,
|
||||
)
|
||||
hint.pack(anchor="w", pady=(8, 0))
|
||||
|
||||
rows = [] # row dicts, table order
|
||||
row_by_item = {} # Treeview item id -> row dict
|
||||
editor = {"widget": None}
|
||||
button_row = ttk.Frame(self.frame)
|
||||
button_row.pack(fill=tk.X, pady=(10, 0))
|
||||
|
||||
def close_editor(_event=None):
|
||||
if editor["widget"] is not None:
|
||||
editor["widget"].destroy()
|
||||
editor["widget"] = None
|
||||
ttk.Button(
|
||||
button_row, text="Upgrade all kits to latest version", command=self._upgrade_all,
|
||||
).pack(side=tk.LEFT)
|
||||
ttk.Button(
|
||||
button_row, text="Update kits", command=self._update_marked,
|
||||
).pack(side=tk.LEFT, padx=6)
|
||||
ttk.Button(button_row, text="Close", command=self.close).pack(side=tk.RIGHT)
|
||||
ttk.Button(
|
||||
button_row, text="Export as XLS", command=self._export_xls,
|
||||
).pack(side=tk.RIGHT, padx=6)
|
||||
|
||||
def action_text(row):
|
||||
"""What the Action cell says, which an untick overrides with REMOVE."""
|
||||
self.rows = []
|
||||
self.row_by_item = {}
|
||||
self._editor_widget = None
|
||||
|
||||
self.table.bind("<Button-1>", self._on_click)
|
||||
self.table.bind("<MouseWheel>", lambda _e: self._close_editor(), add="+")
|
||||
self.table.bind("<Configure>", lambda _e: self._close_editor(), add="+")
|
||||
|
||||
self._rebuild_table()
|
||||
|
||||
def close(self):
|
||||
if self.on_close is not None:
|
||||
self.on_close()
|
||||
|
||||
def destroy(self):
|
||||
self._close_editor()
|
||||
self.frame.destroy()
|
||||
|
||||
def _export_xls(self):
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
except ImportError:
|
||||
messagebox.showerror("Export", "openpyxl is not installed.")
|
||||
return
|
||||
|
||||
default_name = ""
|
||||
if app_state.current_file_path:
|
||||
base = os.path.splitext(os.path.basename(app_state.current_file_path))[0]
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
default_name = f"{base}_kits_{stamp}.xlsx"
|
||||
|
||||
path = filedialog.asksaveasfilename(
|
||||
title="Export Kit Table",
|
||||
defaultextension=".xlsx",
|
||||
initialfile=default_name,
|
||||
filetypes=[("Excel Files", "*.xlsx")],
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Kits"
|
||||
|
||||
headers = ["", "Kit name", "Installed", "Latest", "Action"]
|
||||
header_font = Font(bold=True)
|
||||
section_fill = PatternFill(start_color="DCDCDC", end_color="DCDCDC", fill_type="solid")
|
||||
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=header)
|
||||
cell.font = header_font
|
||||
|
||||
row_num = 2
|
||||
for row in self.rows:
|
||||
if "section" in row:
|
||||
cell = ws.cell(row=row_num, column=2, value=row["section"])
|
||||
cell.font = Font(bold=True)
|
||||
for col in range(1, 6):
|
||||
ws.cell(row=row_num, column=col).fill = section_fill
|
||||
row_num += 1
|
||||
continue
|
||||
|
||||
check = "✓" if row["checked"] else ""
|
||||
ws.cell(row=row_num, column=1, value=check).alignment = Alignment(horizontal="center")
|
||||
ws.cell(row=row_num, column=2, value=row["kit"])
|
||||
ws.cell(row=row_num, column=3, value=row["installed_text"])
|
||||
ws.cell(row=row_num, column=4, value=row["latest_text"])
|
||||
ws.cell(row=row_num, column=5, value=self._action_text(row))
|
||||
row_num += 1
|
||||
|
||||
ws.column_dimensions["A"].width = 4
|
||||
ws.column_dimensions["B"].width = 28
|
||||
ws.column_dimensions["C"].width = 16
|
||||
ws.column_dimensions["D"].width = 16
|
||||
ws.column_dimensions["E"].width = 30
|
||||
|
||||
try:
|
||||
wb.save(path)
|
||||
app_state.set_status(f"Kit table exported to {os.path.basename(path)}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Export", f"Could not save file:\n{e}")
|
||||
|
||||
def _close_editor(self):
|
||||
if self._editor_widget is not None:
|
||||
self._editor_widget.destroy()
|
||||
self._editor_widget = None
|
||||
|
||||
def _action_text(self, row):
|
||||
if row["declared"] and not row["checked"]:
|
||||
return REMOVE_ACTION
|
||||
return row["options"][row["choice"]][0]
|
||||
|
||||
def row_values(row):
|
||||
return (row["kit"], row["installed_text"], row["latest_text"], action_text(row))
|
||||
def _row_values(self, row):
|
||||
return (row["kit"], row["installed_text"], row["latest_text"], self._action_text(row))
|
||||
|
||||
def row_image(row):
|
||||
return boxes[(row["checked"], not row["required"])]
|
||||
def _row_image(self, row):
|
||||
if row.get("unsupported"):
|
||||
return self.cross_box
|
||||
return self.boxes[(row["checked"], not row["required"])]
|
||||
|
||||
def rebuild_table():
|
||||
close_editor()
|
||||
for item in table.get_children():
|
||||
table.delete(item)
|
||||
row_by_item.clear()
|
||||
def _rebuild_table(self):
|
||||
self._close_editor()
|
||||
for item in self.table.get_children():
|
||||
self.table.delete(item)
|
||||
self.row_by_item.clear()
|
||||
|
||||
rows[:] = collect_rows()
|
||||
for row in rows:
|
||||
self.rows[:] = collect_rows()
|
||||
for row in self.rows:
|
||||
if "section" in row:
|
||||
table.insert("", "end", values=(row["section"], "", "", ""), tags=(SECTION_TAG,))
|
||||
self.table.insert(
|
||||
"", "end", values=(row["section"], "", "", ""), tags=(SECTION_TAG,),
|
||||
)
|
||||
continue
|
||||
item = table.insert("", "end", image=row_image(row), values=row_values(row))
|
||||
row_by_item[item] = row
|
||||
item = self.table.insert(
|
||||
"", "end", image=self._row_image(row), values=self._row_values(row),
|
||||
)
|
||||
self.row_by_item[item] = row
|
||||
|
||||
def refresh_row(item):
|
||||
row = row_by_item[item]
|
||||
table.item(item, image=row_image(row), values=row_values(row))
|
||||
def _refresh_row(self, item):
|
||||
row = self.row_by_item[item]
|
||||
self.table.item(item, image=self._row_image(row), values=self._row_values(row))
|
||||
|
||||
def set_choice(item, index):
|
||||
row = row_by_item[item]
|
||||
def _set_choice(self, item, index):
|
||||
row = self.row_by_item[item]
|
||||
row["choice"] = index
|
||||
|
||||
# Naming a build is the user saying they want the kit at it, so the row ticks
|
||||
# itself — and for a declared row that also cancels a pending removal.
|
||||
if row["declared"] or row["options"][index][1] is not None:
|
||||
row["checked"] = True
|
||||
refresh_row(item)
|
||||
self._refresh_row(item)
|
||||
|
||||
def toggle_check(item):
|
||||
row = row_by_item[item]
|
||||
if row["required"]:
|
||||
# Needed by the app: the tick is fixed, and the box is drawn greyed to say so.
|
||||
def _toggle_check(self, item):
|
||||
row = self.row_by_item[item]
|
||||
if row["required"] or row.get("unsupported"):
|
||||
return
|
||||
|
||||
row["checked"] = not row["checked"]
|
||||
if not row["declared"]:
|
||||
# Ticking a kit that is not in the schema means adding it, at its newest build
|
||||
# unless the user picks another; unticking puts the row back to doing nothing.
|
||||
row["choice"] = 1 if (row["checked"] and len(row["options"]) > 1) else 0
|
||||
if row["checked"] and len(row["options"]) < 2:
|
||||
row["checked"] = False
|
||||
@@ -332,76 +438,70 @@ def open_dialog(on_applied=None):
|
||||
"Manage Kits",
|
||||
f"Sedona home holds no manifest for '{row['kit']}', so there is no "
|
||||
"build to add it at.",
|
||||
parent=dialog
|
||||
)
|
||||
refresh_row(item)
|
||||
self._refresh_row(item)
|
||||
|
||||
def edit_action(item):
|
||||
row = row_by_item[item]
|
||||
cell = table.bbox(item, "action")
|
||||
def _edit_action(self, item):
|
||||
row = self.row_by_item[item]
|
||||
if row.get("unsupported"):
|
||||
return
|
||||
cell = self.table.bbox(item, "action")
|
||||
if not cell:
|
||||
return
|
||||
|
||||
labels = [label for label, _ in row["options"]]
|
||||
current = action_text(row)
|
||||
current = self._action_text(row)
|
||||
variable = tk.StringVar(value=current if current in labels else labels[row["choice"]])
|
||||
combo = ttk.Combobox(table, textvariable=variable, values=labels, state="readonly")
|
||||
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])
|
||||
editor["widget"] = combo
|
||||
self._editor_widget = combo
|
||||
|
||||
def commit(_event=None):
|
||||
if variable.get() in labels:
|
||||
set_choice(item, labels.index(variable.get()))
|
||||
close_editor()
|
||||
self._set_choice(item, labels.index(variable.get()))
|
||||
self._close_editor()
|
||||
|
||||
combo.bind("<<ComboboxSelected>>", commit)
|
||||
combo.bind("<Escape>", lambda event: close_editor())
|
||||
combo.bind("<Escape>", lambda _e: self._close_editor())
|
||||
combo.focus_set()
|
||||
# A single click on the cell should show the builds, not just a collapsed box.
|
||||
combo.after_idle(lambda: combo.event_generate("<Down>"))
|
||||
|
||||
def on_click(event):
|
||||
close_editor()
|
||||
if table.identify_region(event.x, event.y) not in ("tree", "cell"):
|
||||
def _on_click(self, event):
|
||||
self._close_editor()
|
||||
if self.table.identify_region(event.x, event.y) not in ("tree", "cell"):
|
||||
return
|
||||
item = table.identify_row(event.y)
|
||||
if item not in row_by_item:
|
||||
item = self.table.identify_row(event.y)
|
||||
if item not in self.row_by_item:
|
||||
return
|
||||
|
||||
column = table.identify_column(event.x)
|
||||
column = self.table.identify_column(event.x)
|
||||
if column == "#0":
|
||||
toggle_check(item)
|
||||
self._toggle_check(item)
|
||||
elif column == "#4":
|
||||
edit_action(item)
|
||||
self._edit_action(item)
|
||||
|
||||
table.bind("<Button-1>", on_click)
|
||||
table.bind("<MouseWheel>", close_editor, add="+")
|
||||
table.bind("<Configure>", close_editor, add="+")
|
||||
|
||||
def apply_changes(selections, removals, label, nothing_message, note=""):
|
||||
def _apply_changes(self, selections, removals, label, nothing_message, note=""):
|
||||
if not selections and not removals:
|
||||
messagebox.showinfo("Manage Kits", nothing_message, parent=dialog)
|
||||
messagebox.showinfo("Manage Kits", nothing_message)
|
||||
return
|
||||
|
||||
changes, error = sax_file.apply_kit_selections(selections, removals, label=label)
|
||||
if error:
|
||||
messagebox.showerror("Kits unchanged", error, parent=dialog)
|
||||
rebuild_table()
|
||||
messagebox.showerror("Kits unchanged", error)
|
||||
self._rebuild_table()
|
||||
return
|
||||
|
||||
if not changes:
|
||||
messagebox.showinfo(
|
||||
"Manage Kits", "The schema already reads that way.", parent=dialog
|
||||
)
|
||||
messagebox.showinfo("Manage Kits", "The schema already reads that way.")
|
||||
return
|
||||
|
||||
rebuild_table()
|
||||
if on_applied is not None:
|
||||
on_applied()
|
||||
self._rebuild_table()
|
||||
if self.on_applied is not None:
|
||||
self.on_applied()
|
||||
|
||||
app_state.set_status(
|
||||
f"schema kits changed ({len(changes)}) — save the file to keep it.",
|
||||
warn=True
|
||||
warn=True,
|
||||
)
|
||||
messagebox.showinfo(
|
||||
"Kits updated",
|
||||
@@ -409,17 +509,15 @@ def open_dialog(on_applied=None):
|
||||
+ (note + "\n\n" if note else "")
|
||||
+ "Save the file to write this to disk. The device's scode must hold these "
|
||||
"builds or the application will not run.",
|
||||
parent=dialog
|
||||
)
|
||||
|
||||
def upgrade_all():
|
||||
"""Every kit the schema declares, moved to the newest build Sedona home holds."""
|
||||
def _upgrade_all(self):
|
||||
selections = []
|
||||
for row in rows:
|
||||
for row in self.rows:
|
||||
if "section" in row or not row["declared"] or not row["latest"]:
|
||||
continue
|
||||
latest_checksum = row["latest"][1]
|
||||
if latest_checksum != row["checksum"]:
|
||||
if not same_checksum(latest_checksum, row["checksum"]):
|
||||
selections.append((row["kit"], latest_checksum))
|
||||
|
||||
if selections:
|
||||
@@ -432,22 +530,20 @@ def open_dialog(on_applied=None):
|
||||
f"{len(selections)} kit{'' if len(selections) == 1 else 's'} in this app's "
|
||||
f"schema will move to their latest installed build:\n\n{preview}\n\nContinue?",
|
||||
icon=messagebox.WARNING,
|
||||
parent=dialog
|
||||
)
|
||||
if not proceed:
|
||||
return
|
||||
|
||||
apply_changes(
|
||||
self._apply_changes(
|
||||
selections, [],
|
||||
"upgrade all kits",
|
||||
"Every kit in the schema is already at its latest installed build."
|
||||
"Every kit in the schema is already at its latest installed build.",
|
||||
)
|
||||
|
||||
def update_marked():
|
||||
"""The ticks and the Action column, applied: repins, additions and removals."""
|
||||
def _update_marked(self):
|
||||
selections = []
|
||||
removals = []
|
||||
for row in row_by_item.values():
|
||||
for row in self.row_by_item.values():
|
||||
if row["declared"] and not row["checked"]:
|
||||
removals.append(row["kit"])
|
||||
continue
|
||||
@@ -464,17 +560,14 @@ def open_dialog(on_applied=None):
|
||||
"installed in Sedona home can be added back here."
|
||||
)
|
||||
|
||||
apply_changes(
|
||||
self._apply_changes(
|
||||
selections, removals,
|
||||
"update kits",
|
||||
"Nothing is marked for a change. Pick a build in a kit's Action column, tick "
|
||||
"a kit to add it, or untick one to remove it."
|
||||
"a kit to add it, or untick one to remove it.",
|
||||
)
|
||||
|
||||
ttk.Button(
|
||||
button_row, text="Upgrade all kits to latest version", command=upgrade_all
|
||||
).pack(side=tk.LEFT)
|
||||
ttk.Button(button_row, text="Update kits", command=update_marked).pack(side=tk.LEFT, padx=6)
|
||||
ttk.Button(button_row, text="Close", command=dialog.destroy).pack(side=tk.RIGHT)
|
||||
|
||||
rebuild_table()
|
||||
def build_kits_panel(parent, on_applied, on_close):
|
||||
"""Factory matching the pattern of properties.build_properties_panel."""
|
||||
return KitsPanel(parent, on_applied=on_applied, on_close=on_close)
|
||||
|
||||
+786
@@ -0,0 +1,786 @@
|
||||
"""WYSIWYG HMI page editor for ontrolOx::OxPage components.
|
||||
|
||||
Renders the device screen canvas at its native resolution and places the child
|
||||
widgets (ontrolOx::Widget subclasses) by their x, y, width, height slots.
|
||||
Supports selection, drag-move, and a synced widget tree.
|
||||
|
||||
Device resolution is detected from the schema kits:
|
||||
ontrolOrion → 800×480, or 480×800 when its platform service's screenOrientation
|
||||
is portrait (drawn in frame_orion_portrait.png)
|
||||
ontrolRion → 320×240
|
||||
|
||||
A device frame image (the physical bezel) sits behind the screen area when the
|
||||
corresponding PNG is found in the assets/ directory next to this module.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont, ttk
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageTk
|
||||
_HAS_PIL = True
|
||||
except ImportError:
|
||||
_HAS_PIL = False
|
||||
|
||||
import app_state
|
||||
import live_hooks
|
||||
import undo
|
||||
from ox_widget_properties import ORION_KIT, OxWidgetPropertiesPanel, orion_portrait
|
||||
from ox_widget_render import WidgetPainter
|
||||
from sax_dom import set_prop_value
|
||||
|
||||
HEADER_BG = "#f2f2f2"
|
||||
|
||||
_ASSETS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
|
||||
|
||||
DEVICE_PROFILES = {
|
||||
"ontrolOrion": {
|
||||
"screen": (800, 480),
|
||||
"frame_file": "frame_orion.png",
|
||||
"screen_offset": (191, 169),
|
||||
"default_zoom": 0.5,
|
||||
},
|
||||
"ontrolRion": {
|
||||
"screen": (320, 240),
|
||||
"frame_file": "frame_rion.png",
|
||||
"screen_offset": (76, 62),
|
||||
"default_zoom": 1.0,
|
||||
},
|
||||
}
|
||||
ORION_PORTRAIT_PROFILE = {
|
||||
"screen": (480, 800),
|
||||
"frame_file": "frame_orion_portrait.png",
|
||||
"screen_offset": (190, 170),
|
||||
"default_zoom": 0.5,
|
||||
}
|
||||
DEFAULT_SCREEN = (800, 480)
|
||||
DEFAULT_ZOOM = 0.5
|
||||
|
||||
ZOOM_LEVELS = (0.25, 0.33, 0.5, 0.67, 0.8, 1.0, 1.25, 1.5, 2.0, 3.0)
|
||||
|
||||
CANVAS_PADDING = 16
|
||||
# About what the widget tree / properties pane asks for (300px sheet + scrollbar).
|
||||
RIGHT_PANE_WIDTH = 320
|
||||
|
||||
WIDGET_OUTLINE_WIDTH = 1
|
||||
SELECTED_OUTLINE = "#1f4e79"
|
||||
SELECTED_OUTLINE_WIDTH = 2
|
||||
HANDLE_SIZE = 6
|
||||
HANDLE_COLOUR = "#00b050"
|
||||
|
||||
_SHORT_TYPE_NAMES = {
|
||||
"NumericLabel": "NL",
|
||||
"NumericLabelSet": "NLS",
|
||||
"PushButtonLabel": "PBL",
|
||||
"PushButtonImage": "PBI",
|
||||
"BooleanLabel": "BL",
|
||||
"BooleanImage": "BI",
|
||||
"Geometry": "Geo",
|
||||
"CalibrationButton": "Cal",
|
||||
"NumericSelectImage": "NSI",
|
||||
"NumericSelectLabel": "NSL",
|
||||
"DateLabelSet": "DLS",
|
||||
"TimeLabelSet": "TLS",
|
||||
"ScheduleLabelSet": "ScL",
|
||||
}
|
||||
|
||||
|
||||
def _rgb565_to_hex(val):
|
||||
try:
|
||||
v = int(val)
|
||||
except (TypeError, ValueError):
|
||||
return "#ffffff"
|
||||
if v < 0:
|
||||
v &= 0xFFFF
|
||||
r = ((v >> 11) & 0x1F) * 255 // 31
|
||||
g = ((v >> 5) & 0x3F) * 255 // 63
|
||||
b = (v & 0x1F) * 255 // 31
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
def _get_prop(comp, name, default=None):
|
||||
prop = comp.find(f"prop[@name='{name}']")
|
||||
return prop.get("val", default) if prop is not None else default
|
||||
|
||||
|
||||
def _short_type(full_type):
|
||||
short = full_type.rsplit("::", 1)[-1] if "::" in full_type else full_type
|
||||
return _SHORT_TYPE_NAMES.get(short, short)
|
||||
|
||||
|
||||
def _detect_device_profile():
|
||||
for kit_name, profile in DEVICE_PROFILES.items():
|
||||
if kit_name in app_state.active_schema_kits:
|
||||
if kit_name == ORION_KIT and orion_portrait():
|
||||
return ORION_PORTRAIT_PROFILE
|
||||
return profile
|
||||
return None
|
||||
|
||||
|
||||
def _detect_screen_size():
|
||||
profile = _detect_device_profile()
|
||||
return profile["screen"] if profile else DEFAULT_SCREEN
|
||||
|
||||
|
||||
def _load_frame_image(profile):
|
||||
if profile is None:
|
||||
return None, None
|
||||
path = os.path.join(_ASSETS_DIR, profile["frame_file"])
|
||||
if not os.path.isfile(path):
|
||||
return None, None
|
||||
if _HAS_PIL:
|
||||
try:
|
||||
pil_img = Image.open(path).convert("RGBA")
|
||||
tk_img = ImageTk.PhotoImage(pil_img)
|
||||
return pil_img, tk_img
|
||||
except Exception:
|
||||
return None, None
|
||||
try:
|
||||
return None, tk.PhotoImage(file=path)
|
||||
except tk.TclError:
|
||||
return None, None
|
||||
|
||||
|
||||
def _is_ox_widget(comp):
|
||||
comp_type = comp.get("type", "")
|
||||
return comp_type.startswith("ontrolOx::") and comp_type != "ontrolOx::OxPage"
|
||||
|
||||
|
||||
def _get_widget_children(page_comp):
|
||||
return [c for c in page_comp if c.tag == "comp" and _is_ox_widget(c)]
|
||||
|
||||
|
||||
CANVAS_BG = "#e0e0e0"
|
||||
|
||||
GRID_STEP = 10 # device pixels
|
||||
GRID_MIN_SPACING = 4 # canvas pixels; a denser grid is left out rather than drawn solid
|
||||
GRID_SHADE = 0.82 # grid line = page colour scaled by this (lifted on dark pages)
|
||||
|
||||
# Page Editor toggles, held for the session like the Sedona sheet's: whether the page
|
||||
# can be changed (edit mode, which is also when the 10x10 grid is drawn), whether a
|
||||
# drag lands on the grid, and whether the tree/properties pane shows.
|
||||
edit_mode = False
|
||||
snap_to_grid = True
|
||||
show_right_pane = True
|
||||
|
||||
|
||||
def _grid_colour(page_hex):
|
||||
r, g, b = (int(page_hex[i:i + 2], 16) for i in (1, 3, 5))
|
||||
if (r * 299 + g * 587 + b * 114) / 1000 < 60:
|
||||
r, g, b = (min(255, v + 45) for v in (r, g, b))
|
||||
else:
|
||||
r, g, b = (int(v * GRID_SHADE) for v in (r, g, b))
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
class OxPageViewPanel:
|
||||
|
||||
def __init__(self, parent, comp, on_change=None, on_close=None):
|
||||
self.comp = comp
|
||||
self.on_change = on_change
|
||||
self.on_close = on_close
|
||||
self.dirty = False
|
||||
self.frame = tk.Frame(parent)
|
||||
|
||||
profile = _detect_device_profile()
|
||||
self._screen_w, self._screen_h = _detect_screen_size()
|
||||
self._frame_pil, self._frame_image = _load_frame_image(profile)
|
||||
self._scaled_frame = None
|
||||
|
||||
if self._frame_image and profile:
|
||||
self._screen_ox = profile["screen_offset"][0]
|
||||
self._screen_oy = profile["screen_offset"][1]
|
||||
frame_w = self._frame_image.width()
|
||||
frame_h = self._frame_image.height()
|
||||
self._frame_ox = CANVAS_PADDING
|
||||
self._frame_oy = CANVAS_PADDING
|
||||
self._canvas_w = frame_w + CANVAS_PADDING * 2
|
||||
self._canvas_h = frame_h + CANVAS_PADDING * 2
|
||||
else:
|
||||
self._screen_ox = 0
|
||||
self._screen_oy = 0
|
||||
self._frame_ox = 0
|
||||
self._frame_oy = 0
|
||||
self._canvas_w = self._screen_w + CANVAS_PADDING * 2
|
||||
self._canvas_h = self._screen_h + CANVAS_PADDING * 2
|
||||
|
||||
self._zoom = profile.get("default_zoom", DEFAULT_ZOOM) if profile else DEFAULT_ZOOM
|
||||
self._selected_widget = None
|
||||
self._widget_items = {}
|
||||
self._tag_to_comp = {}
|
||||
self._widget_boxes = {}
|
||||
self._drag = {"tag": None, "x": 0, "y": 0}
|
||||
|
||||
self._build_header()
|
||||
self._build_body()
|
||||
self._build_footer()
|
||||
self._apply_right_pane()
|
||||
self.refresh()
|
||||
|
||||
@property
|
||||
def _screen_canvas_x(self):
|
||||
if self._frame_image:
|
||||
return self._frame_ox + self._screen_ox
|
||||
return CANVAS_PADDING
|
||||
|
||||
@property
|
||||
def _screen_canvas_y(self):
|
||||
if self._frame_image:
|
||||
return self._frame_oy + self._screen_oy
|
||||
return CANVAS_PADDING
|
||||
|
||||
def _build_header(self):
|
||||
header = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG)
|
||||
header.pack(fill=tk.X)
|
||||
|
||||
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
|
||||
plain_font = tkfont.Font(family="Segoe UI", size=9)
|
||||
|
||||
tk.Label(header, text="Page Editor", font=name_font,
|
||||
bg=HEADER_BG).pack(side=tk.LEFT)
|
||||
|
||||
page_name = self.comp.get("name", "?")
|
||||
tk.Label(header, text=f"— {page_name} ({self._screen_w}×{self._screen_h})",
|
||||
font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.LEFT,
|
||||
padx=(8, 0))
|
||||
|
||||
self._widget_count_var = tk.StringVar(value="")
|
||||
tk.Label(header, textvariable=self._widget_count_var, font=plain_font,
|
||||
fg="#555555", bg=HEADER_BG).pack(side=tk.RIGHT)
|
||||
|
||||
self._edit_var = tk.BooleanVar(value=edit_mode)
|
||||
self._snap_var = tk.BooleanVar(value=snap_to_grid)
|
||||
self._right_pane_var = tk.BooleanVar(value=show_right_pane)
|
||||
|
||||
def _build_body(self):
|
||||
body = ttk.PanedWindow(self.frame, orient=tk.HORIZONTAL)
|
||||
body.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
canvas_frame = ttk.Frame(body)
|
||||
self._build_canvas(canvas_frame)
|
||||
body.add(canvas_frame, weight=1)
|
||||
|
||||
# Workbench's layout: the widget tree above, the selected widget's sheet below.
|
||||
side = ttk.PanedWindow(body, orient=tk.VERTICAL)
|
||||
body.add(side, weight=0)
|
||||
self._body = body
|
||||
self._side = side
|
||||
|
||||
tree_frame = ttk.Frame(side)
|
||||
self._build_widget_tree(tree_frame)
|
||||
side.add(tree_frame, weight=1)
|
||||
|
||||
self._props = OxWidgetPropertiesPanel(side, self._commit_props, self._repaint_all)
|
||||
self._props.set_editable(self._edit_var.get())
|
||||
side.add(self._props.frame, weight=3)
|
||||
|
||||
def _build_widget_tree(self, parent):
|
||||
ttk.Label(parent, text="Widgets", font=("TkDefaultFont", 9, "bold")).pack(
|
||||
anchor="w", padx=4, pady=(4, 2))
|
||||
|
||||
tree_container = ttk.Frame(parent)
|
||||
tree_container.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
|
||||
|
||||
self._tree = ttk.Treeview(tree_container, show="tree", selectmode="browse",
|
||||
height=8)
|
||||
vscroll = ttk.Scrollbar(tree_container, orient=tk.VERTICAL,
|
||||
command=self._tree.yview)
|
||||
self._tree.configure(yscrollcommand=vscroll.set)
|
||||
|
||||
tree_container.columnconfigure(0, weight=1)
|
||||
tree_container.rowconfigure(0, weight=1)
|
||||
self._tree.grid(row=0, column=0, sticky="nsew")
|
||||
vscroll.grid(row=0, column=1, sticky="ns")
|
||||
|
||||
self._tree.bind("<<TreeviewSelect>>", self._on_tree_select)
|
||||
self._tree_items = {}
|
||||
|
||||
def _build_canvas(self, parent):
|
||||
vscroll = ttk.Scrollbar(parent, orient=tk.VERTICAL)
|
||||
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
hscroll = ttk.Scrollbar(parent, orient=tk.HORIZONTAL)
|
||||
hscroll.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
|
||||
self._canvas = tk.Canvas(
|
||||
parent, bg=self._background(), highlightthickness=0,
|
||||
xscrollcommand=hscroll.set, yscrollcommand=vscroll.set,
|
||||
scrollregion=(0, 0, int(self._canvas_w * self._zoom), int(self._canvas_h * self._zoom)),
|
||||
)
|
||||
self._canvas.pack(fill=tk.BOTH, expand=True)
|
||||
vscroll.config(command=self._canvas.yview)
|
||||
hscroll.config(command=self._canvas.xview)
|
||||
|
||||
self._canvas.bind("<ButtonPress-1>", self._on_canvas_click)
|
||||
self._canvas.bind("<B1-Motion>", self._on_canvas_drag)
|
||||
self._canvas.bind("<ButtonRelease-1>", self._on_canvas_release)
|
||||
self._canvas.bind("<ButtonPress-3>", self._on_canvas_right_click)
|
||||
self._canvas.bind("<Control-MouseWheel>", self._on_wheel_zoom)
|
||||
|
||||
def _render_page(self):
|
||||
c = self._canvas
|
||||
c.delete("all")
|
||||
self._widget_items.clear()
|
||||
self._tag_to_comp.clear()
|
||||
self._widget_boxes.clear()
|
||||
z = self._zoom
|
||||
|
||||
sx = self._screen_canvas_x * z
|
||||
sy = self._screen_canvas_y * z
|
||||
|
||||
bg_val = _get_prop(self.comp, "backgroundColor", "65535")
|
||||
bg_colour = _rgb565_to_hex(bg_val)
|
||||
c.create_rectangle(sx, sy, sx + self._screen_w * z, sy + self._screen_h * z,
|
||||
fill=bg_colour, outline="",
|
||||
tags=("screen_bg",))
|
||||
|
||||
self._painter = WidgetPainter(c, sx, sy, z, bg_colour)
|
||||
self._painter.page_image(_get_prop(self.comp, "imageFileName", ""),
|
||||
self._screen_w, self._screen_h)
|
||||
if self._edit_var.get():
|
||||
self._draw_grid(sx, sy, _grid_colour(bg_colour))
|
||||
|
||||
if self._frame_image:
|
||||
if z != 1.0 and self._frame_pil is not None:
|
||||
# Rescaled once per zoom level; every edit redraws the page.
|
||||
if getattr(self, "_scaled_frame_zoom", None) != z:
|
||||
orig_w, orig_h = self._frame_pil.size
|
||||
zw = max(1, int(orig_w * z))
|
||||
zh = max(1, int(orig_h * z))
|
||||
resized = self._frame_pil.resize((zw, zh), Image.LANCZOS)
|
||||
self._scaled_frame = ImageTk.PhotoImage(resized)
|
||||
self._scaled_frame_zoom = z
|
||||
c.create_image(self._frame_ox * z, self._frame_oy * z, anchor="nw",
|
||||
image=self._scaled_frame, tags=("device_frame",))
|
||||
else:
|
||||
c.create_image(self._frame_ox, self._frame_oy, anchor="nw",
|
||||
image=self._frame_image, tags=("device_frame",))
|
||||
else:
|
||||
c.create_rectangle(sx, sy, sx + self._screen_w * z, sy + self._screen_h * z,
|
||||
fill="", outline="#333333", width=2,
|
||||
tags=("screen_border",))
|
||||
|
||||
widgets = _get_widget_children(self.comp)
|
||||
for widget in widgets:
|
||||
self._draw_widget(widget)
|
||||
|
||||
self._widget_count_var.set(
|
||||
f"{len(widgets)} widget{'' if len(widgets) == 1 else 's'}")
|
||||
|
||||
self._populate_tree()
|
||||
|
||||
def _draw_grid(self, sx, sy, colour):
|
||||
"""The 10x10 device-pixel grid over the page background, under the widgets."""
|
||||
c = self._canvas
|
||||
z = self._zoom
|
||||
if GRID_STEP * z < GRID_MIN_SPACING:
|
||||
return
|
||||
right = sx + self._screen_w * z
|
||||
bottom = sy + self._screen_h * z
|
||||
for gx in range(GRID_STEP, self._screen_w, GRID_STEP):
|
||||
c.create_line(sx + gx * z, sy, sx + gx * z, bottom, fill=colour, tags=("grid",))
|
||||
for gy in range(GRID_STEP, self._screen_h, GRID_STEP):
|
||||
c.create_line(sx, sy + gy * z, right, sy + gy * z, fill=colour, tags=("grid",))
|
||||
|
||||
def _on_edit_toggled(self):
|
||||
global edit_mode
|
||||
edit_mode = self._edit_var.get()
|
||||
if not edit_mode:
|
||||
self._props.flush_edits()
|
||||
self._props.set_editable(edit_mode)
|
||||
self.refresh()
|
||||
app_state.set_status("Page Editor: edit mode — the page can be changed."
|
||||
if edit_mode else "Page Editor: view mode — nothing can be changed.")
|
||||
|
||||
def _on_snap_toggled(self):
|
||||
global snap_to_grid
|
||||
snap_to_grid = self._snap_var.get()
|
||||
|
||||
def _on_right_pane_toggled(self):
|
||||
global show_right_pane
|
||||
show_right_pane = self._right_pane_var.get()
|
||||
self._apply_right_pane()
|
||||
|
||||
def _apply_right_pane(self):
|
||||
"""Takes the widget tree and properties sheet out of the split, or puts them back."""
|
||||
showing = str(self._side) in self._body.panes()
|
||||
if self._right_pane_var.get() and not showing:
|
||||
self._body.add(self._side, weight=0)
|
||||
elif not self._right_pane_var.get() and showing:
|
||||
self._props.flush_edits()
|
||||
self._body.forget(self._side)
|
||||
|
||||
# ── main toolbar ──────────────────────────────────────────────
|
||||
|
||||
def toolbar_tools(self):
|
||||
"""The Page Editor's own toolbar buttons: (icon, tooltip, variable, command).
|
||||
|
||||
`main` lays them out on its toolbar while this editor is open and takes them
|
||||
down with it. A button with a variable is a toggle; add new tools here.
|
||||
"""
|
||||
return [
|
||||
("✏", "Edit Mode", self._edit_var, self._on_edit_toggled),
|
||||
("⌗", "Snap to Grid", self._snap_var, self._on_snap_toggled),
|
||||
("◨", "Widgets / Properties Pane", self._right_pane_var,
|
||||
self._on_right_pane_toggled),
|
||||
]
|
||||
|
||||
def _draw_widget(self, widget):
|
||||
"""Paints the widget as the device would, then lays an empty box over it.
|
||||
|
||||
The box is what selection outlines and what a drag's bbox is read from, so a
|
||||
transparent label with no text can still be found and moved.
|
||||
"""
|
||||
tag = f"ox_widget_{id(widget)}"
|
||||
tags = (tag, "ox_widget")
|
||||
x, y, w, h = self._painter.draw(widget, tags, _short_type(widget.get("type", "")))
|
||||
|
||||
z = self._zoom
|
||||
cx = (self._screen_canvas_x + x) * z
|
||||
cy = (self._screen_canvas_y + y) * z
|
||||
self._canvas.create_rectangle(cx, cy, cx + w * z, cy + h * z, fill="", outline="",
|
||||
width=WIDGET_OUTLINE_WIDTH,
|
||||
tags=(tag, "ox_widget", "ox_widget_rect"))
|
||||
|
||||
self._widget_items[widget] = tag
|
||||
self._tag_to_comp[tag] = widget
|
||||
self._widget_boxes[widget] = (x, y, w, h)
|
||||
|
||||
def _widget_at(self, cx, cy):
|
||||
"""The topmost widget whose box holds canvas point (cx, cy): the last one drawn."""
|
||||
z = self._zoom
|
||||
px = cx / z - self._screen_canvas_x
|
||||
py = cy / z - self._screen_canvas_y
|
||||
for widget in reversed(_get_widget_children(self.comp)):
|
||||
box = self._widget_boxes.get(widget)
|
||||
if box and box[0] <= px <= box[0] + box[2] and box[1] <= py <= box[1] + box[3]:
|
||||
return widget
|
||||
return None
|
||||
|
||||
def _populate_tree(self):
|
||||
for item in self._tree.get_children():
|
||||
self._tree.delete(item)
|
||||
self._tree_items.clear()
|
||||
|
||||
page_name = self.comp.get("name", "?")
|
||||
page_iid = self._tree.insert("", tk.END, text=f"\U0001f4c4 {page_name}",
|
||||
open=True)
|
||||
|
||||
widgets = _get_widget_children(self.comp)
|
||||
for widget in widgets:
|
||||
name = widget.get("name", "?")
|
||||
short = _short_type(widget.get("type", ""))
|
||||
iid = self._tree.insert(page_iid, tk.END, text=f"{name} [{short}]")
|
||||
self._tree_items[iid] = widget
|
||||
|
||||
def _select_widget(self, widget):
|
||||
c = self._canvas
|
||||
|
||||
c.itemconfig("ox_widget_rect", outline="", width=WIDGET_OUTLINE_WIDTH)
|
||||
c.delete("selection_handle")
|
||||
|
||||
self._selected_widget = widget
|
||||
# With nothing selected the sheet shows the page itself, as Workbench does.
|
||||
self._props.show(widget if widget is not None else self.comp)
|
||||
if widget is None:
|
||||
return
|
||||
|
||||
tag = self._widget_items.get(widget)
|
||||
if tag is None:
|
||||
return
|
||||
|
||||
# Only the outline comes forward: raising the widget itself would paint it over
|
||||
# the ones the device draws later.
|
||||
c.itemconfig(f"{tag} && ox_widget_rect", outline=SELECTED_OUTLINE,
|
||||
width=SELECTED_OUTLINE_WIDTH)
|
||||
c.tag_raise(f"{tag} && ox_widget_rect")
|
||||
|
||||
bbox = c.bbox(f"{tag} && ox_widget_rect")
|
||||
if bbox:
|
||||
self._draw_handles(bbox)
|
||||
|
||||
for iid, comp in self._tree_items.items():
|
||||
if comp is widget:
|
||||
# selection_set always fires <<TreeviewSelect>>, which calls
|
||||
# back into _select_widget — only set it when it changes.
|
||||
if self._tree.selection() != (iid,):
|
||||
self._tree.selection_set(iid)
|
||||
self._tree.see(iid)
|
||||
break
|
||||
|
||||
name = widget.get("name", "?")
|
||||
comp_type = widget.get("type", "")
|
||||
x = _get_prop(widget, "x", "0")
|
||||
y = _get_prop(widget, "y", "0")
|
||||
w = _get_prop(widget, "width", "0")
|
||||
h = _get_prop(widget, "height", "0")
|
||||
app_state.set_status(
|
||||
f"{name} ({comp_type}) x={x} y={y} w={w} h={h}")
|
||||
|
||||
def _draw_handles(self, bbox):
|
||||
c = self._canvas
|
||||
x1, y1, x2, y2 = bbox
|
||||
hs = HANDLE_SIZE // 2
|
||||
points = [
|
||||
(x1, y1), ((x1 + x2) / 2, y1), (x2, y1),
|
||||
(x1, (y1 + y2) / 2), (x2, (y1 + y2) / 2),
|
||||
(x1, y2), ((x1 + x2) / 2, y2), (x2, y2),
|
||||
]
|
||||
for px, py in points:
|
||||
c.create_rectangle(px - hs, py - hs, px + hs, py + hs,
|
||||
fill=HANDLE_COLOUR, outline=HANDLE_COLOUR,
|
||||
tags=("selection_handle",))
|
||||
|
||||
def _repaint_all(self):
|
||||
"""Every view redrawn from the document, this one included."""
|
||||
if self.on_change is not None:
|
||||
self.on_change()
|
||||
else:
|
||||
self.refresh()
|
||||
|
||||
def _commit_props(self, comp, values):
|
||||
"""Writes {prop: raw} to `comp` as one undoable step, and to the device when live.
|
||||
|
||||
Returns whether anything changed; a change repaints every view, this one included.
|
||||
"""
|
||||
changes = []
|
||||
for name, after in values.items():
|
||||
prop = comp.find(f"prop[@name='{name}']")
|
||||
before = prop.get("val") if prop is not None else None
|
||||
if before != after:
|
||||
changes.append((comp, name, before, after))
|
||||
if not changes:
|
||||
return False
|
||||
|
||||
# Live: the device first, and the page only shows what the device accepted.
|
||||
live_failures = []
|
||||
applied = []
|
||||
for change in changes:
|
||||
element, name, _before, after = change
|
||||
if app_state.live_mode and not live_hooks.live_set_prop_value(element, name, after):
|
||||
live_failures.append(f"{name}: {live_hooks.last_error}")
|
||||
continue
|
||||
set_prop_value(element, name, after)
|
||||
applied.append(change)
|
||||
|
||||
if app_state.live_mode:
|
||||
if applied:
|
||||
undo.push(undo.LiveSlotValueCommand(applied))
|
||||
else:
|
||||
undo.push(undo.SlotValueCommand(changes))
|
||||
app_state.mark_dirty()
|
||||
|
||||
self._repaint_all()
|
||||
|
||||
who = comp.get("name", "?")
|
||||
if live_failures:
|
||||
live_hooks.warn_failure(
|
||||
"Page Editor",
|
||||
f"{len(live_failures)} of {len(changes)} change(s) to {who} did not reach "
|
||||
"the device and were not applied",
|
||||
"\n".join(f" • {failure}" for failure in live_failures),
|
||||
)
|
||||
return len(live_failures) < len(changes)
|
||||
else:
|
||||
app_state.set_status(
|
||||
f"{who}: {', '.join(name for _, name, _, _ in changes)} changed.")
|
||||
return True
|
||||
|
||||
def _on_canvas_click(self, event):
|
||||
c = self._canvas
|
||||
cx = c.canvasx(event.x)
|
||||
cy = c.canvasy(event.y)
|
||||
|
||||
# Hit-tested by box, not by canvas item: a transparent label is mostly grid.
|
||||
widget = self._widget_at(cx, cy)
|
||||
if widget is not None:
|
||||
self._select_widget(widget)
|
||||
if not self._edit_var.get():
|
||||
self._drag["tag"] = None
|
||||
return
|
||||
box = self._widget_boxes[widget]
|
||||
self._drag.update(tag=self._widget_items[widget], x=cx, y=cy,
|
||||
origin=(box[0], box[1]), pos=(box[0], box[1]))
|
||||
else:
|
||||
self._select_widget(None)
|
||||
self._drag["tag"] = None
|
||||
|
||||
def _on_canvas_drag(self, event):
|
||||
if not self._drag["tag"]:
|
||||
return
|
||||
|
||||
c = self._canvas
|
||||
z = self._zoom
|
||||
cx = c.canvasx(event.x)
|
||||
cy = c.canvasy(event.y)
|
||||
|
||||
# Worked out from where the drag began rather than step by step, so snapping
|
||||
# never accumulates rounding and the widget lands exactly where it is drawn.
|
||||
ox, oy = self._drag["origin"]
|
||||
new_x = ox + (cx - self._drag["x"]) / z
|
||||
new_y = oy + (cy - self._drag["y"]) / z
|
||||
if self._snap_var.get():
|
||||
new_x = round(new_x / GRID_STEP) * GRID_STEP
|
||||
new_y = round(new_y / GRID_STEP) * GRID_STEP
|
||||
new_x = int(round(max(0, min(new_x, self._screen_w))))
|
||||
new_y = int(round(max(0, min(new_y, self._screen_h))))
|
||||
|
||||
px, py = self._drag["pos"]
|
||||
if (new_x, new_y) != (px, py):
|
||||
c.move(self._drag["tag"], (new_x - px) * z, (new_y - py) * z)
|
||||
self._drag["pos"] = (new_x, new_y)
|
||||
c.delete("selection_handle")
|
||||
|
||||
def _on_canvas_release(self, event):
|
||||
tag = self._drag["tag"]
|
||||
if not tag:
|
||||
return
|
||||
|
||||
widget = self._tag_to_comp.get(tag)
|
||||
if widget is None:
|
||||
self._drag["tag"] = None
|
||||
return
|
||||
|
||||
self._drag["tag"] = None
|
||||
moved = False
|
||||
new_x, new_y = self._drag["pos"]
|
||||
if (new_x, new_y) != self._drag["origin"]:
|
||||
moved = self._commit_props(widget, {"x": str(new_x), "y": str(new_y)})
|
||||
|
||||
# A commit re-renders through refresh(); a drag that went nowhere still has to
|
||||
# put back the handles the motion took down.
|
||||
if not moved:
|
||||
self.refresh()
|
||||
|
||||
def _on_canvas_right_click(self, event):
|
||||
c = self._canvas
|
||||
cx = c.canvasx(event.x)
|
||||
cy = c.canvasy(event.y)
|
||||
|
||||
widget = self._widget_at(cx, cy)
|
||||
if widget is None:
|
||||
return
|
||||
self._select_widget(widget)
|
||||
|
||||
menu = tk.Menu(self._canvas, tearoff=0)
|
||||
menu.add_command(label="Properties",
|
||||
command=lambda: self._open_properties(widget))
|
||||
menu.post(event.x_root, event.y_root)
|
||||
|
||||
def _on_tree_select(self, event):
|
||||
sel = self._tree.selection()
|
||||
if not sel:
|
||||
return
|
||||
widget = self._tree_items.get(sel[0])
|
||||
if widget is not None:
|
||||
self._select_widget(widget)
|
||||
|
||||
def _open_properties(self, widget):
|
||||
pass
|
||||
|
||||
def _on_wheel_zoom(self, event):
|
||||
if not event.delta:
|
||||
return "break"
|
||||
step = 1 if event.delta > 0 else -1
|
||||
level = self.zoom_step(step)
|
||||
anchor = (event.x, event.y)
|
||||
self._zoom_with_anchor(level, anchor)
|
||||
from main import update_zoom_label
|
||||
update_zoom_label()
|
||||
return "break"
|
||||
|
||||
def _zoom_with_anchor(self, level, anchor):
|
||||
c = self._canvas
|
||||
before = self._zoom
|
||||
sheet_x = c.canvasx(anchor[0]) / before
|
||||
sheet_y = c.canvasy(anchor[1]) / before
|
||||
|
||||
after = self.set_zoom(level)
|
||||
if after == before:
|
||||
return
|
||||
|
||||
region = str(c.cget("scrollregion")).split()
|
||||
if len(region) == 4:
|
||||
span_x = float(region[2]) or 1.0
|
||||
span_y = float(region[3]) or 1.0
|
||||
c.xview_moveto(max(0.0, (sheet_x * after - anchor[0]) / span_x))
|
||||
c.yview_moveto(max(0.0, (sheet_y * after - anchor[1]) / span_y))
|
||||
|
||||
# ── Zoom ──────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def zoom(self):
|
||||
return self._zoom
|
||||
|
||||
def zoom_step(self, direction):
|
||||
idx = 0
|
||||
for i, lvl in enumerate(ZOOM_LEVELS):
|
||||
if lvl <= self._zoom:
|
||||
idx = i
|
||||
target = idx + direction
|
||||
target = max(0, min(len(ZOOM_LEVELS) - 1, target))
|
||||
return ZOOM_LEVELS[target]
|
||||
|
||||
def set_zoom(self, level):
|
||||
level = max(ZOOM_LEVELS[0], min(ZOOM_LEVELS[-1], level))
|
||||
self._zoom = level
|
||||
self._apply_zoom()
|
||||
return level
|
||||
|
||||
def fit_zoom_level(self):
|
||||
c = self._canvas
|
||||
view_w = c.winfo_width() - 4
|
||||
view_h = c.winfo_height() - 4
|
||||
if view_w <= 0 or view_h <= 0:
|
||||
return None
|
||||
fit_x = view_w / self._canvas_w
|
||||
fit_y = view_h / self._canvas_h
|
||||
return max(ZOOM_LEVELS[0], min(ZOOM_LEVELS[-1], min(fit_x, fit_y)))
|
||||
|
||||
def _apply_zoom(self):
|
||||
z = self._zoom
|
||||
zw = int(self._canvas_w * z)
|
||||
zh = int(self._canvas_h * z)
|
||||
self._canvas.config(scrollregion=(0, 0, zw, zh))
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
"""Redraws from the document, keeping the selection when the widget survives."""
|
||||
selected = self._selected_widget
|
||||
self._render_page()
|
||||
if selected is not None and selected not in _get_widget_children(self.comp):
|
||||
selected = None
|
||||
self._select_widget(selected)
|
||||
|
||||
def _background(self):
|
||||
return app_state.DIRTY_TINT if app_state.is_dirty else CANVAS_BG
|
||||
|
||||
def refresh_dirty_tint(self):
|
||||
"""Wears the wiresheet's unsaved-state tint while the document has unwritten changes."""
|
||||
self._canvas.config(bg=self._background())
|
||||
|
||||
def close(self):
|
||||
if self.on_close is not None:
|
||||
self.on_close()
|
||||
return True
|
||||
|
||||
def destroy(self):
|
||||
if self.frame.winfo_exists():
|
||||
self.frame.destroy()
|
||||
|
||||
def _build_footer(self):
|
||||
footer = tk.Frame(self.frame, padx=8, pady=6)
|
||||
footer.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
|
||||
ttk.Button(footer, text="Close", width=9,
|
||||
command=self.close).pack(side=tk.RIGHT)
|
||||
|
||||
|
||||
def is_ox_page(comp):
|
||||
return comp is not None and comp.get("type", "") == "ontrolOx::OxPage"
|
||||
|
||||
|
||||
def build_ox_page_view_panel(parent, comp, on_change=None, on_close=None):
|
||||
if comp is None or parent is None:
|
||||
return None
|
||||
return OxPageViewPanel(parent, comp, on_change, on_close)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,532 @@
|
||||
"""Draws Ox widgets on the Page Editor canvas the way the device's firmware draws them.
|
||||
|
||||
The rules are the R-ION firmware's (graphics.c, ontrolXx.c) and each widget's draw():
|
||||
|
||||
text boxes drawn with the device's own bitmap fonts from Sedona home's fontBank
|
||||
(a Windows font of the same size when the file is not there). The
|
||||
line is centred vertically, aligned by attributes bits 4-5 when the
|
||||
box is wider than the text, and cut at the character that would cross
|
||||
the right edge. A box whose draw-background bit (3) is clear is
|
||||
transparent. The last `subscriptDigits` characters are drawn half size.
|
||||
images centred in a box larger than the image, cropped to a smaller one; a
|
||||
file the editor cannot find is drawn as the device draws a missing
|
||||
one, a red and white checker.
|
||||
values each widget shows the text its `in` currently selects, so a live
|
||||
page reads as the device does and an offline one shows the defaults.
|
||||
|
||||
Everything is drawn in canvas units: device pixels times the zoom.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageTk
|
||||
_HAS_PIL = True
|
||||
except ImportError:
|
||||
_HAS_PIL = False
|
||||
|
||||
import app_state
|
||||
import properties
|
||||
from ox_widget_properties import (_Model, BACKGROUND_BIT, ALIGN_MASK, SUBSCRIPT_MASK,
|
||||
DATE_HIDE_YEAR, DATE_TWO_DIGIT_YEAR, DATE_LEADING_ZERO,
|
||||
DATE_MONTH_FIRST, IMAGE_EXTENSIONS, PORTRAIT_FONT_EXTENSION,
|
||||
bank_folders, font_extensions, font_names,
|
||||
parse_lookup, parse_numeric_format, rgb565_to_hex)
|
||||
|
||||
PLACEHOLDER_FILL = "#dce6f2"
|
||||
PLACEHOLDER_OUTLINE = "#7f9db9"
|
||||
HIDDEN_OUTLINE = "#9a9a9a"
|
||||
CHECKER_COLOURS = ((232, 60, 60), (255, 255, 255)) # the firmware's RED, WHITE
|
||||
CHECKER_CELL = 4
|
||||
SEPARATORS = {0: "/", 1: ".", 2: "-", 3: " "}
|
||||
FALLBACK_FAMILY = "Segoe UI"
|
||||
|
||||
_font_cache = {}
|
||||
_families = None
|
||||
_text_cache = {} # rendered lines, so a redraw does not re-decode every glyph
|
||||
TEXT_CACHE_SIZE = 400
|
||||
|
||||
|
||||
def _font(family, pixels, style):
|
||||
"""A Tk font `pixels` tall; style is fontAttributes' 0 normal, 1 bold, 2 italic."""
|
||||
global _families
|
||||
if _families is None:
|
||||
_families = {f.lower() for f in tkfont.families()}
|
||||
if family.lower() not in _families:
|
||||
family = FALLBACK_FAMILY
|
||||
key = (family, pixels, style)
|
||||
if key not in _font_cache:
|
||||
_font_cache[key] = tkfont.Font(
|
||||
family=family, size=-max(1, pixels),
|
||||
weight="bold" if style == 1 else "normal",
|
||||
slant="italic" if style == 2 else "roman")
|
||||
return _font_cache[key]
|
||||
|
||||
|
||||
# --- the device's own bitmap fonts ----------------------------------------------------
|
||||
|
||||
class BitmapFont:
|
||||
"""A device font file: fontBank/'32pt Tahoma.bin', or fontBankXL/'.oxfl' (same layout).
|
||||
|
||||
Orion's portrait cut, fontBankXL/'.oxfp', has the same header and table but stores
|
||||
each glyph turned a quarter turn anticlockwise: `height` pixels to a row, `width`
|
||||
rows. glyph_image turns it back, so every caller sees the upright glyph.
|
||||
|
||||
0..63 header; u16 character count at 32, line height at 34
|
||||
64.. 10 bytes per character: code (u8, or u16 above 191), data offset (u24
|
||||
at 2), width, height, x offset (signed), y offset, x advance
|
||||
data 4-bit grey, two pixels a byte, high nibble first, row by row
|
||||
"""
|
||||
|
||||
def __init__(self, path):
|
||||
data = open(path, "rb").read()
|
||||
self.data = data
|
||||
self.rotated = path.lower().endswith(PORTRAIT_FONT_EXTENSION)
|
||||
self.height = data[34]
|
||||
count = data[32] | data[33] << 8
|
||||
self.glyphs = {}
|
||||
for index in range(count):
|
||||
e = data[64 + index * 10:74 + index * 10]
|
||||
if len(e) < 10:
|
||||
break
|
||||
code = e[0] if e[0] < 192 and e[1] == 0 else e[0] | e[1] << 8
|
||||
self.glyphs[code] = (e[2] | e[3] << 8 | e[4] << 16, e[5], e[6],
|
||||
e[7] - 256 if e[7] > 127 else e[7], e[8], e[9])
|
||||
|
||||
def metrics(self, code):
|
||||
"""(printWidth, xOffset) the way Calculate_Text_Width works them out."""
|
||||
_, width, _, x_off, _, advance = self.glyphs[code]
|
||||
x_offset = 1 if x_off < 0 else x_off + 1
|
||||
print_width = max(advance, width, x_offset + width)
|
||||
return print_width, x_offset
|
||||
|
||||
def glyph_image(self, code):
|
||||
offset, width, height, _, _, _ = self.glyphs[code]
|
||||
stored = (height, width) if self.rotated else (width, height)
|
||||
img = Image.new("L", (max(1, stored[0]), max(1, stored[1])))
|
||||
if width and height:
|
||||
raw = self.data[offset:offset + (width * height + 1) // 2]
|
||||
pixels = bytearray(width * height)
|
||||
for i in range(width * height):
|
||||
byte = raw[i >> 1] if (i >> 1) < len(raw) else 0
|
||||
pixels[i] = ((byte >> 4) if i % 2 == 0 else (byte & 0x0F)) * 17
|
||||
img.putdata(pixels)
|
||||
return img.transpose(Image.ROTATE_270) if self.rotated else img
|
||||
|
||||
|
||||
_bitmap_fonts = {}
|
||||
|
||||
|
||||
def find_bitmap_font(family, size, style):
|
||||
"""The device font for fontName/fontSize/fontAttributes, or None when none is local.
|
||||
|
||||
get_Font_File_Name's naming: '<size>pt <Name>', prefixed 'bold ' for bold. There is
|
||||
no italic cut, so italic falls back to the regular one as the device's lookup would
|
||||
find nothing better. The file comes from the platform's own bank, and on Orion in
|
||||
the cut its screen orientation draws with.
|
||||
"""
|
||||
if not _HAS_PIL:
|
||||
return None
|
||||
stems = [f"{'bold ' if style == 1 else ''}{size}pt {family}"]
|
||||
if style == 1:
|
||||
stems.append(f"{size}pt {family}")
|
||||
candidates = [os.path.join(folder, stem + ext) for stem in stems
|
||||
for folder in bank_folders("font") for ext in font_extensions()]
|
||||
for path in candidates:
|
||||
if path in _bitmap_fonts:
|
||||
return _bitmap_fonts[path]
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
_bitmap_fonts[path] = BitmapFont(path)
|
||||
except (OSError, IndexError):
|
||||
_bitmap_fonts[path] = None
|
||||
return _bitmap_fonts[path]
|
||||
return None
|
||||
|
||||
|
||||
def render_bitmap_text(font, text, box_width, subscript):
|
||||
"""(mask, text width): the line as Draw_Text_Box lays it out, in device pixels.
|
||||
|
||||
Characters the font lacks are skipped, the last `subscript` drawn ones are halved
|
||||
in both directions and sit on the lower half of the line (Stretch_Char), and the
|
||||
line stops at the first character that would cross the box's right edge.
|
||||
"""
|
||||
codes = [ord(ch) for ch in text if ord(ch) in font.glyphs]
|
||||
stretch_from = len(codes) - subscript if 0 < subscript <= len(codes) else len(codes)
|
||||
layout, width = [], 0
|
||||
for n, code in enumerate(codes):
|
||||
print_width, x_offset = font.metrics(code)
|
||||
stretched = n >= stretch_from
|
||||
if stretched:
|
||||
print_width, x_offset = print_width >> 1, x_offset >> 1
|
||||
if width + print_width > box_width:
|
||||
break
|
||||
layout.append((code, width + x_offset, stretched))
|
||||
width += print_width
|
||||
|
||||
mask = Image.new("L", (max(1, width), max(1, font.height)))
|
||||
for code, x, stretched in layout:
|
||||
glyph = font.glyph_image(code)
|
||||
_, _, height, _, y_offset, _ = font.glyphs[code]
|
||||
if stretched:
|
||||
glyph = glyph.resize((max(1, glyph.width >> 1), max(1, glyph.height >> 1)),
|
||||
Image.BOX)
|
||||
y_offset += height >> 1
|
||||
mask.paste(glyph, (x, y_offset), glyph)
|
||||
return mask, width
|
||||
|
||||
|
||||
def find_image_file(name):
|
||||
"""The local file for a device image name, looked for in Sedona home's image banks."""
|
||||
if not name:
|
||||
return None
|
||||
for folder in bank_folders("image"):
|
||||
for ext in IMAGE_EXTENSIONS:
|
||||
path = os.path.join(folder, name + ext)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _format_value(fmt, value):
|
||||
"""sprintf(fmt, value) for the %f formats NumericLabel carries."""
|
||||
parts = parse_numeric_format(fmt)
|
||||
if parts is not None:
|
||||
prefix, show, precision, suffix = parts
|
||||
return f"{prefix}{value:.{precision}f}{suffix}" if show else prefix
|
||||
try:
|
||||
return fmt % value
|
||||
except (TypeError, ValueError):
|
||||
return fmt
|
||||
|
||||
|
||||
def _lookup_text(text, index):
|
||||
"""Process_Text: prefix + the index'th item + suffix, or '<n>:Error'."""
|
||||
prefix, items, suffix = parse_lookup(text)
|
||||
if 0 <= index < len(items):
|
||||
return f"{prefix}{items[index]}{suffix}"
|
||||
return f"{index}:Error"
|
||||
|
||||
|
||||
def _date_text(options, separator):
|
||||
"""Today's date as DateLabelSet.prepareDateString lays it out."""
|
||||
today = datetime.date.today()
|
||||
no_zero = bool(options & DATE_LEADING_ZERO) # the firmware drops the zero when set
|
||||
day = str(today.day) if no_zero else f"{today.day:02d}"
|
||||
month = str(today.month) if no_zero else f"{today.month:02d}"
|
||||
sep = SEPARATORS.get(separator, "/")
|
||||
first, second = (month, day) if options & DATE_MONTH_FIRST else (day, month)
|
||||
text = f"{first}{sep}{second}"
|
||||
if not options & DATE_HIDE_YEAR:
|
||||
year = today.year % 100 if options & DATE_TWO_DIGIT_YEAR else today.year
|
||||
text += f"{sep}{year:02d}"
|
||||
return text
|
||||
|
||||
|
||||
def _time_text(prefix, minutes, am_pm):
|
||||
"""drawTimeTextBox with digits 0: prefix + HH:MM, optionally 12-hour."""
|
||||
minutes = int(minutes) % 1440
|
||||
hour, minute = divmod(minutes, 60)
|
||||
if not am_pm:
|
||||
return f"{prefix}{hour:02d}:{minute:02d}"
|
||||
suffix = " AM" if hour < 12 else " PM"
|
||||
hour = 12 if hour == 0 else hour - 12 if hour > 12 else hour
|
||||
return f"{prefix}{hour:02d}:{minute:02d}{suffix}"
|
||||
|
||||
|
||||
def _as_float(raw, fallback=0.0):
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
|
||||
def _bool_state(raw):
|
||||
return properties.bool_state(raw)
|
||||
|
||||
|
||||
class WidgetPainter:
|
||||
"""Paints one page's widgets; holds the PhotoImages the canvas needs kept alive."""
|
||||
|
||||
def __init__(self, canvas, origin_x, origin_y, zoom, page_colour):
|
||||
self.c = canvas
|
||||
self.ox = origin_x
|
||||
self.oy = origin_y
|
||||
self.z = zoom
|
||||
self.page_colour = page_colour
|
||||
self.images = []
|
||||
|
||||
# ── primitives ────────────────────────────────────────────────
|
||||
|
||||
def _xy(self, x, y):
|
||||
return self.ox + x * self.z, self.oy + y * self.z
|
||||
|
||||
def _shape(self, x, y, w, h, fill, shape, tags):
|
||||
x1, y1 = self._xy(x, y)
|
||||
x2, y2 = self._xy(x + w, y + h)
|
||||
maker = self.c.create_oval if shape else self.c.create_rectangle
|
||||
maker(x1, y1, x2, y2, fill=fill, outline="", tags=tags)
|
||||
|
||||
def text_box(self, m, text, x, y, w, h, text_colour, box_colour, tags,
|
||||
attributes=None, shape=None):
|
||||
"""A label's box and text.
|
||||
|
||||
The line box (the font's full height) is centred on the widget box even when
|
||||
it is the taller of the two, so the glyphs sit in the box as Workbench shows
|
||||
them rather than hanging below a short one.
|
||||
"""
|
||||
attributes = m.int("attributes", 0x08) if attributes is None else attributes
|
||||
shape = m.int("shape") if shape is None else shape
|
||||
names = font_names(m) if "fontName" in m.slots else ("Tahoma",)
|
||||
family_index = m.int("fontName")
|
||||
family = names[family_index] if 0 <= family_index < len(names) else names[0]
|
||||
size = m.int("fontSize", 16)
|
||||
style = m.int("fontAttributes")
|
||||
|
||||
if attributes & BACKGROUND_BIT:
|
||||
self._shape(x, y, w, h, rgb565_to_hex(box_colour), shape, tags)
|
||||
if not text:
|
||||
return
|
||||
|
||||
digits = attributes & SUBSCRIPT_MASK
|
||||
bitmap = find_bitmap_font(family, size, style)
|
||||
if bitmap is not None:
|
||||
self._bitmap_text(bitmap, text, x, y, w, h, attributes, digits, text_colour, tags)
|
||||
else:
|
||||
self._tk_text(family, size, style, text, x, y, w, h, attributes, digits,
|
||||
text_colour, tags)
|
||||
|
||||
@staticmethod
|
||||
def _left_space(attributes, box_width, text_width):
|
||||
if box_width <= text_width:
|
||||
return 0
|
||||
alignment = attributes & ALIGN_MASK
|
||||
if alignment == 0x20:
|
||||
return box_width - text_width
|
||||
if alignment == 0x10:
|
||||
return (box_width - text_width) / 2
|
||||
return 0
|
||||
|
||||
def _bitmap_text(self, font, text, x, y, w, h, attributes, digits, text_colour, tags):
|
||||
key = (id(font), text, w, digits, text_colour, self.z)
|
||||
cached = _text_cache.get(key)
|
||||
if cached is None:
|
||||
mask, width = render_bitmap_text(font, text, w, digits)
|
||||
if width == 0:
|
||||
return
|
||||
size = (max(1, round(mask.width * self.z)), max(1, round(mask.height * self.z)))
|
||||
if size != mask.size:
|
||||
# Up close the device's pixels are shown as pixels; below 1:1 they blend.
|
||||
mask = mask.resize(size, Image.NEAREST if self.z >= 2 else Image.LANCZOS)
|
||||
r, g, b = (int(rgb565_to_hex(text_colour)[i:i + 2], 16) for i in (1, 3, 5))
|
||||
img = Image.new("RGBA", mask.size, (r, g, b, 0))
|
||||
img.putalpha(mask)
|
||||
if len(_text_cache) > TEXT_CACHE_SIZE:
|
||||
_text_cache.clear()
|
||||
cached = _text_cache[key] = (ImageTk.PhotoImage(img), width)
|
||||
photo, width = cached
|
||||
self.images.append(photo)
|
||||
left = self._left_space(attributes, w, width)
|
||||
top = (h - font.height) / 2
|
||||
self.c.create_image(*self._xy(x + left, y + top), anchor="nw", image=photo, tags=tags)
|
||||
|
||||
def _tk_text(self, family, size, style, text, x, y, w, h, attributes, digits,
|
||||
text_colour, tags):
|
||||
"""The fallback when no device font file is found: a Windows font of the size."""
|
||||
z = self.z
|
||||
main_font = _font(family, round(size * z), style)
|
||||
half_font = _font(family, round(size * z / 2), style)
|
||||
split = len(text) - digits if 0 < digits < len(text) else len(text)
|
||||
chars = [(ch, main_font if i < split else half_font) for i, ch in enumerate(text)]
|
||||
|
||||
limit = w * z
|
||||
drawn, width = [], 0.0
|
||||
for ch, fnt in chars:
|
||||
advance = fnt.measure(ch)
|
||||
if width + advance > limit + 0.5:
|
||||
break
|
||||
drawn.append((ch, fnt))
|
||||
width += advance
|
||||
|
||||
bx, by = self._xy(x, y)
|
||||
cursor = bx + self._left_space(attributes, limit, width)
|
||||
top = by + (h * z - main_font.metrics("linespace")) / 2
|
||||
colour = rgb565_to_hex(text_colour)
|
||||
main_text = "".join(ch for ch, fnt in drawn if fnt is main_font)
|
||||
tail_text = "".join(ch for ch, fnt in drawn if fnt is half_font)
|
||||
if main_text:
|
||||
self.c.create_text(cursor, top, text=main_text, anchor="nw", fill=colour,
|
||||
font=main_font, tags=tags)
|
||||
cursor += main_font.measure(main_text)
|
||||
if tail_text:
|
||||
baseline = top + main_font.metrics("ascent") - half_font.metrics("ascent")
|
||||
self.c.create_text(cursor, baseline, text=tail_text, anchor="nw", fill=colour,
|
||||
font=half_font, tags=tags)
|
||||
|
||||
def image_box(self, name, x, y, w, h, tags):
|
||||
path = find_image_file(name)
|
||||
if not _HAS_PIL:
|
||||
self._placeholder(x, y, w, h, name or "image", tags)
|
||||
return
|
||||
if path is None:
|
||||
self._checker(x, y, w, h, tags)
|
||||
return
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
except Exception:
|
||||
self._checker(x, y, w, h, tags)
|
||||
return
|
||||
iw, ih = img.size
|
||||
crop_w, crop_h = min(iw, w), min(ih, h)
|
||||
img = img.crop((0, 0, crop_w, crop_h))
|
||||
left = (w - iw) // 2 if w > iw else 0
|
||||
top = (h - ih) // 2 if h > ih else 0
|
||||
size = (max(1, round(crop_w * self.z)), max(1, round(crop_h * self.z)))
|
||||
if size != img.size:
|
||||
img = img.resize(size, Image.LANCZOS)
|
||||
photo = ImageTk.PhotoImage(img)
|
||||
self.images.append(photo)
|
||||
self.c.create_image(*self._xy(x + left, y + top), anchor="nw", image=photo, tags=tags)
|
||||
|
||||
def _checker(self, x, y, w, h, tags):
|
||||
cw, ch = max(1, round(w * self.z)), max(1, round(h * self.z))
|
||||
cell = max(1, round(CHECKER_CELL * self.z))
|
||||
img = Image.new("RGB", (cw, ch))
|
||||
pixels = img.load()
|
||||
for py in range(ch):
|
||||
row = (py // cell) & 1
|
||||
for px in range(cw):
|
||||
pixels[px, py] = CHECKER_COLOURS[((px // cell) & 1) ^ row]
|
||||
photo = ImageTk.PhotoImage(img)
|
||||
self.images.append(photo)
|
||||
self.c.create_image(*self._xy(x, y), anchor="nw", image=photo, tags=tags)
|
||||
|
||||
def _placeholder(self, x, y, w, h, label, tags):
|
||||
x1, y1 = self._xy(x, y)
|
||||
x2, y2 = self._xy(x + w, y + h)
|
||||
self.c.create_rectangle(x1, y1, x2, y2, fill=PLACEHOLDER_FILL,
|
||||
outline=PLACEHOLDER_OUTLINE, tags=tags)
|
||||
self.c.create_text((x1 + x2) / 2, (y1 + y2) / 2, text=label, fill="#000000",
|
||||
font=(FALLBACK_FAMILY, max(7, min(10, int(min(x2 - x1, y2 - y1) // 8)))),
|
||||
tags=tags)
|
||||
|
||||
def page_image(self, name, width, height):
|
||||
"""OxPage.imageFileName, drawn over the page colour from the top-left corner."""
|
||||
if not name or find_image_file(name) is None:
|
||||
return
|
||||
self.image_box(name, 0, 0, width, height, ("page_image",))
|
||||
|
||||
# ── widgets ───────────────────────────────────────────────────
|
||||
|
||||
def draw(self, widget, tags, short_label):
|
||||
"""Paints `widget`; returns its box (x, y, w, h) in device pixels."""
|
||||
m = _Model(widget)
|
||||
x, y = m.int("x"), m.int("y")
|
||||
w, h = max(1, m.int("width", 80)), max(1, m.int("height", 80))
|
||||
kind = widget.get("type", "").rsplit("::", 1)[-1]
|
||||
|
||||
if _bool_state(m.raw("visibility")) == "false":
|
||||
x1, y1 = self._xy(x, y)
|
||||
x2, y2 = self._xy(x + w, y + h)
|
||||
self.c.create_rectangle(x1, y1, x2, y2, outline=HIDDEN_OUTLINE, dash=(3, 3),
|
||||
tags=tags)
|
||||
return x, y, w, h
|
||||
|
||||
painter = getattr(self, f"_draw_{kind}", None)
|
||||
if painter is None:
|
||||
self._placeholder(x, y, w, h, f"{widget.get('name', '?')}\n({short_label})", tags)
|
||||
else:
|
||||
painter(m, x, y, w, h, tags)
|
||||
return x, y, w, h
|
||||
|
||||
def _draw_PushButtonLabel(self, m, x, y, w, h, tags):
|
||||
self.text_box(m, m.text("text"), x, y, w, h, m.int("textColor"),
|
||||
m.int("backgroundColor", 0xFF00), tags)
|
||||
|
||||
def _draw_NumericLabel(self, m, x, y, w, h, tags):
|
||||
fmt = m.text("text")
|
||||
value = _as_float(m.raw("in"))
|
||||
tab = m.int("valueTabPosition")
|
||||
split = fmt.find("%")
|
||||
if tab == 0 or split <= 0:
|
||||
self.text_box(m, _format_value(fmt, value), x, y, w, h, m.int("textColor"),
|
||||
m.int("backgroundColor", 0xFF00), tags)
|
||||
return
|
||||
attributes = m.int("attributes", 0x08)
|
||||
self.text_box(m, fmt[:split].replace("%%", "%"), x, y, tab, h, m.int("textColor"),
|
||||
m.int("backgroundColor", 0xFF00), tags, shape=0)
|
||||
self.text_box(m, _format_value(": " + fmt[split:], value), x + tab, y, w - tab, h,
|
||||
m.int("textColor"), m.int("backgroundColor", 0xFF00), tags,
|
||||
attributes=attributes & 0xCF, shape=0)
|
||||
|
||||
_draw_NumericLabelSet = _draw_NumericLabel
|
||||
|
||||
def _draw_NumericSelectLabel(self, m, x, y, w, h, tags):
|
||||
text = m.text("text")
|
||||
if not text:
|
||||
return
|
||||
self.text_box(m, _lookup_text(text, m.int("in")), x, y, w, h, m.int("textColor"),
|
||||
m.int("backgroundColor", 0xFF00), tags)
|
||||
|
||||
def _draw_BooleanLabel(self, m, x, y, w, h, tags):
|
||||
state = _bool_state(m.raw("in"))
|
||||
if state == "true":
|
||||
text, fg, bg = m.text("trueText"), m.int("trueTextColor"), m.int("trueBackgroundColor")
|
||||
else:
|
||||
text = m.text("falseText") if state == "false" else "Null"
|
||||
fg, bg = m.int("falseTextColor"), m.int("falseBackgroundColor")
|
||||
attributes = m.int("attributes", 0x08)
|
||||
if not text:
|
||||
attributes &= 0xF7
|
||||
self.text_box(m, text, x, y, w, h, fg, bg, tags, attributes=attributes)
|
||||
|
||||
def _draw_ScheduleLabelSet(self, m, x, y, w, h, tags):
|
||||
state = _bool_state(m.raw("in"))
|
||||
if state == "true":
|
||||
img, fg, bg, index = (m.text("trueImageFileName"), m.int("trueTextColor"),
|
||||
m.int("trueBackgroundColor"), 1)
|
||||
else:
|
||||
img = m.text("falseImageFileName") if state == "false" else ""
|
||||
fg, bg = m.int("falseTextColor"), m.int("falseBackgroundColor")
|
||||
index = 0 if state == "false" else 2
|
||||
if img:
|
||||
self.image_box(img, x, y, w, h, tags)
|
||||
elif m.text("text"):
|
||||
self.text_box(m, _lookup_text(m.text("text"), index), x, y, w, h, fg, bg, tags)
|
||||
|
||||
def _draw_DateLabelSet(self, m, x, y, w, h, tags):
|
||||
text = _date_text(m.int("dateFormatOptions"), m.int("dateSeparatorCharacter"))
|
||||
self.text_box(m, text, x, y, w, h, m.int("textColor"),
|
||||
m.int("backgroundColor", 0xFF00), tags)
|
||||
|
||||
def _draw_TimeLabelSet(self, m, x, y, w, h, tags):
|
||||
am_pm = _bool_state(m.raw("amPmFormat")) == "true"
|
||||
text = _time_text(m.text("text"), m.int("in"), am_pm)
|
||||
self.text_box(m, text, x, y, w, h, m.int("textColor"),
|
||||
m.int("backgroundColor", 0xFF00), tags,
|
||||
attributes=m.int("attributes", 0x08) & 0xF8)
|
||||
|
||||
def _draw_Geometry(self, m, x, y, w, h, tags):
|
||||
if _bool_state(m.raw("transparent")) != "true":
|
||||
self._shape(x, y, w, h, rgb565_to_hex(m.int("backgroundColor", 0xFF00)),
|
||||
m.int("shape"), tags)
|
||||
|
||||
def _draw_CalibrationButton(self, m, x, y, w, h, tags):
|
||||
self._shape(x, y, w, h, rgb565_to_hex(m.int("backgroundColor", 0xFF00)),
|
||||
m.int("shape"), tags)
|
||||
|
||||
def _draw_PushButtonImage(self, m, x, y, w, h, tags):
|
||||
self.image_box(m.text("imageFileName"), x, y, w, h, tags)
|
||||
|
||||
def _draw_BooleanImage(self, m, x, y, w, h, tags):
|
||||
state = _bool_state(m.raw("in"))
|
||||
if state == "null":
|
||||
return
|
||||
name = m.text("trueImageFileName" if state == "true" else "falseImageFileName")
|
||||
self.image_box(name, x, y, w, h, tags)
|
||||
|
||||
def _draw_NumericSelectImage(self, m, x, y, w, h, tags):
|
||||
self.image_box(f"{m.text('imageFileNamePrefix')}{m.int('in')}", x, y, w, h, tags)
|
||||
+198
-10
@@ -46,8 +46,17 @@ def version_sort_key(version_string):
|
||||
return numbers or [0]
|
||||
|
||||
|
||||
def _norm_checksum(checksum):
|
||||
"""Strips leading zeros so differently-padded hex checksums compare equal."""
|
||||
return checksum.lstrip("0") if checksum else checksum
|
||||
|
||||
|
||||
def kit_versions(kit_name):
|
||||
"""Maps checksum -> version string, read from the .kit filenames under kits/."""
|
||||
"""Maps checksum -> version string, read from the .kit filenames under kits/.
|
||||
|
||||
Keys are stored with leading zeros stripped because .kit files and manifest
|
||||
filenames sometimes disagree on padding (e.g. 462d43e vs 0462d43e).
|
||||
"""
|
||||
if kit_name in _versions_cache:
|
||||
return _versions_cache[kit_name]
|
||||
|
||||
@@ -58,7 +67,12 @@ def kit_versions(kit_name):
|
||||
for entry in os.listdir(kit_dir):
|
||||
match = pattern.match(entry)
|
||||
if match:
|
||||
versions[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
|
||||
@@ -89,13 +103,14 @@ def resolve_manifest_path(kit_name):
|
||||
if pinned:
|
||||
candidate = f"{kit_name}-{pinned}.xml"
|
||||
if candidate in manifests:
|
||||
return os.path.join(kit_dir, candidate), versions.get(pinned, pinned)
|
||||
return os.path.join(kit_dir, candidate), versions.get(_norm_checksum(pinned), pinned)
|
||||
|
||||
available = []
|
||||
for entry in manifests:
|
||||
checksum = entry[len(kit_name) + 1:-4]
|
||||
if checksum in versions:
|
||||
available.append((version_sort_key(versions[checksum]), entry, versions[checksum]))
|
||||
norm = _norm_checksum(checksum)
|
||||
if norm in versions:
|
||||
available.append((version_sort_key(versions[norm]), entry, versions[norm]))
|
||||
|
||||
if available:
|
||||
available.sort()
|
||||
@@ -104,7 +119,7 @@ def resolve_manifest_path(kit_name):
|
||||
|
||||
entry = manifests[-1]
|
||||
checksum = entry[len(kit_name) + 1:-4]
|
||||
return os.path.join(kit_dir, entry), versions.get(checksum, "")
|
||||
return os.path.join(kit_dir, entry), versions.get(_norm_checksum(checksum), "")
|
||||
|
||||
|
||||
def kit_builds(kit_name):
|
||||
@@ -125,7 +140,7 @@ def kit_builds(kit_name):
|
||||
if not (entry.startswith(f"{kit_name}-") and entry.endswith(".xml")):
|
||||
continue
|
||||
checksum = entry[len(kit_name) + 1:-4]
|
||||
builds.append((versions.get(checksum, ""), checksum))
|
||||
builds.append((versions.get(_norm_checksum(checksum), ""), checksum))
|
||||
|
||||
builds.sort(
|
||||
key=lambda build: (bool(build[0]), version_sort_key(build[0])),
|
||||
@@ -184,7 +199,7 @@ def version_for_checksum(kit_name, checksum):
|
||||
"""
|
||||
if not checksum:
|
||||
return ""
|
||||
return kit_versions(kit_name).get(checksum, "")
|
||||
return kit_versions(kit_name).get(_norm_checksum(checksum), "")
|
||||
|
||||
|
||||
def _find_type(qualified_name):
|
||||
@@ -217,11 +232,25 @@ def _is_component(qualified_name, seen=None):
|
||||
return result
|
||||
|
||||
|
||||
def _hidden_from_palette(type_element):
|
||||
"""True when the type's own facets carry @palette=false.
|
||||
|
||||
Only the type-level <facets> counts; slots have <facets> of their own nested deeper.
|
||||
"""
|
||||
facets = type_element.find("facets")
|
||||
if facets is None:
|
||||
return False
|
||||
for facet in facets.findall("bool"):
|
||||
if facet.get("name") == "palette" and facet.get("val") == "false":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def list_component_types(kit_name):
|
||||
"""Concrete component types in a kit, as (type name, base) pairs.
|
||||
|
||||
Primitives (no base) and abstract types (the 'a' flag) are left out — neither can be
|
||||
placed in an application.
|
||||
Primitives (no base), abstract types (the 'a' flag) and types marked @palette=false
|
||||
are left out — none of them belong in the palette.
|
||||
"""
|
||||
_, types = _load_kit(kit_name)
|
||||
|
||||
@@ -229,6 +258,8 @@ def list_component_types(kit_name):
|
||||
for type_name, type_element in types.items():
|
||||
if "a" in (type_element.get("flags") or ""):
|
||||
continue
|
||||
if _hidden_from_palette(type_element):
|
||||
continue
|
||||
if not _is_component(f"{kit_name}::{type_name}"):
|
||||
continue
|
||||
entries.append((type_name, type_element.get("base") or ""))
|
||||
@@ -236,9 +267,166 @@ def list_component_types(kit_name):
|
||||
return sorted(entries)
|
||||
|
||||
|
||||
_unsupported_cache = {} # frozenset(sax kit names) -> set of unsupported kit names
|
||||
|
||||
|
||||
def _platforms_db():
|
||||
return os.path.join(app_state.SEDONA_HOME, "platforms", "db")
|
||||
|
||||
|
||||
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
|
||||
every PlatformService-providing kit the SAX declares. *all_platforms* is the
|
||||
union across every build in the database regardless of match.
|
||||
|
||||
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() if sax_names is None else sax_names)
|
||||
db = _platforms_db()
|
||||
if not os.path.isdir(db):
|
||||
return None, None
|
||||
|
||||
sax_platform_kits = set()
|
||||
for name in sax_names:
|
||||
kit_dir = os.path.join(_manifests_dir(), name)
|
||||
if not os.path.isdir(kit_dir):
|
||||
continue
|
||||
for entry in os.listdir(kit_dir):
|
||||
if not (entry.startswith(f"{name}-") and entry.endswith(".xml")):
|
||||
continue
|
||||
try:
|
||||
tree = ET.parse(os.path.join(kit_dir, entry))
|
||||
except ET.ParseError:
|
||||
continue
|
||||
for t in tree.getroot().findall(".//type"):
|
||||
text = t.get("name", "") + " " + t.get("base", "")
|
||||
if "PlatformService" in text:
|
||||
sax_platform_kits.add(name)
|
||||
break
|
||||
|
||||
if not sax_platform_kits:
|
||||
return None, None
|
||||
|
||||
matching = set()
|
||||
all_platforms = set()
|
||||
found = False
|
||||
for vendor in os.listdir(db):
|
||||
vd = os.path.join(db, vendor)
|
||||
if not os.path.isdir(vd):
|
||||
continue
|
||||
for device in os.listdir(vd):
|
||||
dd = os.path.join(vd, device)
|
||||
if not os.path.isdir(dd):
|
||||
continue
|
||||
for build in os.listdir(dd):
|
||||
mf = os.path.join(dd, build, ".par", "platformManifest.xml")
|
||||
if not os.path.isfile(mf):
|
||||
continue
|
||||
try:
|
||||
tree = ET.parse(mf)
|
||||
except ET.ParseError:
|
||||
continue
|
||||
nk_names = set()
|
||||
for nk in tree.getroot().findall(".//nativeKit"):
|
||||
nk_names.add(nk.get("depend", "").split()[0])
|
||||
all_platforms.update(nk_names)
|
||||
if sax_platform_kits <= nk_names:
|
||||
matching.update(nk_names)
|
||||
found = True
|
||||
|
||||
return (matching if found else None), all_platforms
|
||||
|
||||
|
||||
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)
|
||||
but is not compiled into any build matching the SAX's device, or
|
||||
- it provides a PlatformService type for a different device.
|
||||
|
||||
Kits that depend on an unsupported kit are also flagged.
|
||||
"""
|
||||
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(sax_names)
|
||||
if matching_natives is None:
|
||||
_unsupported_cache[sax_names] = set()
|
||||
return set()
|
||||
|
||||
unsupported = set()
|
||||
all_kits = list_kits()
|
||||
|
||||
for name in all_kits:
|
||||
if name in sax_names:
|
||||
continue
|
||||
|
||||
# Native on another platform but not on the matching one.
|
||||
if name in all_natives and name not in matching_natives:
|
||||
unsupported.add(name)
|
||||
continue
|
||||
|
||||
kit_dir = os.path.join(_manifests_dir(), name)
|
||||
if not os.path.isdir(kit_dir):
|
||||
continue
|
||||
for entry in os.listdir(kit_dir):
|
||||
if not (entry.startswith(f"{name}-") and entry.endswith(".xml")):
|
||||
continue
|
||||
try:
|
||||
tree = ET.parse(os.path.join(kit_dir, entry))
|
||||
except ET.ParseError:
|
||||
continue
|
||||
root = tree.getroot()
|
||||
has_natives = root.get("hasNatives") == "true"
|
||||
has_platform_type = any(
|
||||
"PlatformService" in (t.get("name", "") + " " + t.get("base", ""))
|
||||
for t in root.findall(".//type")
|
||||
)
|
||||
if (has_natives or has_platform_type) and name not in matching_natives:
|
||||
unsupported.add(name)
|
||||
break
|
||||
|
||||
# Transitive: kits that depend on an unsupported kit
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for name in all_kits:
|
||||
if name in unsupported or name in sax_names:
|
||||
continue
|
||||
kit_dir = os.path.join(_manifests_dir(), name)
|
||||
if not os.path.isdir(kit_dir):
|
||||
continue
|
||||
for entry in os.listdir(kit_dir):
|
||||
if not (entry.startswith(f"{name}-") and entry.endswith(".xml")):
|
||||
continue
|
||||
try:
|
||||
tree = ET.parse(os.path.join(kit_dir, entry))
|
||||
except ET.ParseError:
|
||||
continue
|
||||
for dep in tree.getroot().findall(".//depend"):
|
||||
dep_name = dep.get("on", "").split()[0]
|
||||
if dep_name in unsupported:
|
||||
unsupported.add(name)
|
||||
changed = True
|
||||
break
|
||||
|
||||
_unsupported_cache[sax_names] = unsupported
|
||||
return unsupported
|
||||
|
||||
|
||||
def invalidate():
|
||||
"""Clears every cache, for when Sedona home changes or a kit is repinned."""
|
||||
_versions_cache.clear()
|
||||
_manifest_cache.clear()
|
||||
_component_cache.clear()
|
||||
_builds_cache.clear()
|
||||
_unsupported_cache.clear()
|
||||
|
||||
+1315
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
+629
@@ -0,0 +1,629 @@
|
||||
"""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,
|
||||
down to the `<!-- /path -->` comments and Java's float formatting. Kit manifests come
|
||||
from Sedona home, found the way the editor finds them everywhere else.
|
||||
|
||||
A .sab holds config props only, so runtime values cannot come back: like sedonac, the
|
||||
manifest default stands in for them, and a value equal to its default is not written.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import math
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import schema
|
||||
|
||||
|
||||
class SabError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _Reader:
|
||||
def __init__(self, data):
|
||||
self.b, self.p = data, 0
|
||||
|
||||
def take(self, n):
|
||||
if self.p + n > len(self.b):
|
||||
raise SabError(f"truncated sab at offset {self.p}")
|
||||
v = self.b[self.p:self.p + n]
|
||||
self.p += n
|
||||
return v
|
||||
|
||||
def u1(self): return self.take(1)[0]
|
||||
def u2(self): return struct.unpack(">H", self.take(2))[0]
|
||||
def i4(self): return struct.unpack(">i", self.take(4))[0]
|
||||
def i8(self): return struct.unpack(">q", self.take(8))[0]
|
||||
def f4(self): return struct.unpack(">f", self.take(4))[0]
|
||||
def f8(self): return struct.unpack(">d", self.take(8))[0]
|
||||
|
||||
def str_(self):
|
||||
end = self.b.index(0, self.p)
|
||||
s = self.b[self.p:end].decode("ascii")
|
||||
self.p = end + 1
|
||||
return s
|
||||
|
||||
|
||||
# --- schema ---------------------------------------------------------------------------
|
||||
|
||||
class Slot:
|
||||
__slots__ = ("name", "type", "flags", "default", "facets")
|
||||
|
||||
def __init__(self, elem):
|
||||
self.name = elem.get("name")
|
||||
self.type = elem.get("type")
|
||||
self.flags = elem.get("flags", "")
|
||||
self.default = elem.get("default")
|
||||
self.facets = {f.get("name"): f.get("val") for f in elem.iter()
|
||||
if f is not elem and f.get("name") is not None}
|
||||
|
||||
|
||||
def _load_kits(parts):
|
||||
"""[(kitName, checksumHex)] in kit-id order -> (types by kit, types by qname)."""
|
||||
kits, types_by_q = [], {}
|
||||
for name, cks in parts:
|
||||
path = schema.find_manifest_path(name, cks)
|
||||
if path is None:
|
||||
raise SabError(f"no manifest for kit '{name}' in Sedona home")
|
||||
mroot = ET.parse(path).getroot()
|
||||
tlist = sorted(mroot.findall("type"), key=lambda t: int(t.get("id")))
|
||||
for t in tlist:
|
||||
types_by_q[f"{name}::{t.get('name')}"] = t
|
||||
kits.append(tlist)
|
||||
return kits, types_by_q
|
||||
|
||||
|
||||
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 []
|
||||
by_name = {s.name: i for i, s in enumerate(out)}
|
||||
for elem in sorted(t.findall("slot"), key=lambda s: int(s.get("id"))):
|
||||
slot = Slot(elem)
|
||||
if slot.name in by_name: # an action override keeps the inherited id
|
||||
out[by_name[slot.name]] = slot
|
||||
else:
|
||||
by_name[slot.name] = len(out)
|
||||
out.append(slot)
|
||||
cache[qname] = out
|
||||
return out
|
||||
|
||||
|
||||
# --- values ---------------------------------------------------------------------------
|
||||
|
||||
def _int(raw): # Integer.decode: supports 0x
|
||||
if raw in (None, "", "null"):
|
||||
return 0
|
||||
return int(raw, 0) if raw.lower().startswith(("0x", "-0x")) else int(raw)
|
||||
|
||||
|
||||
def _flt(raw):
|
||||
if raw == "null":
|
||||
return float("nan") # explicit null only
|
||||
if raw in (None, ""):
|
||||
return 0.0 # Value.defaultForType -> zero
|
||||
return float(raw)
|
||||
|
||||
|
||||
def _decode_value(r, vtype, flags):
|
||||
if "s" in flags: # asStr Buf: u2 len (incl NUL)
|
||||
n = r.u2()
|
||||
return r.take(n).split(b"\0")[0].decode("ascii", errors="replace")
|
||||
if vtype == "bool":
|
||||
v = r.u1()
|
||||
return None if v == 2 else (v == 1)
|
||||
if vtype == "byte": return r.u1()
|
||||
if vtype == "short": return r.u2()
|
||||
if vtype == "int": return r.i4()
|
||||
if vtype == "long": return r.i8()
|
||||
if vtype == "float": return r.f4()
|
||||
if vtype == "double": return r.f8()
|
||||
if vtype in ("sys::Buf", "buf"):
|
||||
return bytes(r.take(r.u2()))
|
||||
raise SabError(f"unhandled slot type {vtype}")
|
||||
|
||||
|
||||
def _default_value(slot):
|
||||
"""The manifest default, or Value.defaultForType's zero."""
|
||||
vtype, flags, raw = slot.type, slot.flags, slot.default
|
||||
if "s" in flags:
|
||||
return raw or ""
|
||||
if vtype == "bool":
|
||||
return None if raw == "null" else raw in ("true", "1")
|
||||
if vtype in ("byte", "short", "int", "long"):
|
||||
return _int(raw)
|
||||
if vtype == "float":
|
||||
# the default is a 32-bit float too: 0.1 as a double is not the file's 0.1
|
||||
return _to_f32(_flt(raw))
|
||||
if vtype == "double":
|
||||
return _flt(raw)
|
||||
if vtype in ("sys::Buf", "buf"):
|
||||
return base64.b64decode(raw) if raw else b""
|
||||
raise SabError(f"unhandled slot type {vtype}")
|
||||
|
||||
|
||||
def _same(a, b):
|
||||
if isinstance(a, float) and isinstance(b, float):
|
||||
return (math.isnan(a) and math.isnan(b)) or a == b
|
||||
return type(a) is type(b) and a == b
|
||||
|
||||
|
||||
# --- Java number formatting -----------------------------------------------------------
|
||||
|
||||
_F32_MAX = 3.4028234663852886e38
|
||||
_F32_INF_EDGE = 3.4028235677973366e38 # midpoint to 2**128: below rounds to F32_MAX
|
||||
|
||||
|
||||
def _to_f32(v):
|
||||
try:
|
||||
return struct.unpack(">f", struct.pack(">f", v))[0]
|
||||
except OverflowError:
|
||||
if abs(v) < _F32_INF_EDGE:
|
||||
return math.copysign(_F32_MAX, v)
|
||||
return math.copysign(math.inf, v)
|
||||
|
||||
|
||||
def _shortest(a, single):
|
||||
"""Fewest digits that still round-trip -- what Java's toString picks."""
|
||||
for prec in range(0, 17):
|
||||
s = "%.*e" % (prec, a)
|
||||
v = float(s)
|
||||
if single:
|
||||
v = _to_f32(v)
|
||||
if v == a:
|
||||
break
|
||||
mant, exp = s.split("e")
|
||||
digits = mant.replace(".", "").rstrip("0") or "0"
|
||||
return digits, int(exp)
|
||||
|
||||
|
||||
def _java_float(v, single=True):
|
||||
"""Float/Double.toString: always a decimal point, E-form outside [1e-3, 1e7)."""
|
||||
if math.isnan(v):
|
||||
return "NaN"
|
||||
if math.isinf(v):
|
||||
return "-Infinity" if v < 0 else "Infinity"
|
||||
neg = math.copysign(1.0, v) < 0
|
||||
a = abs(v)
|
||||
if a == 0.0:
|
||||
return "-0.0" if neg else "0.0"
|
||||
digits, exp = _shortest(a, single)
|
||||
if -3 <= exp < 7:
|
||||
if exp >= 0:
|
||||
head = digits[:exp + 1].ljust(exp + 1, "0")
|
||||
tail = digits[exp + 1:] or "0"
|
||||
else:
|
||||
head, tail = "0", "0" * (-exp - 1) + digits
|
||||
out = head + "." + tail
|
||||
else:
|
||||
out = digits[0] + "." + (digits[1:] or "0") + "E" + str(exp)
|
||||
return "-" + out if neg else out
|
||||
|
||||
|
||||
def _encode_string(val, slot):
|
||||
"""Value.encodeString()."""
|
||||
vtype, flags = slot.type, slot.flags
|
||||
if "s" in flags:
|
||||
return _literal(val)
|
||||
if vtype == "bool":
|
||||
return "null" if val is None else ("true" if val else "false")
|
||||
if vtype == "float":
|
||||
return "null" if math.isnan(val) else _java_float(val, True)
|
||||
if vtype == "double":
|
||||
return "null" if math.isnan(val) else _java_float(val, False)
|
||||
if vtype in ("sys::Buf", "buf"):
|
||||
return base64.b64encode(val).decode("ascii")
|
||||
return str(val)
|
||||
|
||||
|
||||
def _literal(s):
|
||||
"""TextUtil.toLiteral."""
|
||||
return "".join({"\0": "\\0", "\n": "\\n", "\r": "\\r", "\t": "\\t",
|
||||
"\\": "\\\\", '"': '\\"', "$": "\\$"}.get(c, c) for c in s)
|
||||
|
||||
|
||||
def _safe(s):
|
||||
"""XWriter.safe: escape markup, quotes, and anything outside printable ASCII."""
|
||||
out = []
|
||||
for c in s:
|
||||
o = ord(c)
|
||||
if o < 0x20 or o > 0x7e or c in "'\"":
|
||||
out.append("&#x%x;" % o)
|
||||
elif c == "<":
|
||||
out.append("<")
|
||||
elif c == ">":
|
||||
out.append(">")
|
||||
elif c == "&":
|
||||
out.append("&")
|
||||
else:
|
||||
out.append(c)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
# --- the app --------------------------------------------------------------------------
|
||||
|
||||
class SabApp:
|
||||
"""A decoded app.sab: kits, components by id, and links.
|
||||
|
||||
Each component is a dict: id, type, name, parent, kids, and props -- a list of
|
||||
(Slot, value) for every config prop, defaults included.
|
||||
"""
|
||||
|
||||
def __init__(self, data):
|
||||
r = _Reader(data)
|
||||
if r.i4() != 0x73617070:
|
||||
raise SabError("not a .sab (bad magic)")
|
||||
ver = r.i4()
|
||||
if ver != 0x0003:
|
||||
raise SabError(f"unsupported sab version 0x{ver:x}")
|
||||
|
||||
self.kit_parts = []
|
||||
for _ in range(r.u1()):
|
||||
name = r.str_()
|
||||
self.kit_parts.append((name, f"{r.i4() & 0xFFFFFFFF:08x}"))
|
||||
kits, self._types_by_q = _load_kits(self.kit_parts)
|
||||
self._cache = {}
|
||||
r.u2() # maxId, recomputed on the way out
|
||||
|
||||
qname_of = {}
|
||||
for ki, ((kname, _), tlist) in enumerate(zip(self.kit_parts, kits)):
|
||||
for t in tlist:
|
||||
qname_of[(ki, int(t.get("id")))] = f"{kname}::{t.get('name')}"
|
||||
|
||||
by_id = {}
|
||||
while True:
|
||||
cid = r.u2()
|
||||
if cid == 0xFFFF:
|
||||
break
|
||||
ki, ti = r.u1(), r.u1()
|
||||
if (ki, ti) not in qname_of:
|
||||
raise SabError(f"comp {cid}: no type {ti} in kit {ki}")
|
||||
qname = qname_of[(ki, ti)]
|
||||
c = {"id": cid, "type": qname, "name": r.str_(), "kids": [], "props": []}
|
||||
c["parentId"], c["childId"], c["siblingId"] = r.u2(), r.u2(), r.u2()
|
||||
for slot in self.slots(qname):
|
||||
if "a" in slot.flags or "c" not in slot.flags:
|
||||
continue # config props only, in slot-id order
|
||||
c["props"].append((slot, _decode_value(r, slot.type, slot.flags)))
|
||||
if r.u1() != ord(";"):
|
||||
raise SabError(f"corrupted component {cid} {c['name']}")
|
||||
if cid in by_id:
|
||||
raise SabError(f"duplicate id: {cid}")
|
||||
by_id[cid] = c
|
||||
|
||||
def must(cid, what, owner):
|
||||
if cid not in by_id:
|
||||
raise SabError(f"comp {owner['id']} {owner['name']}: missing {what} {cid}")
|
||||
return by_id[cid]
|
||||
|
||||
for c in by_id.values():
|
||||
c["parent"] = must(c["parentId"], "parent", c) if c["parentId"] != 0xFFFF else None
|
||||
if c["childId"] != 0xFFFF:
|
||||
kid = must(c["childId"], "child", c)
|
||||
while True:
|
||||
c["kids"].append(kid)
|
||||
if kid["siblingId"] == 0xFFFF:
|
||||
break
|
||||
kid = must(kid["siblingId"], "nextSibling", kid)
|
||||
|
||||
self.links = []
|
||||
while True:
|
||||
fc = r.u2()
|
||||
if fc == 0xFFFF:
|
||||
break
|
||||
fs, tc, ts = r.u1(), r.u2(), r.u1()
|
||||
for cid in (fc, tc):
|
||||
if cid not in by_id:
|
||||
raise SabError(f"link references unknown comp id {cid}")
|
||||
self.links.append((by_id[fc], fs, by_id[tc], ts))
|
||||
if r.u1() != ord("."):
|
||||
raise SabError("invalid app end marker")
|
||||
|
||||
if 0 not in by_id:
|
||||
raise SabError("no app component (id 0)")
|
||||
self.comps = by_id
|
||||
self.app = by_id[0]
|
||||
|
||||
def slots(self, qname):
|
||||
return _resolve_slots(qname, self._types_by_q, self._cache)
|
||||
|
||||
def kit_names(self):
|
||||
return [name for name, _ in self.kit_parts]
|
||||
|
||||
def prop(self, comp, name, default=None):
|
||||
for slot, val in comp["props"]:
|
||||
if slot.name == name:
|
||||
return val
|
||||
return default
|
||||
|
||||
def slot(self, comp, name):
|
||||
return next((s for s in self.slots(comp["type"]) if s.name == name), None)
|
||||
|
||||
# --- writing app.sax ---
|
||||
|
||||
def to_sax(self):
|
||||
out = []
|
||||
w = out.append
|
||||
w("<?xml version='1.0'?>\n")
|
||||
w("<sedonaApp>\n")
|
||||
w("<schema>\n")
|
||||
for name, cks in self.kit_parts:
|
||||
w(f' <kit name="{_safe(name)}" checksum="{cks}" />\n')
|
||||
w("</schema>\n")
|
||||
w("<app>\n")
|
||||
self._write_props(w, self.app, 2)
|
||||
for kid in self.app["kids"]:
|
||||
self._write_comp(w, kid, 2)
|
||||
w("</app>\n")
|
||||
w("<links>\n")
|
||||
for fc, fs, tc, ts in self.links:
|
||||
w(' <link from="%s.%s" to="%s.%s"/>\n' % (
|
||||
_safe(_path_of(fc)), _safe(self._slot_name(fc, fs)),
|
||||
_safe(_path_of(tc)), _safe(self._slot_name(tc, ts))))
|
||||
w("</links>\n")
|
||||
w("</sedonaApp>\n")
|
||||
return "".join(out)
|
||||
|
||||
def _slot_name(self, comp, slot_id):
|
||||
slots = self.slots(comp["type"])
|
||||
if slot_id >= len(slots):
|
||||
raise SabError(f"{comp['type']} has no slot id {slot_id}")
|
||||
return slots[slot_id].name
|
||||
|
||||
def _write_props(self, w, c, indent):
|
||||
"""Only props whose value differs from the slot default get written."""
|
||||
n = 0
|
||||
for slot, val in c["props"]:
|
||||
if _same(val, _default_value(slot)):
|
||||
continue
|
||||
w('%s<prop name="%s" val="%s"/>\n' % (
|
||||
" " * indent, _safe(slot.name), _safe(_encode_string(val, slot))))
|
||||
n += 1
|
||||
return n
|
||||
|
||||
def _write_comp(self, w, c, indent):
|
||||
pad = " " * indent
|
||||
w(f"{pad}<!-- {_path_of(c)} -->\n")
|
||||
w(f'{pad}<comp name="{_safe(c["name"])}" id="{c["id"]}" type="{_safe(c["type"])}"')
|
||||
body = []
|
||||
n = self._write_props(body.append, c, indent + 2)
|
||||
for kid in c["kids"]:
|
||||
self._write_comp(body.append, kid, indent + 2)
|
||||
if not n and not c["kids"]:
|
||||
w("/>\n")
|
||||
else:
|
||||
w(">\n")
|
||||
w("".join(body))
|
||||
w(f"{pad}</comp>\n")
|
||||
|
||||
|
||||
def _path_of(c):
|
||||
parts = []
|
||||
while c["parent"] is not None:
|
||||
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)
|
||||
+85
@@ -26,6 +26,34 @@ def restore_children(element, snapshot):
|
||||
element.append(copy.deepcopy(child))
|
||||
|
||||
|
||||
def set_prop_value(element, name, value):
|
||||
"""Writes a component's <prop>, creating it in the block the other props sit in.
|
||||
|
||||
A .sax keeps every <prop> ahead of the child <comp> elements, and ET.SubElement
|
||||
would append past them, so a new prop is inserted after the last existing one
|
||||
instead. That is cosmetic to a parser and not to a human reading the diff.
|
||||
"""
|
||||
prop = element.find(f"prop[@name='{name}']")
|
||||
if prop is not None:
|
||||
prop.set("val", value)
|
||||
return prop
|
||||
|
||||
prop = ET.Element("prop", name=name, val=value)
|
||||
last_prop_index = -1
|
||||
for index, child in enumerate(element):
|
||||
if child.tag == "prop":
|
||||
last_prop_index = index
|
||||
element.insert(last_prop_index + 1, prop)
|
||||
return prop
|
||||
|
||||
|
||||
def remove_prop(element, name):
|
||||
"""Drops a component's <prop>, for undoing a value that the file never carried."""
|
||||
prop = element.find(f"prop[@name='{name}']")
|
||||
if prop is not None:
|
||||
element.remove(prop)
|
||||
|
||||
|
||||
def read_meta_property(meta_str):
|
||||
"""Parses 32-bit meta property integer fields via structured byte extraction rules."""
|
||||
if meta_str is None or not meta_str:
|
||||
@@ -77,6 +105,35 @@ def update_meta_property(element, meta_str, raw_x, raw_y):
|
||||
meta_prop.set("val", str(new_val))
|
||||
|
||||
|
||||
def update_meta_groups(element, meta_str, groups):
|
||||
"""Rewrites only meta's four user-group bits, leaving the packed position alone.
|
||||
|
||||
The mirror of update_meta_property, which rewrites only x and y. Both exist because
|
||||
meta is one integer carrying two unrelated things, and an editor that touches one of
|
||||
them must not disturb the other.
|
||||
"""
|
||||
raw_x, raw_y, reserved, _, _, _, _, ug_res = read_meta_property(meta_str)
|
||||
|
||||
user_group_byte = 0
|
||||
for bit, on in enumerate(groups[:4]):
|
||||
if on:
|
||||
user_group_byte |= (1 << bit)
|
||||
user_group_byte |= (ug_res & 0x0F) << 4
|
||||
|
||||
new_val = (
|
||||
((raw_x & 0xFF) << 24) |
|
||||
((raw_y & 0xFF) << 16) |
|
||||
((reserved & 0xFF) << 8) |
|
||||
(user_group_byte & 0xFF)
|
||||
)
|
||||
|
||||
meta_prop = element.find("prop[@name='meta']")
|
||||
if meta_prop is None:
|
||||
meta_prop = ET.SubElement(element, "prop", name="meta")
|
||||
|
||||
meta_prop.set("val", str(new_val))
|
||||
|
||||
|
||||
def find_parent_element(root_elem, child_to_find):
|
||||
"""Traverses the XML DOM tree recursively to find the parent element of a given element."""
|
||||
for parent in root_elem.iter():
|
||||
@@ -85,6 +142,34 @@ def find_parent_element(root_elem, child_to_find):
|
||||
return None
|
||||
|
||||
|
||||
def find_component_by_path(root_elem, path):
|
||||
"""The component an absolute path names, or None if the document has no such node.
|
||||
|
||||
The inverse of get_component_path, and the way a <link>'s from/to reference is
|
||||
turned back into the component it points at. Paths carry no entry for <app>, so
|
||||
resolution starts among its children.
|
||||
"""
|
||||
if root_elem is None or not path:
|
||||
return None
|
||||
|
||||
names = [part for part in path.split("/") if part]
|
||||
if not names:
|
||||
return None
|
||||
|
||||
node = root_elem.find("app")
|
||||
if node is None:
|
||||
node = root_elem
|
||||
|
||||
for name in names:
|
||||
node = next(
|
||||
(child for child in node if child.tag == "comp" and child.get("name") == name),
|
||||
None,
|
||||
)
|
||||
if node is None:
|
||||
return None
|
||||
return node
|
||||
|
||||
|
||||
def get_component_path(element, root_elem):
|
||||
"""Computes the absolute path of a component within the application tree hierarchy."""
|
||||
path_parts = []
|
||||
|
||||
+846
-55
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,41 @@ def read_summary_facet(slot_element):
|
||||
return True
|
||||
|
||||
|
||||
def read_all_facets(slot_element):
|
||||
"""Every facet on a slot, as {name: value} with each one typed by its own tag.
|
||||
|
||||
The manifest writes facets as <str name="unit" val="celsius"/>, <int name="max"
|
||||
val="1000"/> and so on, so the tag is the type. The Properties dialog needs the lot —
|
||||
unit, precision, min, max, trueText/falseText, range — and there are few enough of
|
||||
them per slot that carrying the whole dict costs less than a lookup per facet.
|
||||
"""
|
||||
facets = {}
|
||||
facet_element = slot_element.find("facets")
|
||||
if facet_element is None:
|
||||
return facets
|
||||
|
||||
for child in facet_element:
|
||||
name = child.get("name")
|
||||
if not name:
|
||||
continue
|
||||
raw = child.get("val", "")
|
||||
if child.tag == "bool":
|
||||
facets[name] = (raw.strip().lower() == "true")
|
||||
elif child.tag == "int":
|
||||
try:
|
||||
facets[name] = int(raw)
|
||||
except ValueError:
|
||||
facets[name] = raw
|
||||
elif child.tag in ("float", "double"):
|
||||
try:
|
||||
facets[name] = float(raw)
|
||||
except ValueError:
|
||||
facets[name] = raw
|
||||
else:
|
||||
facets[name] = raw
|
||||
return facets
|
||||
|
||||
|
||||
def read_readonly_facet(slot_element):
|
||||
"""Reads a slot's '@readonly' facet. Absent means writable."""
|
||||
facets = slot_element.find("facets")
|
||||
@@ -116,10 +151,11 @@ def get_slots_with_inheritance(type_name, kit_schemas):
|
||||
current_slots.append({
|
||||
"name": slot.get("name"),
|
||||
"type": slot.get("type"),
|
||||
"default": slot.get("default", "false"),
|
||||
"default": slot.get("default"), # absent means "the type's own zero"
|
||||
"flags": slot.get("flags", ""),
|
||||
"readonly": read_readonly_facet(slot),
|
||||
"summary": read_summary_facet(slot),
|
||||
"facets": read_all_facets(slot),
|
||||
"declared_id": int(slot.get("id"))
|
||||
})
|
||||
|
||||
@@ -157,7 +193,8 @@ def compile_schema_registry(manifest_paths):
|
||||
"default": slot_data["default"],
|
||||
"flags": slot_data["flags"],
|
||||
"readonly": slot_data["readonly"],
|
||||
"summary": slot_data["summary"]
|
||||
"summary": slot_data["summary"],
|
||||
"facets": slot_data["facets"]
|
||||
})
|
||||
app_state.final_components_registry[type_name] = resolved_type_structure
|
||||
|
||||
@@ -187,6 +224,41 @@ def find_manifest_path(kit_name, checksum):
|
||||
return None
|
||||
|
||||
|
||||
def resolve_truncated_checksum(kit_name, prefix):
|
||||
"""The checksum of the one local manifest whose leading bytes match ``prefix``.
|
||||
|
||||
Used when a device's schema reply cut a kit's checksum short. Returns the
|
||||
checksum as a signed 32-bit int (as read off the wire), or None when no
|
||||
manifest, or more than one, matches.
|
||||
"""
|
||||
manifest_dir = os.path.join(app_state.SEDONA_HOME, "manifests", kit_name)
|
||||
if not os.path.isdir(manifest_dir):
|
||||
return None
|
||||
want = prefix.hex()
|
||||
matches = set()
|
||||
for f in os.listdir(manifest_dir):
|
||||
if f.startswith(f"{kit_name}-") and f.endswith(".xml"):
|
||||
hex_part = f[len(kit_name) + 1:-4].zfill(8).lower()
|
||||
if hex_part.startswith(want):
|
||||
matches.add(hex_part)
|
||||
if len(matches) != 1:
|
||||
return None
|
||||
value = int(matches.pop(), 16)
|
||||
return value - (1 << 32) if value & 0x80000000 else value
|
||||
|
||||
|
||||
def find_manifest(kit_name, checksum):
|
||||
"""Returns the parsed ET root of a kit manifest, or None."""
|
||||
checksum_hex = f"{checksum & 0xFFFFFFFF:08x}" if isinstance(checksum, int) else checksum
|
||||
path = find_manifest_path(kit_name, checksum_hex)
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
return ET.parse(path).getroot()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load_schema_kits_and_manifests():
|
||||
"""Loads the manifests for the file's <schema> kits, plus any kit still only pending.
|
||||
|
||||
@@ -240,6 +312,17 @@ def get_all_slots_for_type(type_str):
|
||||
]
|
||||
|
||||
|
||||
def get_full_slots_for_type(type_str):
|
||||
"""Every slot of a type, whole, in manifest order — nothing filtered out.
|
||||
|
||||
get_slots_for_type hides most of them, which is right for a wiresheet box and wrong
|
||||
for the Properties dialog: a config slot is only editable somewhere, and this is
|
||||
that somewhere. Copies are handed out so a caller cannot edit the registry in place.
|
||||
"""
|
||||
return [dict(slot) for slot in app_state.final_components_registry.get(type_str, [])
|
||||
if slot["name"]]
|
||||
|
||||
|
||||
def get_slots_for_type(type_str, linked_slots=()):
|
||||
"""
|
||||
Returns the slots worth painting for a given type, prefixed with double-digit
|
||||
@@ -270,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))
|
||||
|
||||
@@ -1014,7 +1014,7 @@
|
||||
<prop name="meta" val="16842753" />
|
||||
<prop name="channelNo" val="16" />
|
||||
</comp>
|
||||
<comp name="Add2" id="27" type="control::Add2"><prop name="meta" val="521011201" /></comp></comp>
|
||||
<comp name="Add2" id="27" type="control::Add2"><prop name="meta" val="504496129" /></comp><comp name="Cmpr" id="28" type="control::Cmpr"><prop name="meta" val="873136129" /></comp><comp name="ConstFl" id="29" type="control::ConstFloat"><prop name="meta" val="102563841" /></comp></comp>
|
||||
|
||||
<comp name="Perform" id="225" type="ontrolControl::Performance" />
|
||||
</app>
|
||||
@@ -1262,5 +1262,5 @@
|
||||
<link from="/driver/ModbusN/Vnt2Fc/StsWord.out" to="/AHU20_1/SuppFan/F2I1.in" />
|
||||
<link from="/AHU20_1/SuppFan/I2F2.out" to="/driver/ModbusN/Vnt1Fc/CmdWord.in" />
|
||||
<link from="/AHU20_1/RetFan/I2F2.out" to="/driver/ModbusN/AspFc1/CmdWord.in" />
|
||||
<link from="/AHU20_1/RetPres/RetSet.value" to="/Fire/Add2.in1" /></links>
|
||||
<link from="/AHU20_1/RetPres/RetSet.value" to="/Fire/Add2.in1" /><link from="/Fire/Add2.out" to="/Fire/Cmpr.x" /><link from="/Fire/ConstFl.out" to="/Fire/Add2.in2" /></links>
|
||||
</sedonaApp>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,25 @@
|
||||
"""Undo/redo as a stack of reversible commands.
|
||||
"""Undo as a stack of reversible commands. There is no redo: an undone step is gone.
|
||||
|
||||
Commands own the DOM mutation in both directions. Nothing here touches Tk: the view is
|
||||
Commands own the DOM mutation in both directions -- redo() is how a command is first
|
||||
applied, undo() takes it back. In live mode they also carry it to the device, and raise
|
||||
LiveUndoError when it does not get there. Nothing here touches Tk: the view is
|
||||
refreshed through a callback that main registers at startup, which also keeps the
|
||||
import graph free of cycles (sax_file and wiresheet both push commands here).
|
||||
"""
|
||||
|
||||
import app_state
|
||||
import live_hooks
|
||||
import palette
|
||||
import schema
|
||||
from sax_dom import restore_children, update_meta_property
|
||||
from sax_dom import remove_prop, restore_children, set_prop_value, update_meta_property
|
||||
|
||||
_undo_stack = []
|
||||
_redo_stack = []
|
||||
_saved_depth = 0 # len(_undo_stack) as of the last save
|
||||
_refresh_view = None
|
||||
|
||||
|
||||
def set_refresh_callback(callback):
|
||||
"""Registers how the view should be rebuilt after an undo or redo."""
|
||||
"""Registers how the view should be rebuilt after an undo."""
|
||||
global _refresh_view
|
||||
_refresh_view = callback
|
||||
|
||||
@@ -34,39 +36,49 @@ def _refresh():
|
||||
|
||||
|
||||
def push(command):
|
||||
"""Records an already-applied command. Any redo history becomes unreachable."""
|
||||
"""Records an already-applied command."""
|
||||
global _saved_depth
|
||||
# Undone past the save, the saved state can no longer be reached by undoing.
|
||||
if len(_undo_stack) < _saved_depth:
|
||||
_saved_depth = -1
|
||||
_undo_stack.append(command)
|
||||
_redo_stack.clear()
|
||||
|
||||
|
||||
def can_undo():
|
||||
return bool(_undo_stack)
|
||||
|
||||
|
||||
def can_redo():
|
||||
return bool(_redo_stack)
|
||||
class LiveUndoError(Exception):
|
||||
"""A live command's undo did not fully reach the device.
|
||||
|
||||
Raised with what did not happen; live_hooks.last_error holds why.
|
||||
"""
|
||||
|
||||
|
||||
def undo():
|
||||
if not _undo_stack:
|
||||
return None
|
||||
command = _undo_stack.pop()
|
||||
command.undo()
|
||||
_redo_stack.append(command)
|
||||
_refresh()
|
||||
return command.label
|
||||
|
||||
|
||||
def redo():
|
||||
if not _redo_stack:
|
||||
try:
|
||||
command.undo()
|
||||
except LiveUndoError as failure:
|
||||
_abandon_history("Undo", failure)
|
||||
return None
|
||||
command = _redo_stack.pop()
|
||||
command.redo()
|
||||
_undo_stack.append(command)
|
||||
_refresh()
|
||||
return command.label
|
||||
|
||||
|
||||
def _abandon_history(title, failure):
|
||||
"""After a step that did not reach the device, the history no longer matches it.
|
||||
|
||||
The device may hold part of the step, so the steps below it cannot be trusted to land
|
||||
where they say. History is dropped rather than replayed against a state it never saw.
|
||||
"""
|
||||
reset()
|
||||
_refresh()
|
||||
live_hooks.warn_failure(title, f"{failure}. Undo history was cleared")
|
||||
|
||||
|
||||
def mark_saved():
|
||||
"""Pins the current stack depth as the on-disk state."""
|
||||
global _saved_depth
|
||||
@@ -77,14 +89,13 @@ def reset():
|
||||
"""Drops all history, for when a different file is loaded."""
|
||||
global _saved_depth
|
||||
_undo_stack.clear()
|
||||
_redo_stack.clear()
|
||||
_saved_depth = 0
|
||||
|
||||
|
||||
def reload_schema_registry():
|
||||
"""Recompiles the slot registry after the <schema> block changed.
|
||||
|
||||
A failure is swallowed: undo and redo only ever land on a state that resolved once
|
||||
A failure is swallowed: undo only ever lands on a state that resolved once
|
||||
already, so a missing manifest here means Sedona home moved underneath the session,
|
||||
which the next action reports in its own words.
|
||||
"""
|
||||
@@ -137,6 +148,98 @@ class MetaMoveCommand:
|
||||
update_meta_property(element, old_val, new_x, new_y)
|
||||
|
||||
|
||||
def _meta_val(element):
|
||||
meta_prop = element.find("prop[@name='meta']")
|
||||
return meta_prop.get("val") if meta_prop is not None else None
|
||||
|
||||
|
||||
class LiveMetaMoveCommand(MetaMoveCommand):
|
||||
"""A move on the device: each new position is written there after the DOM takes it.
|
||||
|
||||
A box the device refuses goes back to where the device still has it.
|
||||
"""
|
||||
|
||||
def undo(self):
|
||||
before = [(element, _meta_val(element)) for element, *_ in self.changes]
|
||||
super().undo()
|
||||
self._send(before)
|
||||
|
||||
def redo(self):
|
||||
before = [(element, _meta_val(element)) for element, *_ in self.changes]
|
||||
super().redo()
|
||||
self._send(before)
|
||||
|
||||
@staticmethod
|
||||
def _send(before):
|
||||
failures = []
|
||||
for element, old_val in before:
|
||||
new_val = _meta_val(element)
|
||||
if new_val is None or new_val == old_val:
|
||||
continue
|
||||
if not live_hooks.live_set_prop_value(element, "meta", new_val):
|
||||
failures.append(element.get("name", "?"))
|
||||
if old_val is None:
|
||||
remove_prop(element, "meta")
|
||||
else:
|
||||
set_prop_value(element, "meta", old_val)
|
||||
if failures:
|
||||
raise LiveUndoError(f"{', '.join(failures)} could not be moved on the device")
|
||||
|
||||
|
||||
class SlotValueCommand:
|
||||
"""One Save from the Properties dialog: every slot it changed, as one undoable step.
|
||||
|
||||
A change is recorded as the <prop>'s value before and after, with None standing for
|
||||
"the file had no such prop". Undoing back to None removes the prop again rather than
|
||||
writing the default out, so a component that only ever carried the props the file
|
||||
shipped with still carries exactly those.
|
||||
"""
|
||||
|
||||
def __init__(self, changes, label="edit properties"):
|
||||
# changes: list of (element, prop_name, old_val_or_None, new_val_or_None)
|
||||
self.changes = changes
|
||||
self.label = label
|
||||
|
||||
def _apply(self, value_index):
|
||||
for change in self.changes:
|
||||
element, prop_name = change[0], change[1]
|
||||
value = change[value_index]
|
||||
if value is None:
|
||||
remove_prop(element, prop_name)
|
||||
else:
|
||||
set_prop_value(element, prop_name, value)
|
||||
|
||||
def undo(self):
|
||||
self._apply(2)
|
||||
|
||||
def redo(self):
|
||||
self._apply(3)
|
||||
|
||||
|
||||
class LiveSlotValueCommand(SlotValueCommand):
|
||||
"""Slot edits on the device: each value is written there first, and only a value the
|
||||
device took lands in the DOM, so the views never show what the device does not hold.
|
||||
|
||||
A DOM read from the device carries every slot, so a None side does not come up; one
|
||||
that did would have nothing to send, and only the DOM would change.
|
||||
"""
|
||||
|
||||
def _apply(self, value_index):
|
||||
failures = []
|
||||
for change in self.changes:
|
||||
element, prop_name = change[0], change[1]
|
||||
value = change[value_index]
|
||||
if value is None:
|
||||
remove_prop(element, prop_name)
|
||||
continue
|
||||
if not live_hooks.live_set_prop_value(element, prop_name, value):
|
||||
failures.append(f"{element.get('name', '?')}.{prop_name}")
|
||||
continue
|
||||
set_prop_value(element, prop_name, value)
|
||||
if failures:
|
||||
raise LiveUndoError(f"{', '.join(failures)} could not be written to the device")
|
||||
|
||||
|
||||
class AddComponentCommand:
|
||||
"""Placing a component.
|
||||
|
||||
@@ -160,6 +263,25 @@ class AddComponentCommand:
|
||||
self.parent.insert(self.index, self.element)
|
||||
|
||||
|
||||
class CompositeCommand:
|
||||
"""Several commands taken as one step, such as a duplicate: its copies and its links.
|
||||
|
||||
Undone last-first, so links go before the components they run between.
|
||||
"""
|
||||
|
||||
def __init__(self, commands, label="edit"):
|
||||
self.commands = list(commands)
|
||||
self.label = label
|
||||
|
||||
def undo(self):
|
||||
for command in reversed(self.commands):
|
||||
command.undo()
|
||||
|
||||
def redo(self):
|
||||
for command in self.commands:
|
||||
command.redo()
|
||||
|
||||
|
||||
class AddLinkCommand:
|
||||
"""Adding a single link to the <links> section."""
|
||||
|
||||
@@ -218,3 +340,82 @@ class DeleteComponentCommand:
|
||||
links_parent.remove(link)
|
||||
if self.element in list(self.parent):
|
||||
self.parent.remove(self.element)
|
||||
|
||||
|
||||
class RenameComponentCommand:
|
||||
"""A rename, plus every link whose path ran through the component's old name."""
|
||||
|
||||
def __init__(self, element, old_name, new_name, link_changes, label="rename"):
|
||||
# link_changes: list of (link_element, attr, old_value, new_value)
|
||||
self.element = element
|
||||
self.old_name = old_name
|
||||
self.new_name = new_name
|
||||
self.link_changes = link_changes
|
||||
self.label = label
|
||||
|
||||
def undo(self):
|
||||
self.element.set("name", self.old_name)
|
||||
for link, attr, old_value, _ in self.link_changes:
|
||||
link.set(attr, old_value)
|
||||
|
||||
def redo(self):
|
||||
self.element.set("name", self.new_name)
|
||||
for link, attr, _, new_value in self.link_changes:
|
||||
link.set(attr, new_value)
|
||||
|
||||
|
||||
class LiveRenameCommand(RenameComponentCommand):
|
||||
"""A rename on the device: the old name goes back there before the DOM takes it."""
|
||||
|
||||
def undo(self):
|
||||
if not live_hooks.live_rename_component(self.element, self.old_name):
|
||||
raise LiveUndoError(f"{self.new_name} was not renamed back to {self.old_name} "
|
||||
"on the device")
|
||||
super().undo()
|
||||
|
||||
def redo(self):
|
||||
if not live_hooks.live_rename_component(self.element, self.new_name):
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
"""The User Manager: a table of sys::User components under a sys::UserService.
|
||||
|
||||
Like the other panels it takes the editor pane over from the canvas rather than
|
||||
floating above it. `main` is what hides the canvas and puts this in its place;
|
||||
this module knows only the frame it was handed.
|
||||
|
||||
Each sys::User component carries three properties:
|
||||
cred — SHA-1 of "username:password" (20 bytes, base64 in the SAX)
|
||||
perm — 32-bit int: four groups × 7 permission flags per group
|
||||
prov — 8-bit byte: app/kits/svm provisioning flags
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont, messagebox, ttk
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import app_state
|
||||
import live_hooks
|
||||
import sax_file
|
||||
import undo
|
||||
from sax_dom import set_prop_value
|
||||
|
||||
HEADER_BG = "#f2f2f2"
|
||||
|
||||
PERM_FLAGS = ["or", "ow", "oi", "ar", "aw", "ai", "ua"]
|
||||
PERM_LABELS = "rwIRWIu"
|
||||
|
||||
PROV_FLAGS = [("app", 0x01), ("kits", 0x02), ("svm", 0x04)]
|
||||
|
||||
|
||||
def _hash_credentials(username, password):
|
||||
"""SHA-1 of 'username:password', the Sedona credential algorithm."""
|
||||
return base64.b64encode(
|
||||
hashlib.sha1(f"{username}:{password}".encode("utf-8")).digest()
|
||||
).decode("ascii")
|
||||
|
||||
|
||||
def _decode_perm(perm_val):
|
||||
"""32-bit int -> list of four 7-element bool lists."""
|
||||
try:
|
||||
val = int(perm_val)
|
||||
except (TypeError, ValueError):
|
||||
val = 0
|
||||
groups = []
|
||||
for g in range(4):
|
||||
byte = (val >> (g * 8)) & 0xFF
|
||||
groups.append([(byte >> bit) & 1 == 1 for bit in range(7)])
|
||||
return groups
|
||||
|
||||
|
||||
def _encode_perm(groups):
|
||||
"""List of four 7-element bool lists -> 32-bit int."""
|
||||
val = 0
|
||||
for g, flags in enumerate(groups):
|
||||
byte = 0
|
||||
for bit, on in enumerate(flags):
|
||||
if on:
|
||||
byte |= (1 << bit)
|
||||
val |= (byte << (g * 8))
|
||||
return val
|
||||
|
||||
|
||||
def _format_perm(perm_val):
|
||||
"""Human-readable: '1=rwIRWIu, 2=rwIRWIu, ...'"""
|
||||
groups = _decode_perm(perm_val)
|
||||
parts = []
|
||||
for g, flags in enumerate(groups):
|
||||
chars = "".join(PERM_LABELS[i] if on else "-" for i, on in enumerate(flags))
|
||||
parts.append(f"{g + 1}={chars}")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def _decode_prov(prov_val):
|
||||
"""8-bit byte -> dict of flag name -> bool."""
|
||||
try:
|
||||
val = int(prov_val)
|
||||
except (TypeError, ValueError):
|
||||
val = 0
|
||||
return {name: bool(val & mask) for name, mask in PROV_FLAGS}
|
||||
|
||||
|
||||
def _encode_prov(flags_dict):
|
||||
val = 0
|
||||
for name, mask in PROV_FLAGS:
|
||||
if flags_dict.get(name):
|
||||
val |= mask
|
||||
return val
|
||||
|
||||
|
||||
def _format_prov(prov_val):
|
||||
"""Human-readable: 'app,kits,svm' for set flags."""
|
||||
flags = _decode_prov(prov_val)
|
||||
active = [name for name, on in flags.items() if on]
|
||||
return ",".join(active) if active else "(none)"
|
||||
|
||||
|
||||
def _get_prop(comp, name):
|
||||
prop = comp.find(f"prop[@name='{name}']")
|
||||
return prop.get("val") if prop is not None else None
|
||||
|
||||
|
||||
def _safe_int(val):
|
||||
if val is None:
|
||||
return 0
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
try:
|
||||
return int(float(val))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _get_users(service_comp):
|
||||
"""Returns child comp elements, excluding uncached placeholders."""
|
||||
return [c for c in service_comp
|
||||
if c.tag == "comp" and c.get("name", "") != "..."]
|
||||
|
||||
|
||||
def _resolve_comp(comp):
|
||||
"""Re-locate a comp element in the current DOM by id (live mode rebuilds the tree)."""
|
||||
root = app_state.xml_root_element
|
||||
if root is None:
|
||||
return comp
|
||||
comp_id = comp.get("id")
|
||||
if comp_id is None:
|
||||
return comp
|
||||
for elem in root.iter("comp"):
|
||||
if elem.get("id") == comp_id:
|
||||
return elem
|
||||
return comp
|
||||
|
||||
|
||||
def is_user_service(comp):
|
||||
"""Whether a component is a sys::UserService (or looks like one in live mode)."""
|
||||
if comp is None:
|
||||
return False
|
||||
type_name = comp.get("type", "")
|
||||
if "UserService" in type_name:
|
||||
return True
|
||||
for child in comp:
|
||||
if child.tag == "comp" and "User" in child.get("type", ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class _PermissionsDialog:
|
||||
"""Workbench-style permissions grid: Groups x Operator/Admin columns."""
|
||||
|
||||
def __init__(self, parent, perm_val):
|
||||
self.result = None
|
||||
groups = _decode_perm(perm_val)
|
||||
|
||||
self._dlg = tk.Toplevel(parent)
|
||||
self._dlg.title("Permissions")
|
||||
self._dlg.transient(parent)
|
||||
self._dlg.grab_set()
|
||||
self._dlg.resizable(False, False)
|
||||
|
||||
grid = tk.Frame(self._dlg, padx=8, pady=8)
|
||||
grid.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
COL_HEADERS = [
|
||||
("Operator", ["Read", "Write", "Invoke"]),
|
||||
("Admin", ["Read", "Write", "Invoke", "User"]),
|
||||
]
|
||||
|
||||
# header row 0: "Groups" + category spans
|
||||
tk.Label(grid, text="Groups", relief=tk.GROOVE, bg="#e8e8e8",
|
||||
padx=4, pady=2).grid(row=0, column=0, rowspan=2, sticky=tk.NSEW)
|
||||
col = 1
|
||||
for cat_name, sub_cols in COL_HEADERS:
|
||||
span = len(sub_cols)
|
||||
tk.Label(grid, text=cat_name, relief=tk.GROOVE, bg="#d8e8f0",
|
||||
font=("TkDefaultFont", 8, "bold"),
|
||||
padx=4, pady=2).grid(row=0, column=col, columnspan=span,
|
||||
sticky=tk.NSEW)
|
||||
col += span
|
||||
|
||||
# header row 1: sub-column names
|
||||
col = 1
|
||||
for _, sub_cols in COL_HEADERS:
|
||||
for sub in sub_cols:
|
||||
tk.Label(grid, text=sub, relief=tk.GROOVE, bg="#e8e8e8",
|
||||
font=("TkDefaultFont", 7), padx=4, pady=1).grid(
|
||||
row=1, column=col, sticky=tk.NSEW)
|
||||
col += 1
|
||||
|
||||
# data rows
|
||||
self._perm_vars = []
|
||||
for g in range(4):
|
||||
r = g + 2
|
||||
tk.Label(grid, text=f"Group {g + 1}", relief=tk.GROOVE,
|
||||
bg="#f4f4f4", padx=4, pady=1).grid(row=r, column=0,
|
||||
sticky=tk.NSEW)
|
||||
gvars = []
|
||||
for f_idx in range(7):
|
||||
var = tk.BooleanVar(value=groups[g][f_idx])
|
||||
var.trace_add("write", lambda *_a, g=g, f=f_idx: self._on_perm_toggle(g, f))
|
||||
ttk.Checkbutton(grid, variable=var).grid(
|
||||
row=r, column=f_idx + 1, padx=6, pady=1)
|
||||
gvars.append(var)
|
||||
self._perm_vars.append(gvars)
|
||||
|
||||
# Clear All / Set All
|
||||
action_frame = tk.Frame(grid)
|
||||
action_frame.grid(row=6, column=0, columnspan=8, sticky=tk.E, pady=(6, 0))
|
||||
ttk.Button(action_frame, text="Clear All", width=8,
|
||||
command=self._clear_all).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(action_frame, text="Set All", width=8,
|
||||
command=self._set_all).pack(side=tk.LEFT, padx=2)
|
||||
|
||||
# OK / Cancel
|
||||
btn_frame = tk.Frame(self._dlg, pady=6)
|
||||
btn_frame.pack()
|
||||
ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
|
||||
side=tk.LEFT, padx=4)
|
||||
ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack(
|
||||
side=tk.LEFT, padx=4)
|
||||
|
||||
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
||||
self._dlg.wait_window()
|
||||
|
||||
def _on_perm_toggle(self, group, flag_idx):
|
||||
"""Auto-cascade: write implies read, admin implies operator."""
|
||||
gvars = self._perm_vars[group]
|
||||
if not gvars[flag_idx].get():
|
||||
return
|
||||
# Flags: 0=or, 1=ow, 2=oi, 3=ar, 4=aw, 5=ai, 6=ua
|
||||
# Write (1) → Read (0); Admin Write (4) → Admin Read (3)
|
||||
if flag_idx in (1, 4):
|
||||
gvars[flag_idx - 1].set(True)
|
||||
# Admin flag (3,4,5) → same Operator flag (0,1,2)
|
||||
if 3 <= flag_idx <= 5:
|
||||
gvars[flag_idx - 3].set(True)
|
||||
|
||||
def _clear_all(self):
|
||||
for gvars in self._perm_vars:
|
||||
for v in gvars:
|
||||
v.set(False)
|
||||
|
||||
def _set_all(self):
|
||||
for gvars in self._perm_vars:
|
||||
for v in gvars:
|
||||
v.set(True)
|
||||
|
||||
def _on_ok(self):
|
||||
groups = [[v.get() for v in gvars] for gvars in self._perm_vars]
|
||||
self.result = _encode_perm(groups)
|
||||
self._dlg.destroy()
|
||||
|
||||
def _on_cancel(self):
|
||||
self._dlg.destroy()
|
||||
|
||||
|
||||
class _ProvisioningDialog:
|
||||
"""Provisioning flags popup: app, kits, svm checkboxes."""
|
||||
|
||||
def __init__(self, parent, prov_val):
|
||||
self.result = None
|
||||
flags = _decode_prov(prov_val)
|
||||
|
||||
self._dlg = tk.Toplevel(parent)
|
||||
self._dlg.title("Provisioning Permissions")
|
||||
self._dlg.transient(parent)
|
||||
self._dlg.grab_set()
|
||||
self._dlg.resizable(False, False)
|
||||
|
||||
main = ttk.Frame(self._dlg, padding=10)
|
||||
main.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
self._prov_vars = {}
|
||||
for name, _ in PROV_FLAGS:
|
||||
var = tk.BooleanVar(value=flags.get(name, False))
|
||||
ttk.Checkbutton(main, text=name, variable=var).pack(
|
||||
anchor=tk.W, pady=2)
|
||||
self._prov_vars[name] = var
|
||||
|
||||
btn_frame = ttk.Frame(main)
|
||||
btn_frame.pack(pady=(10, 0))
|
||||
ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
|
||||
side=tk.LEFT, padx=4)
|
||||
ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack(
|
||||
side=tk.LEFT, padx=4)
|
||||
|
||||
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
||||
self._dlg.wait_window()
|
||||
|
||||
def _on_ok(self):
|
||||
self.result = _encode_prov(
|
||||
{name: var.get() for name, var in self._prov_vars.items()})
|
||||
self._dlg.destroy()
|
||||
|
||||
def _on_cancel(self):
|
||||
self._dlg.destroy()
|
||||
|
||||
|
||||
class _UserDialog:
|
||||
"""Compact Edit/Add User dialog with >> buttons opening Permissions/Provisioning popups."""
|
||||
|
||||
def __init__(self, parent, service_comp, user_elem, on_done):
|
||||
self._service_comp = service_comp
|
||||
self._user_elem = user_elem
|
||||
self._on_done = on_done
|
||||
self._is_edit = user_elem is not None
|
||||
|
||||
self._perm_val = _safe_int(_get_prop(user_elem, "perm")) if self._is_edit else 0
|
||||
self._prov_val = _safe_int(_get_prop(user_elem, "prov")) if self._is_edit else 0
|
||||
|
||||
self._dlg = tk.Toplevel(parent)
|
||||
self._dlg.title("Edit User" if self._is_edit else "Add User")
|
||||
self._dlg.transient(parent)
|
||||
self._dlg.grab_set()
|
||||
self._dlg.resizable(False, False)
|
||||
|
||||
main = ttk.Frame(self._dlg, padding=10)
|
||||
main.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
row = 0
|
||||
ttk.Label(main, text="Username").grid(row=row, column=0, sticky=tk.W, pady=2)
|
||||
self._name_var = tk.StringVar(
|
||||
value=user_elem.get("name", "") if self._is_edit else "")
|
||||
name_entry = ttk.Entry(main, textvariable=self._name_var, width=28)
|
||||
name_entry.grid(row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
|
||||
if self._is_edit:
|
||||
name_entry.config(state="disabled")
|
||||
|
||||
row += 1
|
||||
ttk.Label(main, text="Password").grid(row=row, column=0, sticky=tk.W, pady=2)
|
||||
self._pass_var = tk.StringVar()
|
||||
ttk.Entry(main, textvariable=self._pass_var, show="*", width=28).grid(
|
||||
row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
|
||||
|
||||
row += 1
|
||||
ttk.Label(main, text="Confirm").grid(row=row, column=0, sticky=tk.W, pady=2)
|
||||
self._confirm_var = tk.StringVar()
|
||||
ttk.Entry(main, textvariable=self._confirm_var, show="*", width=28).grid(
|
||||
row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
|
||||
|
||||
# permissions summary + >> button
|
||||
row += 1
|
||||
ttk.Separator(main, orient=tk.HORIZONTAL).grid(
|
||||
row=row, column=0, columnspan=3, sticky=tk.EW, pady=6)
|
||||
|
||||
row += 1
|
||||
ttk.Label(main, text="Permissions").grid(row=row, column=0, sticky=tk.W, pady=2)
|
||||
self._perm_label = tk.StringVar(value=_format_perm(self._perm_val))
|
||||
ttk.Label(main, textvariable=self._perm_label,
|
||||
font=("TkDefaultFont", 8)).grid(
|
||||
row=row, column=1, sticky=tk.W, pady=2)
|
||||
ttk.Button(main, text="»", width=3,
|
||||
command=self._open_permissions).grid(
|
||||
row=row, column=2, sticky=tk.E, pady=2)
|
||||
|
||||
# provisioning summary + >> button
|
||||
row += 1
|
||||
ttk.Label(main, text="Provisioning Permissions").grid(
|
||||
row=row, column=0, sticky=tk.W, pady=2)
|
||||
self._prov_label = tk.StringVar(value=_format_prov(self._prov_val))
|
||||
ttk.Label(main, textvariable=self._prov_label,
|
||||
font=("TkDefaultFont", 8)).grid(
|
||||
row=row, column=1, sticky=tk.W, pady=2)
|
||||
ttk.Button(main, text="»", width=3,
|
||||
command=self._open_provisioning).grid(
|
||||
row=row, column=2, sticky=tk.E, pady=2)
|
||||
|
||||
# OK / Cancel
|
||||
row += 1
|
||||
btn_frame = ttk.Frame(main)
|
||||
btn_frame.grid(row=row, column=0, columnspan=3, pady=(10, 0))
|
||||
ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
|
||||
side=tk.LEFT, padx=4)
|
||||
ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack(
|
||||
side=tk.LEFT, padx=4)
|
||||
|
||||
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
||||
name_entry.focus_set()
|
||||
self._dlg.wait_window()
|
||||
|
||||
def _open_permissions(self):
|
||||
dlg = _PermissionsDialog(self._dlg, self._perm_val)
|
||||
if dlg.result is not None:
|
||||
self._perm_val = dlg.result
|
||||
self._perm_label.set(_format_perm(self._perm_val))
|
||||
|
||||
def _open_provisioning(self):
|
||||
dlg = _ProvisioningDialog(self._dlg, self._prov_val)
|
||||
if dlg.result is not None:
|
||||
self._prov_val = dlg.result
|
||||
self._prov_label.set(_format_prov(self._prov_val))
|
||||
|
||||
def _on_ok(self):
|
||||
username = self._name_var.get().strip()
|
||||
password = self._pass_var.get()
|
||||
confirm = self._confirm_var.get()
|
||||
|
||||
if not username:
|
||||
messagebox.showwarning("Validation", "Username is required.", parent=self._dlg)
|
||||
return
|
||||
if not self._is_edit and not password:
|
||||
messagebox.showwarning("Validation", "Password is required for new users.",
|
||||
parent=self._dlg)
|
||||
return
|
||||
if password and password != confirm:
|
||||
messagebox.showwarning("Validation", "Passwords do not match.", parent=self._dlg)
|
||||
return
|
||||
|
||||
if not self._is_edit:
|
||||
for c in _get_users(self._service_comp):
|
||||
if c.get("name", "") == username:
|
||||
messagebox.showwarning("Validation",
|
||||
f"User '{username}' already exists.",
|
||||
parent=self._dlg)
|
||||
return
|
||||
|
||||
if self._is_edit:
|
||||
self._apply_edit(username, password)
|
||||
else:
|
||||
self._apply_add(username, password)
|
||||
|
||||
self._dlg.destroy()
|
||||
if self._on_done:
|
||||
self._on_done()
|
||||
|
||||
def _apply_add(self, username, password):
|
||||
elem = ET.Element("comp", name=username, type="sys::User")
|
||||
cred = _hash_credentials(username, password)
|
||||
values = {"cred": cred, "perm": str(self._perm_val), "prov": str(self._prov_val)}
|
||||
|
||||
if app_state.live_mode:
|
||||
# One Sox add carrying every config value, so the user never exists on the
|
||||
# device without its password and rights.
|
||||
try:
|
||||
config = sax_file.build_config_values("sys::User", values)
|
||||
except (ValueError, TypeError) as e:
|
||||
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device",
|
||||
f"A value does not fit its slot: {e}")
|
||||
return
|
||||
new_id = live_hooks.live_add_component(
|
||||
self._service_comp, "sys", "User", username, config)
|
||||
if new_id is None:
|
||||
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device")
|
||||
return
|
||||
elem.set("id", str(new_id))
|
||||
|
||||
for name, val in values.items():
|
||||
ET.SubElement(elem, "prop", name=name, val=val)
|
||||
|
||||
if app_state.live_mode:
|
||||
index = len(list(self._service_comp))
|
||||
self._service_comp.append(elem)
|
||||
undo.push(sax_file.LiveComponentCommand(
|
||||
self._service_comp, index, elem, present=True, label="add user"))
|
||||
app_state.mark_dirty()
|
||||
app_state.set_status(f"User '{username}' added to device.")
|
||||
return
|
||||
|
||||
index = len(list(self._service_comp))
|
||||
cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user")
|
||||
cmd.redo()
|
||||
undo.push(cmd)
|
||||
app_state.mark_dirty()
|
||||
|
||||
def _apply_edit(self, username, password):
|
||||
user = self._user_elem
|
||||
changes = []
|
||||
|
||||
new_perm = str(self._perm_val)
|
||||
old_perm = _get_prop(user, "perm")
|
||||
if new_perm != old_perm:
|
||||
changes.append((user, "perm", old_perm, new_perm))
|
||||
|
||||
new_prov = str(self._prov_val)
|
||||
old_prov = _get_prop(user, "prov")
|
||||
if new_prov != old_prov:
|
||||
changes.append((user, "prov", old_prov, new_prov))
|
||||
|
||||
if password:
|
||||
new_cred = _hash_credentials(username, password)
|
||||
old_cred = _get_prop(user, "cred")
|
||||
if new_cred != old_cred:
|
||||
changes.append((user, "cred", old_cred, new_cred))
|
||||
|
||||
if not changes:
|
||||
return
|
||||
|
||||
if app_state.live_mode:
|
||||
failures = []
|
||||
applied = []
|
||||
for change in changes:
|
||||
elem, prop, _old, new = change
|
||||
if live_hooks.live_set_prop_value(elem, prop, new):
|
||||
set_prop_value(elem, prop, new)
|
||||
applied.append(change)
|
||||
app_state.mark_dirty()
|
||||
else:
|
||||
failures.append(f"{prop}: {live_hooks.last_error}")
|
||||
if applied:
|
||||
undo.push(undo.LiveSlotValueCommand(applied, label="edit user"))
|
||||
if failures:
|
||||
live_hooks.warn_failure(
|
||||
"Edit User",
|
||||
f"{len(failures)} of {len(changes)} change(s) to user '{username}' did "
|
||||
"not reach the device and were not applied",
|
||||
"\n".join(f" • {failure}" for failure in failures),
|
||||
)
|
||||
else:
|
||||
app_state.set_status(f"User '{username}' updated on device.")
|
||||
return
|
||||
|
||||
cmd = undo.SlotValueCommand(changes, label="edit user")
|
||||
cmd.redo()
|
||||
undo.push(cmd)
|
||||
app_state.mark_dirty()
|
||||
|
||||
def _on_cancel(self):
|
||||
self._dlg.destroy()
|
||||
|
||||
|
||||
class UserServicePanel:
|
||||
"""Table of users under a sys::UserService, with Add/Edit/Delete."""
|
||||
|
||||
def __init__(self, parent, comp, on_change=None, on_close=None):
|
||||
self.comp = comp
|
||||
self.on_change = on_change
|
||||
self.on_close = on_close
|
||||
self.dirty = False
|
||||
self.frame = tk.Frame(parent)
|
||||
|
||||
self._build_header()
|
||||
self._build_table()
|
||||
self._build_footer()
|
||||
self._refresh_table()
|
||||
|
||||
def _build_header(self):
|
||||
header = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG)
|
||||
header.pack(fill=tk.X)
|
||||
|
||||
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
|
||||
plain_font = tkfont.Font(family="Segoe UI", size=9)
|
||||
|
||||
tk.Label(header, text="User Manager", font=name_font,
|
||||
bg=HEADER_BG).pack(side=tk.LEFT)
|
||||
tk.Label(header, text=f"— {self.comp.get('name', '?')}",
|
||||
font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.LEFT,
|
||||
padx=(8, 0))
|
||||
self.count_var = tk.StringVar(value="")
|
||||
tk.Label(header, textvariable=self.count_var, font=plain_font, fg="#555555",
|
||||
bg=HEADER_BG).pack(side=tk.RIGHT)
|
||||
|
||||
def _build_table(self):
|
||||
area = tk.Frame(self.frame)
|
||||
area.pack(fill=tk.BOTH, expand=True, padx=8, pady=(4, 0))
|
||||
|
||||
columns = ("username", "permissions", "provisioning")
|
||||
self.table = ttk.Treeview(area, columns=columns, show="headings",
|
||||
selectmode="browse")
|
||||
self.table.heading("username", text="Username")
|
||||
self.table.heading("permissions", text="Group Permissions")
|
||||
self.table.heading("provisioning", text="Provisioning")
|
||||
|
||||
self.table.column("username", width=120, minwidth=80)
|
||||
self.table.column("permissions", width=320, minwidth=200)
|
||||
self.table.column("provisioning", width=120, minwidth=80)
|
||||
|
||||
vscroll = ttk.Scrollbar(area, orient=tk.VERTICAL, command=self.table.yview)
|
||||
self.table.configure(yscrollcommand=vscroll.set)
|
||||
|
||||
self.table.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
self.table.bind("<Double-1>", lambda _e: self._on_edit())
|
||||
self._user_elements = {}
|
||||
|
||||
def _build_footer(self):
|
||||
footer = tk.Frame(self.frame, padx=8, pady=6)
|
||||
footer.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
|
||||
ttk.Button(footer, text="Close", width=9,
|
||||
command=self.close).pack(side=tk.RIGHT)
|
||||
ttk.Button(footer, text="Delete User", width=12,
|
||||
command=self._on_delete).pack(side=tk.RIGHT, padx=(0, 6))
|
||||
ttk.Button(footer, text="Edit User", width=12,
|
||||
command=self._on_edit).pack(side=tk.RIGHT, padx=(0, 6))
|
||||
ttk.Button(footer, text="Add User", width=12,
|
||||
command=self._on_add).pack(side=tk.RIGHT, padx=(0, 6))
|
||||
|
||||
def _refresh_table(self):
|
||||
for item in self.table.get_children():
|
||||
self.table.delete(item)
|
||||
self._user_elements.clear()
|
||||
|
||||
self.comp = _resolve_comp(self.comp)
|
||||
users = _get_users(self.comp)
|
||||
for user_elem in users:
|
||||
name = user_elem.get("name", "?")
|
||||
perm = _get_prop(user_elem, "perm")
|
||||
prov = _get_prop(user_elem, "prov")
|
||||
|
||||
iid = self.table.insert("", tk.END, values=(
|
||||
name,
|
||||
_format_perm(perm),
|
||||
_format_prov(prov),
|
||||
))
|
||||
self._user_elements[iid] = user_elem
|
||||
|
||||
self.count_var.set(f"{len(users)} user{'' if len(users) == 1 else 's'}")
|
||||
|
||||
def refresh(self):
|
||||
self._refresh_table()
|
||||
|
||||
def _selected_user(self):
|
||||
sel = self.table.selection()
|
||||
if not sel:
|
||||
return None, None
|
||||
return sel[0], self._user_elements.get(sel[0])
|
||||
|
||||
def _after_change(self):
|
||||
self._refresh_table()
|
||||
if self.on_change:
|
||||
self.on_change()
|
||||
|
||||
def _on_add(self):
|
||||
_UserDialog(self.frame.winfo_toplevel(), self.comp, None, self._after_change)
|
||||
|
||||
def _on_edit(self):
|
||||
iid, user_elem = self._selected_user()
|
||||
if user_elem is None:
|
||||
messagebox.showinfo("Edit User", "Select a user first.",
|
||||
parent=self.frame.winfo_toplevel())
|
||||
return
|
||||
_UserDialog(self.frame.winfo_toplevel(), self.comp, user_elem, self._after_change)
|
||||
|
||||
def _on_delete(self):
|
||||
iid, user_elem = self._selected_user()
|
||||
if user_elem is None:
|
||||
messagebox.showinfo("Delete User", "Select a user first.",
|
||||
parent=self.frame.winfo_toplevel())
|
||||
return
|
||||
|
||||
name = user_elem.get("name", "?")
|
||||
if not messagebox.askyesno("Delete User", f"Delete user '{name}'?",
|
||||
parent=self.frame.winfo_toplevel()):
|
||||
return
|
||||
|
||||
if app_state.live_mode:
|
||||
# Read before it goes: once deleted, the device has nothing left to read back.
|
||||
snapshot = live_hooks.read_subtree(user_elem)
|
||||
unreadable = live_hooks.last_error if snapshot is None else ""
|
||||
if not live_hooks.live_delete_component(user_elem):
|
||||
live_hooks.warn_failure("Delete User", f"User '{name}' was not deleted from the device")
|
||||
return
|
||||
index = list(self.comp).index(user_elem)
|
||||
self.comp.remove(user_elem)
|
||||
app_state.mark_dirty()
|
||||
if snapshot is not None:
|
||||
undo.push(sax_file.LiveComponentCommand(
|
||||
self.comp, index, user_elem, present=False, snapshot=snapshot,
|
||||
label="delete user"))
|
||||
app_state.set_status(f"User '{name}' deleted from device.")
|
||||
else:
|
||||
app_state.set_status(
|
||||
f"User '{name}' was deleted from the device, but this delete cannot be "
|
||||
f"undone: {unreadable}", warn=True)
|
||||
self._after_change()
|
||||
return
|
||||
|
||||
index = list(self.comp).index(user_elem)
|
||||
cmd = undo.AddComponentCommand(self.comp, index, user_elem, label="delete user")
|
||||
cmd.undo()
|
||||
undo.push(cmd)
|
||||
app_state.mark_dirty()
|
||||
self._after_change()
|
||||
|
||||
def close(self):
|
||||
if self.on_close is not None:
|
||||
self.on_close()
|
||||
return True
|
||||
|
||||
def destroy(self):
|
||||
if self.frame.winfo_exists():
|
||||
self.frame.destroy()
|
||||
|
||||
|
||||
def build_user_service_panel(parent, comp, on_change=None, on_close=None):
|
||||
if comp is None or parent is None:
|
||||
return None
|
||||
return UserServicePanel(parent, comp, on_change=on_change, on_close=on_close)
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
"""How a routed wire looks: rounded bends, hops where wires cross, dots where they split.
|
||||
|
||||
The router in wiresheet.py hands over orthogonal polylines and nothing else. Everything
|
||||
here is pure geometry on those point lists -- no canvas, no app state -- so the styling
|
||||
can change without touching how wires find their way around the boxes.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
CORNER_RADIUS = 6 # bend radius, shrunk on short segments so two bends never overlap
|
||||
CORNER_STEPS = 5 # points per bend; enough to read as a curve at every zoom level
|
||||
HOP_HALF_WIDTH = 4 # a hop spans this far either side of the wire it jumps over
|
||||
HOP_HEIGHT = 4
|
||||
HOP_STEPS = 6
|
||||
HOP_MERGE_GAP = 2 # hops closer than this run together into one wider hop
|
||||
HOP_END_MARGIN = 10 # no hop this close to a target, where the arrowhead sits
|
||||
JUNCTION_RADIUS = 3
|
||||
|
||||
EPS = 0.5
|
||||
|
||||
|
||||
def simplify(points):
|
||||
"""Drops repeated points and the middle of any straight run.
|
||||
|
||||
The router emits a fixed number of points per route shape, so a path often carries a
|
||||
"bend" that does not turn at all. Rounding one of those would put a kink in a
|
||||
straight line.
|
||||
"""
|
||||
out = []
|
||||
for point in points:
|
||||
if out and _same(out[-1], point):
|
||||
continue
|
||||
if len(out) >= 2 and _collinear(out[-2], out[-1], point):
|
||||
out[-1] = point
|
||||
else:
|
||||
out.append(point)
|
||||
return out
|
||||
|
||||
|
||||
def find_hops(paths):
|
||||
"""Where each wire has to hop: one dict per path, segment index -> crossing xs.
|
||||
|
||||
A horizontal run hops over a vertical run of a different wire it crosses. Only true
|
||||
crossings count: a wire touching another at a bend or an end is a junction, and a
|
||||
hop there would hide it.
|
||||
"""
|
||||
horizontals, verticals = [], []
|
||||
for path_index, points in enumerate(paths):
|
||||
for seg_index, (a, b) in enumerate(zip(points, points[1:])):
|
||||
if abs(a[1] - b[1]) < EPS:
|
||||
horizontals.append((path_index, seg_index, a[1],
|
||||
min(a[0], b[0]), max(a[0], b[0])))
|
||||
else:
|
||||
verticals.append((path_index, a[0], min(a[1], b[1]), max(a[1], b[1])))
|
||||
|
||||
hops = [{} for _ in paths]
|
||||
for path_index, seg_index, y, x1, x2 in horizontals:
|
||||
for other, x, y1, y2 in verticals:
|
||||
if other == path_index:
|
||||
continue
|
||||
if x1 + EPS < x < x2 - EPS and y1 + EPS < y < y2 - EPS:
|
||||
hops[path_index].setdefault(seg_index, []).append(x)
|
||||
return hops
|
||||
|
||||
|
||||
def fan_out_junctions(paths_by_owner):
|
||||
"""Points where wires leaving one output pin go their separate ways.
|
||||
|
||||
Every pair of wires from the same pin shares a run out of it, and some share a trunk
|
||||
further on. Where the two stop running together is a branch, and a branch without a
|
||||
dot reads as two wires merely crossing.
|
||||
"""
|
||||
points = set()
|
||||
for paths in paths_by_owner.values():
|
||||
for i, first in enumerate(paths):
|
||||
for second in paths[i + 1:]:
|
||||
split = _divergence(first, second)
|
||||
if split is None:
|
||||
continue
|
||||
if any(_same(split, end) for end in (first[0], first[-1], second[-1])):
|
||||
continue
|
||||
points.add((round(split[0], 1), round(split[1], 1)))
|
||||
return points
|
||||
|
||||
|
||||
def styled_points(points, hops=None):
|
||||
"""The points to draw for a simplified orthogonal path, bends rounded and hops in."""
|
||||
hops = hops or {}
|
||||
count = len(points)
|
||||
if count < 3:
|
||||
return list(points)
|
||||
|
||||
lengths = [_length(a, b) for a, b in zip(points, points[1:])]
|
||||
radii = [0.0] * count
|
||||
for i in range(1, count - 1):
|
||||
radii[i] = min(CORNER_RADIUS, lengths[i - 1] / 2, lengths[i] / 2)
|
||||
|
||||
out = [points[0]]
|
||||
for i in range(count - 1):
|
||||
a, b = points[i], points[i + 1]
|
||||
ux, uy = _unit(a, b)
|
||||
start = (a[0] + ux * radii[i], a[1] + uy * radii[i])
|
||||
end = (b[0] - ux * radii[i + 1], b[1] - uy * radii[i + 1])
|
||||
|
||||
if i in hops:
|
||||
end_margin = HOP_END_MARGIN if i == count - 2 else 0
|
||||
out.extend(_hop_points(start, end, hops[i], end_margin))
|
||||
out.append(end)
|
||||
|
||||
if i + 1 < count - 1:
|
||||
out.extend(_corner_points(end, b, points[i + 2], radii[i + 1]))
|
||||
return out
|
||||
|
||||
|
||||
# --- helpers ---
|
||||
|
||||
def _same(p, q):
|
||||
return abs(p[0] - q[0]) < EPS and abs(p[1] - q[1]) < EPS
|
||||
|
||||
|
||||
def _collinear(p, q, r):
|
||||
return ((abs(p[0] - q[0]) < EPS and abs(q[0] - r[0]) < EPS)
|
||||
or (abs(p[1] - q[1]) < EPS and abs(q[1] - r[1]) < EPS))
|
||||
|
||||
|
||||
def _length(a, b):
|
||||
return abs(b[0] - a[0]) + abs(b[1] - a[1]) # orthogonal, so one term is zero
|
||||
|
||||
|
||||
def _unit(a, b):
|
||||
length = _length(a, b)
|
||||
if length < EPS:
|
||||
return 0.0, 0.0
|
||||
return (b[0] - a[0]) / length, (b[1] - a[1]) / length
|
||||
|
||||
|
||||
def _corner_points(entry, corner, after, radius):
|
||||
"""A bend from `entry` round `corner` towards `after`, as a quadratic curve."""
|
||||
if radius < EPS:
|
||||
return []
|
||||
vx, vy = _unit(corner, after)
|
||||
leave = (corner[0] + vx * radius, corner[1] + vy * radius)
|
||||
out = []
|
||||
for step in range(1, CORNER_STEPS + 1):
|
||||
t = step / CORNER_STEPS
|
||||
s = 1 - t
|
||||
out.append((s * s * entry[0] + 2 * s * t * corner[0] + t * t * leave[0],
|
||||
s * s * entry[1] + 2 * s * t * corner[1] + t * t * leave[1]))
|
||||
return out
|
||||
|
||||
|
||||
def _hop_points(start, end, xs, end_margin):
|
||||
"""The bumps along a horizontal run from `start` to `end`, in the order travelled."""
|
||||
y = start[1]
|
||||
direction = 1 if end[0] >= start[0] else -1
|
||||
lo = min(start[0], end[0]) + HOP_HALF_WIDTH + 1
|
||||
hi = max(start[0], end[0]) - HOP_HALF_WIDTH - 1
|
||||
if direction > 0:
|
||||
hi -= end_margin
|
||||
else:
|
||||
lo += end_margin
|
||||
|
||||
ordered = sorted((x for x in xs if lo <= x <= hi), key=lambda x: x * direction)
|
||||
groups = []
|
||||
for x in ordered:
|
||||
if groups and abs(x - groups[-1][-1]) < 2 * HOP_HALF_WIDTH + HOP_MERGE_GAP:
|
||||
groups[-1].append(x)
|
||||
else:
|
||||
groups.append([x])
|
||||
|
||||
out = []
|
||||
for group in groups:
|
||||
x_from = group[0] - direction * HOP_HALF_WIDTH
|
||||
x_to = group[-1] + direction * HOP_HALF_WIDTH
|
||||
for step in range(HOP_STEPS + 1):
|
||||
t = step / HOP_STEPS
|
||||
out.append((x_from + (x_to - x_from) * t, y - HOP_HEIGHT * math.sin(math.pi * t)))
|
||||
return out
|
||||
|
||||
|
||||
def _divergence(first, second):
|
||||
"""The last point two paths from the same start share before they part, or None."""
|
||||
if not _same(first[0], second[0]):
|
||||
return None
|
||||
pos = first[0]
|
||||
i = j = 0
|
||||
while i < len(first) - 1 and j < len(second) - 1:
|
||||
d1, d2 = _unit(pos, first[i + 1]), _unit(pos, second[j + 1])
|
||||
if _same(pos, first[i + 1]):
|
||||
i += 1
|
||||
continue
|
||||
if _same(pos, second[j + 1]):
|
||||
j += 1
|
||||
continue
|
||||
if not _same(d1, d2):
|
||||
return pos
|
||||
step = min(_length(pos, first[i + 1]), _length(pos, second[j + 1]))
|
||||
pos = (pos[0] + d1[0] * step, pos[1] + d1[1] * step)
|
||||
return pos
|
||||
+1473
-362
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user