From 2db230dec14d111bf098aa67cb793a6e27c6d42a Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Sat, 5 Sep 2026 23:38:03 +0300 Subject: [PATCH] Properties view: every slot of a component, edited in the editor pane Right-click a component on the wiresheet or in the tree and Properties takes over the editor pane, listing every slot the manifest declares rather than the handful a box paints. Config slots that are not @readonly are white and editable; everything else is grey. Rows are built from the slot's facets, read against SedonaDoc/niagara.html: @trueText/@falseText, @range, @precision, @unit, and @min/@max, which bound what Save accepts. Three facets it had wrong: - null is a value, not an absence. control::WriteFloat pairs 'in' with a setNull action and the sample app stores val="null" on four of them, so null now shows, parses back, and reads as @nullText where declared. - @min/@max on a Buf count characters. TcpDevice.ipAddr is a Buf(17) with @max 16; a longer string is refused rather than overrunning the device. - an int's implicit range is noise. [-2147483648 - 2147483647] said no more than "a whole number", so only byte and short print a range they did not declare. meta gets a row of its own: the '>>' opens its user groups as checkboxes and its position as x and y, since typing 822411269 into a field sets neither. Save writes only the rows that moved, as one undoable SlotValueCommand, and refuses the lot if any row fails its range check. An edited field paints the view in the canvas's own dirty tint, and every way of leaving asks before discarding. The file name on the tree's root row goes red while anything is unwritten, counting both a dirty document and unsaved fields. Two things this needed: the nav tree runs on its own style, because a themed Treeview's selected foreground beats a row tag's; and the tag is added to or removed from the row rather than recoloured, because reconfiguring a tag does not repaint rows that are already drawn. Also fixes a latent hazard the panel exposed: Del and Backspace are bound application-wide, so they now stand down while a text field has focus rather than deleting the component behind it. Co-Authored-By: Claude Opus 5 --- README.md | 210 +++++++++- app_state.py | 45 ++- main.py | 217 +++++++++- properties.py | 1075 +++++++++++++++++++++++++++++++++++++++++++++++++ sax_dom.py | 57 +++ sax_file.py | 2 + schema.py | 52 ++- undo.py | 32 +- 8 files changed, 1675 insertions(+), 15 deletions(-) create mode 100644 properties.py diff --git a/README.md b/README.md index ec9e0cd..261a84c 100644 --- a/README.md +++ b/README.md @@ -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.048** (see [Versioning](#versioning)). ## Requirements @@ -49,13 +49,14 @@ not inside the window. Tk always hands it to the system menu bar. | `schema.py` | Kit manifest loading, slot inheritance, slot visibility rules | no | | `undo.py` | Undo/redo stack of reversible DOM commands | 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 | | `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`, and `main` wires it +together. Event handlers live in `main`, which is what keeps the graph acyclic. ### Shared state @@ -70,7 +71,8 @@ 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 `` entry placing it may have added) and `DeleteComponentCommand` (which restores the removed `` 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). 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. @@ -131,11 +133,183 @@ walk cannot rewrite the trail it is following. ### The component popup Right-clicking a component offers the same actions from the wiresheet and from the -navigation tree -- 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 +navigation tree -- Properties, 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 Properties view + +**Properties**, from either component popup, **takes over the editor pane** -- the sheet +and the slot table are the same piece of screen, so only one of them is ever the thing +being looked at. 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. + +Choosing a node in the tree means "show me that sheet", so it stands the view down rather +than leaving the selection looking like it did nothing. So does deleting the component it +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`. + +It lists **every** slot the manifest declares, +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 +`` 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. + +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. + +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 -- in a dropdown, with a red or green dot beside 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 + `` 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 ``, 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. + +### 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. + ### Following a link to its other sheet Right-clicking a wire or a knob offers **Go to Link Wire Sheet ""** alongside Delete @@ -250,6 +424,27 @@ 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 | +|---|---|---| +| `hidden` | 129 slots, every one a `void` action such as `HdwAI.checkType` | The doc says user interfaces should rarely display hidden slots, and the reference tool appears not to. This view shows every slot because that is what it is for; say the word and it will skip them. | +| `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. @@ -266,4 +461,5 @@ step, since they are not part of the dragged canvas group. 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. +- Slot values are edited from the Properties view only; a wiresheet box row is still + display-only. diff --git a/app_state.py b/app_state.py index d6a10ab..8e3052a 100644 --- a/app_state.py +++ b/app_state.py @@ -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.036" +VERSION = "0.0.0.048" 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,43 @@ 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 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" + +# 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" @@ -148,13 +179,20 @@ 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") + canvas.config(bg=DIRTY_TINT) + refresh_dirty_display() def mark_clean(): @@ -162,7 +200,8 @@ def mark_clean(): global is_dirty is_dirty = False if canvas is not None: - canvas.config(bg="white") + canvas.config(bg=CLEAN_TINT) + refresh_dirty_display() SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH, LEFT_PANE_WIDTH = load_preferences() diff --git a/main.py b/main.py index 13d5952..2f1fe51 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,7 @@ from tkinter import filedialog, font as tkfont, messagebox, ttk import app_state import manage_kits import palette +import properties import sax_file import schema import undo @@ -36,6 +37,10 @@ MIN_WINDOW_HEIGHT = 300 # children, so a session touches few of them; the cap only stops an unbounded list. HISTORY_LIMIT = 50 +# Tk class names of the widgets that own the keyboard while they have focus. Del and +# Backspace mean "edit this text" in each of them, never "delete the selected component". +TEXT_ENTRY_CLASSES = ("Entry", "TEntry", "TCombobox", "Text", "Spinbox", "TSpinbox") + MENU_BACK_LABEL = "Back" MENU_FORWARD_LABEL = "Forward" @@ -57,12 +62,167 @@ zoom_label = None menu_view = None show_left_column_var = None +# The Properties view takes the editor pane over from the canvas rather than floating +# above it, so at most one of them is on screen and this is which. None means the sheet. +properties_panel = None + +# The sheet to put the tree back on when a sheet change is refused because of unsaved +# edits, and the flag that stops the restoring selection from being read as a fresh +# choice. The element is held rather than the tree item id, for the same reason the +# history holds elements: a rebuild of the tree hands out fresh ids. +properties_return_sheet = None +_restoring_selection = False + + +def anything_unwritten(): + """Whether there is a change no one has written yet, in the document or in a field. + + The Properties view counts before its Save: a typed value is as unwritten as a + dragged box, and a file name that only went red on the second of those would be + telling half the story. + """ + if app_state.is_dirty: + return True + return properties_panel is not None and properties_panel.dirty + + +def refresh_dirty_display(): + """Puts the file name on the tree's root row in red, or back to its usual colour. + + The tag is added to or taken off the row rather than having its colour reconfigured. + Reconfiguring a tag does not reliably repaint rows already on screen, so the name + only turned red on the paths that happened to rebuild the tree -- a Properties Save + did, dragging a box did not. + """ + tree = app_state.tree + if tree is None: + return + + rows = tree.get_children() + if not rows: + return + + root_row = rows[0] + wanted = (app_state.DIRTY_NAME_TAG,) if anything_unwritten() else () + if tuple(tree.item(root_row, "tags")) != wanted: + tree.item(root_row, tags=wanted) + def refresh_all_views(): """Rebuilds tree and canvas from the DOM. Registered as undo/redo's view refresh.""" sax_file.refresh_treeview_from_dom(app_state.xml_root_element) wiresheet.render_current_sheet() + # An undo or a cascading delete can take the component the Properties view is showing + # out of the document underneath it. Nothing it could display then would be true. + if properties_panel is not None and not _component_in_document(properties_panel.comp): + close_properties(force=True) + + +def _component_in_document(comp): + root = app_state.xml_root_element + return root is not None and any(element is comp for element in root.iter()) + + +def delete_component(comp): + """Deletes a component, taking its Properties view down with it if one is showing. + + Every delete in the UI comes through here. sax_file does its own view refresh rather + than going through refresh_all_views, so the panel would otherwise be left describing + a component the document no longer has. + """ + showing = properties_panel is not None and properties_panel.comp is comp + if sax_file.execute_cascading_deletion(comp) and showing: + close_properties(force=True) + + +def show_properties(comp): + """Puts one component's slots in the editor pane, in place of the wiresheet. + + The canvas is only unpacked, never destroyed, so the sheet keeps its items and its + scroll position and comes back exactly as it was. It is still painted while it is + hidden -- a Save here repaints it -- which costs nothing and means there is no + catching up to do when it returns. + """ + global properties_panel, properties_return_sheet + if comp is None: + return + + # Right-clicking a second component is a way of leaving the first, so it asks about + # unsaved edits exactly as Close does, and a refusal leaves the first one showing. + if not close_properties(): + return + + properties_return_sheet = app_state.current_sheet_parent + + canvas_vscroll.pack_forget() + canvas_hscroll.pack_forget() + app_state.canvas.pack_forget() + + properties_panel = properties.build_properties_panel( + frame_editor, comp, on_change=refresh_all_views, on_close=dismiss_properties, + on_dirty_change=refresh_dirty_display, + ) + properties_panel.frame.pack(fill=tk.BOTH, expand=True) + app_state.set_status(f"Properties: {comp.get('name', '?')}") + + +def dismiss_properties(restore_canvas=True): + """Takes the Properties view down and gives the pane back, asking nothing. + + The panel's own Close is what asks; by the time it calls this the question has been + put and answered. Anything that has made the view moot -- the component deleted, the + file closed -- comes straight here too. + """ + global properties_panel + if properties_panel is None: + return + + properties_panel.destroy() + properties_panel = None + refresh_dirty_display() + + if restore_canvas: + # Repacked in the order the pane was built in, so the scrollbars keep their edges. + canvas_vscroll.pack(side=tk.RIGHT, fill=tk.Y) + canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X) + app_state.canvas.pack(fill=tk.BOTH, expand=True) + app_state.clear_status() + + +def close_properties(force=False): + """Asks about unsaved edits, then takes the view down. True if the pane is now free. + + Every way of leaving the Properties view goes through this, so "you have not saved + that" is asked once, in one place, whether the user pressed Close, picked another + sheet, walked the history or opened a different component. + """ + if properties_panel is None: + return True + if force: + dismiss_properties() + return True + return bool(properties_panel.close()) + + +def _restore_tree_selection(): + """Puts the tree back on the sheet that is actually showing, after a refused change.""" + global _restoring_selection + item_id = _tree_item_for_element(properties_return_sheet) + if item_id is None: + return + + _restoring_selection = True + app_state.tree.selection_set(item_id) + # ttk queues its selection event rather than dispatching it, so the flag has to + # outlive this call or the restoring selection is read as a fresh choice. + app_state.window.after_idle(_clear_restoring_flag) + + +def _clear_restoring_flag(): + global _restoring_selection + _restoring_selection = False + def do_undo(event=None): label = undo.undo() @@ -218,6 +378,16 @@ def on_canvas_wheel_zoom(event): def on_tree_select(event): """Triggered when a branch node is clicked in the Navigation view.""" + if _restoring_selection: + return + + # Choosing a node means "show me that sheet", so the Properties view stands down + # rather than leaving the selection looking like it did nothing. Refusing to leave + # has to put the tree back on the sheet that is still showing. + if not close_properties(): + _restore_tree_selection() + return + tree = app_state.tree selected_items = tree.selection() if not selected_items: @@ -380,6 +550,19 @@ def select_link_on_open_sheet(link_elem): def delete_selected_component(event=None): """Deletes the highlighted canvas component (or selected link) and all its descendants recursively.""" + # Del is bound application-wide, so it also reaches a Properties field being typed in + # and every other window. A keystroke aimed at a text field must not delete a + # component, and neither must one pressed while the sheet is not even on screen. + if properties_panel is not None: + return + + focused = app_state.window.focus_get() + if focused is not None: + if focused.winfo_toplevel() is not app_state.window: + return + if focused.winfo_class() in TEXT_ENTRY_CLASSES: + return + if app_state.selected_link_tag: delete_selected_link() return @@ -389,7 +572,7 @@ def delete_selected_component(event=None): comp_element = app_state.canvas_comp_map.get(app_state.selected_canvas_tag) if comp_element is not None: - sax_file.execute_cascading_deletion(comp_element) + delete_component(comp_element) def on_tree_right_click(event): @@ -676,9 +859,14 @@ def fill_component_popup(menu, comp): that was actually clicked. """ menu.delete(0, tk.END) + menu.add_command( + label="Properties", + command=lambda: show_properties(comp), + ) + menu.add_separator() menu.add_command( label="Delete Component", - command=lambda: sax_file.execute_cascading_deletion(comp), + command=lambda: delete_component(comp), ) menu.add_separator() menu.add_command(label="Link Mark", command=lambda: set_link_mark(comp)) @@ -1072,6 +1260,8 @@ def _show_sheet_history_entry(position): def go_back(event=None): """View menu and toolbar: step to the previously opened sheet.""" + if not close_properties(): + return "break" _prune_sheet_history() if history_index > 0: _show_sheet_history_entry(history_index - 1) @@ -1082,6 +1272,8 @@ def go_back(event=None): def go_forward(event=None): """View menu and toolbar: step back towards the most recently opened sheet.""" + if not close_properties(): + return "break" _prune_sheet_history() if 0 <= history_index < len(sheet_history) - 1: _show_sheet_history_entry(history_index + 1) @@ -1099,6 +1291,13 @@ def on_tree_filled(): """ global _history_file_path + # A different file has no such component, and a rebuild after a delete may have taken + # it. Neither is a moment to ask about edits that now have nowhere to go. + if properties_panel is not None and not _component_in_document(properties_panel.comp): + close_properties(force=True) + + # The root row is new after every rebuild, so its colour has to be reapplied. + refresh_dirty_display() fit_tree_column(app_state.tree) if app_state.current_file_path != _history_file_path: @@ -1536,12 +1735,25 @@ left_pane = ttk.PanedWindow(paned_window, orient=tk.VERTICAL) frame_tree = ttk.Frame(left_pane) +_nav_style = ttk.Style() +_nav_style.map( + app_state.NAV_TREE_STYLE, + # No ("selected", ...) entry: without one the row keeps whatever foreground its tag + # gives it, which is the whole point. + foreground=[("disabled", "SystemGrayText")], + background=[("disabled", "SystemButtonFace"), + ("selected", app_state.NAV_SELECTION_BG)], +) + tree = ttk.Treeview( frame_tree, show="tree", selectmode="browse", height=TREE_REQUESTED_ROWS, + style=app_state.NAV_TREE_STYLE, ) +# Configured once, for the life of the tree. Only the row's tags change after this. +tree.tag_configure(app_state.DIRTY_NAME_TAG, foreground=app_state.DIRTY_NAME_COLOUR) app_state.tree = tree attach_tree_scrollbars(frame_tree, tree) tree.bind("<>", on_tree_select) @@ -1777,6 +1989,7 @@ canvas.bind("", do_zoom_out) paned_window.add(frame_editor, weight=3) +app_state.dirty_display_callback = refresh_dirty_display undo.set_refresh_callback(refresh_all_views) sax_file.set_tree_filled_callback(on_tree_filled) refresh_palette_kits() diff --git a/properties.py b/properties.py new file mode 100644 index 0000000..86190fb --- /dev/null +++ b/properties.py @@ -0,0 +1,1075 @@ +"""The Properties view: every slot of one component, shown, edited and written back. + +It takes over the editor pane rather than floating above it, so the sheet and the slot +table 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. + +The wiresheet box hides most of a type's slots — `HvacControl` paints 8 of its 47 — which +is right for a diagram and wrong for the one place a value is meant to be changed. This +view is that place, so it filters nothing: every slot the manifest declares gets a row, +numbered with the same manifest slot id the box uses, so the two agree about what row 14 is. + +Which rows are editable is a property of the slot, not of the view: + + config (flags 'c') and not '@readonly' -> editable, white field + everything else -> read-only, grey field + +Runtime slots hold live values that no offline file carries, so nothing here can usefully +write one; a '@readonly' config slot is the manifest saying the same thing about a value +that is persisted. Both read the same way on screen — grey means "look, do not touch". + +Nothing is written until Save, which pushes every changed slot as one undoable step. +Refresh throws away pending edits and re-reads the DOM. +""" + +import tkinter as tk +from tkinter import font as tkfont, messagebox, ttk + +import app_state +import schema +import undo +from sax_dom import (get_component_path, read_meta_property, set_prop_value, + update_meta_groups, update_meta_property) + +# Sedona slot types, grouped by how a value of that type is parsed and bounded. +INTEGER_TYPES = {"byte", "short", "int", "long"} +FLOAT_TYPES = {"float", "double"} +STRING_TYPES = {"sys::Buf", "str", "Str"} + +# What an integer type can hold. Every one of these bounds what Save will accept. +TYPE_RANGES = { + "byte": (0, 255), + "short": (0, 65535), + "int": (-2147483648, 2147483647), + "long": (-9223372036854775808, 9223372036854775807), +} + +# ...but only these two are worth *printing* as a range when the manifest declared none. +# 'byte' and 'short' are narrow unsigned types, so [0 - 255] and [0 - 65535] tell a reader +# something real. An undeclared 'int' or 'long' would print its full two's-complement +# span, which says no more than "a whole number" and crowds the row to say it. +IMPLICIT_RANGE_TYPES = {"byte", "short"} + +# Unit facets carry Niagara's unit names; these are the ones the kits under Sedona home +# actually use. An unknown name is shown verbatim rather than dropped, so a kit adding a +# unit still reads sensibly. +UNIT_SYMBOLS = { + "celsius": "°C", + "fahrenheit": "°F", + "kelvin": "K", + "second": "s", + "millisecond": "ms", + "nanosecond": "ns", + "minute": "min", + "hour": "h", + "percent": "%", + "percent_relative_humidity": "%RH", + "pascal": "Pa", + "ohm": "Ω", + "volt": "V", + "hertz": "Hz", + "lux": "lx", + "degrees_angular": "°", + "cubic_meters_per_hour": "m³/h", + "kilojoules_per_kilogram": "kJ/kg", + "grams_per_kilogram": "g/kg", + "grams_per_cubic_meter": "g/m³", + "per_second": "/s", + "per_minute": "/min", +} + +# Row colours. The grey field is the whole point of the read-only rule, so it is a real +# fill rather than a disabled widget's platform default, which on Windows is nearly white. +EDITABLE_FIELD_BG = "#ffffff" +READONLY_FIELD_BG = "#e8e8dc" +LABEL_COLOUR = "#000000" +READONLY_TEXT = "#444444" +ROW_BG = "#ffffff" +HEADER_BG = "#f2f2f2" +TYPE_COLOUR = "#555555" +PATH_COLOUR = "#7a7a7a" +DOT_TRUE = "#3fa34d" +DOT_FALSE = "#d9534f" +DOT_NULL = "#4a90d9" + +FIELD_WIDTH = 18 # characters; the entry and the combobox share it so fields line up +ROW_HEIGHT_HINT = 22 + +# What a .sax writes for a null value, and what an editor has to accept back. Sedona +# models null 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 on four of them. +NULL_VALUE = "null" +NULL_TEXT = "null" + + +# --- slot reading ------------------------------------------------------------------- + +def unit_symbol(unit_name): + """The symbol a unit facet is shown as, or the raw name when it is not a known one.""" + if not unit_name: + return "" + return UNIT_SYMBOLS.get(unit_name, unit_name) + + +def humanise(slot_name): + """'controlSignalForMin_1' -> 'Control Signal For Min _1', the label style the rows use. + + camelCase becomes spaced words and a trailing '_1' is split off as its own token, so + the five `_1`.. `_5` variants of a slot line up down the column. Only the first letter + of a word is touched, which leaves 'in1' as 'In1' and any acronym as it was written. + """ + if not slot_name: + return "" + + words = [] + current = "" + for char in slot_name: + if char == "_": + if current: + words.append(current) + current = "_" + elif char.isupper() and current and not current.startswith("_"): + words.append(current) + current = char + else: + current += char + if current: + words.append(current) + + return " ".join(word[:1].upper() + word[1:] for word in words) + + +def qualified_type(slot_type): + """'int' -> 'sys::int', the way a slot sheet names it. + + A manifest writes the primitives bare and everything else already qualified, so the + only kit that ever has to be supplied is the one they all come from. + """ + if not slot_type: + return "" + return slot_type if "::" in slot_type else f"sys::{slot_type}" + + +def enum_range(slot): + """The named values a '@range' facet declares, or () for a slot that is not an enum.""" + raw = slot["facets"].get("range") + if not isinstance(raw, str) or not raw.strip(): + return () + return tuple(part.strip() for part in raw.split(",")) + + +def type_zero(slot_type): + """The value a slot holds when neither the file nor the manifest names one.""" + if slot_type == "bool": + return "false" + if slot_type in INTEGER_TYPES: + return "0" + if slot_type in FLOAT_TYPES: + return "0.0" + if slot_type in STRING_TYPES: + return "" + return None + + +def is_null(raw): + """Whether a value is Sedona's null, as an absent prop or as the literal it writes.""" + return raw is None or str(raw).strip().lower() == NULL_VALUE + + +def null_text(slot): + """What null reads as on this slot, honouring '@nullText'. + + ontrolBaseDriver::DeviceNetwork.status declares '@nullText="Disabled"', which is the + facet's whole purpose: a bool that is neither true nor false still has to say + something, and the kit author is the one who knows what. + """ + declared = slot["facets"].get("nullText") + return str(declared) if isinstance(declared, str) and declared else NULL_TEXT + + +def allows_null(slot): + """Whether the editor may set this slot to null, per '@allowNull'. + + The facet is only ever written as false in these kits, to stop a bool being cleared. + Absent means allowed, which has to be the default: a file can already hold null on a + slot that declares nothing, and refusing to write back what was read would be worse. + """ + return slot["facets"].get("allowNull") is not False + + +def stored_value(comp, slot): + """What the file says this slot holds, or None when it carries no for it.""" + prop = comp.find(f"prop[@name='{slot['name']}']") + return prop.get("val", "") if prop is not None else None + + +def effective_value(comp, slot): + """The value to show: the file's, else the manifest default, else the type's zero. + + Runtime slots are never persisted, so most of them fall through to a default here. + Showing that rather than 'null' is the honest reading offline — it is what the device + starts from — and the row is grey either way, so nothing invites it to be mistaken + for a live reading. + """ + raw = stored_value(comp, slot) + if raw is not None: + return raw + if slot["default"] is not None: + return slot["default"] + return type_zero(slot["type"]) + + +def is_editable(slot): + """Config and not read-only. The rule the whole view's greying comes from.""" + return "c" in slot["flags"] and not slot["readonly"] + + +def range_text(slot): + """The '[min - max]' suffix, or '' for a slot whose bounds are not worth stating. + + A declared '@min' or '@max' is always printed. With neither declared, only a narrow + unsigned type fills the gap from the type itself — a 'short' reads [0 - 65535], which + is worth knowing. An 'int', a 'long' or a float would print a span that means "any + number", so those rows print nothing at all rather than a bound nobody can hit. + + This is display only: value_bounds still holds every integer to its type, so Save + rejects an out-of-range int whether or not its row advertised the limit. + """ + slot_type = slot["type"] + facets = slot["facets"] + declared_min = facets.get("min") + declared_max = facets.get("max") + has_declared = (isinstance(declared_min, (int, float)) + or isinstance(declared_max, (int, float))) + + if slot_type in INTEGER_TYPES: + if not has_declared and slot_type not in IMPLICIT_RANGE_TYPES: + return "" + # An end the manifest left open is filled from the type only where that bound is + # narrow enough to mean something; on an int it reads as open, the way a float does. + # 'IRamp.secs' declares a min and no max, and [1 - 2147483647] would be all noise + # on the right. + type_min, type_max = TYPE_RANGES[slot_type] + if slot_type not in IMPLICIT_RANGE_TYPES: + type_min, type_max = "-inf", "+inf" + low = _trim_number(declared_min) if isinstance(declared_min, (int, float)) else type_min + high = _trim_number(declared_max) if isinstance(declared_max, (int, float)) else type_max + return f"[{_trim_number(low)} - {_trim_number(high)}]" + + if slot_type in FLOAT_TYPES: + if not has_declared: + return "" + low = _trim_number(declared_min) if isinstance(declared_min, (int, float)) else "-inf" + high = _trim_number(declared_max) if isinstance(declared_max, (int, float)) else "+inf" + return f"[{low} - {high}]" + + if slot_type in STRING_TYPES and has_declared: + # On a Str these two count characters, not magnitude, and the count is what the + # Buf can actually hold: a Buf(17) declares @max=16, the seventeenth byte being + # the null terminator. + if isinstance(declared_min, (int, float)) and isinstance(declared_max, (int, float)): + return f"[{_trim_number(declared_min)} - {_trim_number(declared_max)} chars]" + if isinstance(declared_max, (int, float)): + return f"[max {_trim_number(declared_max)} chars]" + return f"[min {_trim_number(declared_min)} chars]" + + return "" + + +def _trim_number(value): + """'0.1' stays '0.1' and '1000.0' becomes '1000', so a range reads the way it is meant.""" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value) + + +def value_bounds(slot): + """(low, high) a value must fall inside: magnitude for a number, length for a Str.""" + facets = slot["facets"] + low = facets.get("min") + high = facets.get("max") + + if slot["type"] in INTEGER_TYPES: + type_min, type_max = TYPE_RANGES[slot["type"]] + low = low if isinstance(low, (int, float)) else type_min + high = high if isinstance(high, (int, float)) else type_max + + if not isinstance(low, (int, float)): + low = None + if not isinstance(high, (int, float)): + high = None + return low, high + + +def bool_texts(slot): + """The two words a bool slot is shown as, honouring '@trueText' and '@falseText'.""" + facets = slot["facets"] + true_text = facets.get("trueText") or "true" + false_text = facets.get("falseText") or "false" + return str(false_text), str(true_text) + + +def display_text(slot, raw): + """The value as it appears in its field, formatted by the slot's own facets.""" + slot_type = slot["type"] + + if slot_type == "void": + return "action" + + if is_null(raw): + return null_text(slot) + + if slot_type == "bool": + false_text, true_text = bool_texts(slot) + return true_text if str(raw).strip().lower() in ("true", "1") else false_text + + names = enum_range(slot) + if names and slot_type in INTEGER_TYPES: + try: + index = int(raw) + except (TypeError, ValueError): + return str(raw) + return names[index] if 0 <= index < len(names) else str(raw) + + if slot_type in FLOAT_TYPES: + try: + number = float(raw) + except (TypeError, ValueError): + return str(raw) + precision = slot["facets"].get("precision") + if isinstance(precision, int) and 0 <= precision <= 12: + return f"{number:.{precision}f}" + return str(number) + + if slot_type in INTEGER_TYPES: + try: + return str(int(raw)) + except (TypeError, ValueError): + return str(raw) + + return str(raw) + + +def parse_text(slot, text): + """Turns a field's text back into the string a carries. + + Returns (raw, error): exactly one of the two is None. Errors name the slot's own + wording rather than Python's, since it is the manifest's rule that was broken. + """ + slot_type = slot["type"] + text = text.strip() + + # Null is checked ahead of every type, because it is a value each of them can hold + # rather than a failure to parse one. A slot that declares '@allowNull false' is the + # one place it is refused. + if text.lower() == NULL_VALUE or (text and text == null_text(slot)): + if allows_null(slot): + return NULL_VALUE, None + return None, "this slot cannot be null" + + if slot_type == "bool": + false_text, true_text = bool_texts(slot) + if text == true_text: + return "true", None + if text == false_text: + return "false", None + return None, f"expected '{true_text}' or '{false_text}'" + + names = enum_range(slot) + if names and slot_type in INTEGER_TYPES: + if text in names: + return str(names.index(text)), None + return None, f"expected one of: {', '.join(names)}" + + low, high = value_bounds(slot) + + if slot_type in INTEGER_TYPES: + try: + number = int(text, 0) if text.lower().startswith(("0x", "-0x")) else int(text) + except ValueError: + return None, "expected a whole number" + if low is not None and number < low: + return None, f"below the minimum of {_trim_number(low)}" + if high is not None and number > high: + return None, f"above the maximum of {_trim_number(high)}" + return str(number), None + + if slot_type in FLOAT_TYPES: + try: + number = float(text) + except ValueError: + return None, "expected a number" + if low is not None and number < low: + return None, f"below the minimum of {_trim_number(low)}" + if high is not None and number > high: + return None, f"above the maximum of {_trim_number(high)}" + return str(number), None + + if slot_type in STRING_TYPES: + # min/max count characters here. Overrunning the Buf is not a display problem: + # the device gets a string it has no room for. + if isinstance(high, (int, float)) and len(text) > high: + return None, f"longer than {_trim_number(high)} characters" + if isinstance(low, (int, float)) and len(text) < low: + return None, f"shorter than {_trim_number(low)} characters" + return text, None + + return None, "this slot holds no editable value" + + +# Both halves of meta's position are one byte of the packed integer, so this is the +# range the editor accepts — not the sheet's extent, which is this times the grid step. +META_COORD_MIN = 0 +META_COORD_MAX = 255 + + +def group_label(flags): + """The 'Group [1,3]' text, from the four user-group bits in order.""" + active = [str(index + 1) for index, on in enumerate(flags) if on] + return f"Group [{','.join(active)}]" if active else "Group []" + + +def meta_summary(comp): + """meta unpacked into what its row and its editor need: (label, groups, (x, y)).""" + meta_prop = comp.find("prop[@name='meta']") + meta_str = meta_prop.get("val") if meta_prop is not None else None + raw_x, raw_y, _reserved, *flags_and_rest = read_meta_property(meta_str) + flags = list(flags_and_rest[:4]) + return group_label(flags), flags, (raw_x, raw_y) + + +# --- the panel ---------------------------------------------------------------------- + +class PropertiesPanel: + """One component's slots, in a scrolling list of rows, with Save, Refresh and Close. + + Builds 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 canvas or the window. + + Rows are built once and then only ever re-read: Refresh writes into the existing + widgets rather than rebuilding them, so the scroll position survives it. Each row + remembers the text it was last loaded with, and Save considers only the rows whose + text has moved away from that — a component keeps exactly the props the file gave it + plus the ones actually edited here. + """ + + def __init__(self, parent, comp, on_change=None, on_close=None, on_dirty_change=None): + self.comp = comp + self.on_change = on_change + self.on_close = on_close + self.on_dirty_change = on_dirty_change + self.rows = [] + self.meta_row = None + + # Widgets whose background follows the unsaved-state tint, each with the colour + # it wears when there is nothing pending. The fields themselves are left out: a + # value's own white or grey says whether it can be typed into, which is a + # different thing from whether it has been. + self._tint_targets = [] + self.dirty = False + + comp_type = comp.get("type", "sys::Component") + self.slots = schema.get_full_slots_for_type(comp_type) + + self.frame = tk.Frame(parent) + + # The footer is packed before the rows area and against the bottom: pack hands out + # leftover space in order, so an expanding area packed first leaves a later + # sibling nothing at all. + self._build_header(comp_type) + self._build_footer() + self._build_rows_area() + self._load_path() + + if not self.slots: + self.status_var.set( + f"No manifest slots resolved for {comp_type}. Check Sedona home folder." + ) + + # -- the unsaved-state tint -- + + def _tinted(self, widget, normal_background): + """Registers a widget to follow the tint, and hands it straight back.""" + self._tint_targets.append((widget, normal_background)) + return widget + + def has_unsaved_changes(self): + """Whether any row has moved away from what was last loaded or saved. + + Read from the widgets rather than tracked as a flag, so typing a value and then + typing it back leaves the view clean — exactly the rows Save would write. + """ + for row in self.rows: + if not row["editable"]: + continue + if (row["widget"].get() or "").strip() != row["loaded_text"].strip(): + return True + + if self.meta_row is not None: + if self.meta_row["groups"] != self.meta_row["loaded_groups"]: + return True + if self.meta_row["position"] != self.meta_row["loaded_position"]: + return True + + return False + + def _update_tint(self): + """Repaints the view in the same colour the canvas wears when the file is dirty.""" + was_dirty = self.dirty + self.dirty = self.has_unsaved_changes() + for widget, normal_background in self._tint_targets: + if not widget.winfo_exists(): + continue + widget.configure( + bg=app_state.DIRTY_TINT if self.dirty else normal_background + ) + + # Pending edits here are unwritten too, so the file name has to know about them + # even though nothing has reached the document yet. + if was_dirty != self.dirty and self.on_dirty_change is not None: + self.on_dirty_change() + + def _watch_for_edits(self, widget, kind): + """Makes a field repaint the view as soon as its value moves.""" + if kind == "text": + widget.bind("", lambda _event: self._update_tint(), add="+") + widget.bind("<>", lambda _event: self._update_tint(), add="+") + widget.bind("", lambda _event: self._update_tint(), add="+") + else: + widget.bind("<>", + lambda _event: self._update_tint(), add="+") + + # -- construction -- + + def _build_header(self, comp_type): + header = self.header_frame = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG) + header.pack(fill=tk.X) + self._tinted(header, HEADER_BG) + + name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold") + type_font = tkfont.Font(family="Segoe UI", size=9) + + self._tinted(tk.Label(header, text=self.comp.get("name", "?"), font=name_font, + bg=HEADER_BG), HEADER_BG).pack(side=tk.LEFT) + self._tinted(tk.Label(header, text=f"({comp_type})", font=type_font, + fg="#555555", bg=HEADER_BG), + HEADER_BG).pack(side=tk.LEFT, padx=(8, 0)) + + # The path is what a names this component by, and the one thing on screen + # that says which of four identically named boxes this is. It is read from the + # document rather than held, so a renamed parent shows up on the next Refresh. + self.path_var = tk.StringVar() + self._tinted(tk.Label(header, textvariable=self.path_var, font=type_font, + fg=PATH_COLOUR, bg=HEADER_BG, anchor="w"), + HEADER_BG).pack(side=tk.LEFT, padx=(16, 0)) + + def _build_rows_area(self): + """A canvas-scrolled frame; ttk has no scrollable container of its own. + + The rows are laid out at their natural width and never stretched to the window: + every widget is gridded straight into one frame, so grid's own column sizing is + what aligns the four columns across every row, and no column carries a weight. + A wider window therefore shows more white to the right of the table rather than + walking the fields across the pane; a narrower one scrolls sideways instead of + squeezing them. + """ + 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.scroll_canvas = tk.Canvas(area, highlightthickness=0, bg=ROW_BG) + self.scroll_canvas.grid(row=0, column=0, sticky="nsew") + self._tinted(self.scroll_canvas, ROW_BG) + + self.vertical_bar = ttk.Scrollbar(area, orient="vertical", + command=self.scroll_canvas.yview) + self.vertical_bar.grid(row=0, column=1, sticky="ns") + + self.horizontal_bar = ttk.Scrollbar(area, orient="horizontal", + command=self.scroll_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 its table carries neither. + self.scroll_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 + ), + ) + + self.rows_frame = self._tinted(tk.Frame(self.scroll_canvas, bg=ROW_BG), ROW_BG) + self.rows_window = self.scroll_canvas.create_window( + (0, 0), window=self.rows_frame, anchor="nw" + ) + + self.rows_frame.bind( + "", + lambda _e: self.scroll_canvas.configure( + scrollregion=self.scroll_canvas.bbox("all") + ), + ) + # 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 list from there. + self.frame.bind("", self._on_wheel) + self.frame.bind("", self._on_shift_wheel) + + for grid_row, slot in enumerate(self.slots): + if slot["name"] == "meta": + self._build_meta_row(grid_row, slot) + else: + self._build_slot_row(grid_row, slot) + + def _row_label(self, slot): + """'14. Proportional Band' — the manifest slot id, so rows match the box's numbering.""" + return f"{slot['slot_id']:02d}. {humanise(slot['name'])}" + + def _add_bullet(self, grid_row): + """The small ring each row is tagged with, drawn rather than set in a glyph font.""" + dot = self._tinted(tk.Canvas(self.rows_frame, width=14, height=ROW_HEIGHT_HINT, + highlightthickness=0, bg=ROW_BG), ROW_BG) + # Unfilled, so the ring shows whatever the canvas behind it is currently painted. + dot.create_oval(3, 8, 12, 17, outline="#8a8a8a", width=1, fill="") + dot.grid(row=grid_row, column=0, padx=(6, 2)) + return dot + + def _build_slot_row(self, grid_row, slot): + self._add_bullet(grid_row) + + self._tinted(tk.Label(self.rows_frame, text=self._row_label(slot), anchor="w", + bg=ROW_BG, fg=LABEL_COLOUR), + ROW_BG).grid(row=grid_row, column=1, sticky="w", padx=(2, 8)) + + self._tinted(tk.Label(self.rows_frame, text=qualified_type(slot["type"]), + anchor="w", bg=ROW_BG, fg=TYPE_COLOUR), + ROW_BG).grid(row=grid_row, column=2, sticky="w", padx=(0, 10)) + + field_frame = self._tinted(tk.Frame(self.rows_frame, bg=ROW_BG), ROW_BG) + field_frame.grid(row=grid_row, column=3, sticky="w", padx=(0, 4)) + + editable = is_editable(slot) + row = {"slot": slot, "editable": editable, "kind": None, "widget": None, + "state_dot": None, "loaded_text": ""} + + names = enum_range(slot) + if slot["type"] == "bool": + row["kind"] = "bool" + state_dot = self._tinted(tk.Canvas(field_frame, width=14, height=16, + highlightthickness=0, bg=ROW_BG), ROW_BG) + state_dot.grid(row=0, column=0) + row["state_dot"] = state_dot + false_text, true_text = bool_texts(slot) + choices = [false_text, true_text] + # '@allowNull false' is the manifest saying this bool is only ever one of the + # two, so null is offered on every other bool and withheld here. + if allows_null(slot): + choices.append(null_text(slot)) + widget = ttk.Combobox(field_frame, values=choices, + width=FIELD_WIDTH - 3, state="readonly") + widget.grid(row=0, column=1) + elif names and slot["type"] in INTEGER_TYPES: + row["kind"] = "enum" + widget = ttk.Combobox(field_frame, values=list(names), + width=FIELD_WIDTH - 1, state="readonly") + widget.grid(row=0, column=1) + else: + row["kind"] = "text" + widget = tk.Entry(field_frame, width=FIELD_WIDTH, relief="solid", bd=1, + highlightthickness=0, justify="left", + readonlybackground=READONLY_FIELD_BG) + widget.configure(bg=EDITABLE_FIELD_BG if editable else READONLY_FIELD_BG) + widget.grid(row=0, column=1) + + row["widget"] = widget + if editable: + self._watch_for_edits(widget, row["kind"]) + + suffix = " ".join(part for part in (unit_symbol(slot["facets"].get("unit")), + range_text(slot)) if part) + self._tinted(tk.Label(self.rows_frame, text=suffix, anchor="w", bg=ROW_BG, + fg="#555555"), + ROW_BG).grid(row=grid_row, column=4, sticky="w", padx=(6, 6)) + + self.rows.append(row) + self._load_row(row) + + def _build_meta_row(self, grid_row, slot): + """meta is one integer holding a position and four group bits, so it gets its own row. + + Typing 822411269 into a field is no way to set either of them, so the '»' opens + the two apart: the groups as checkboxes and the position as the x and y a drag + would otherwise be the only way to set. + """ + self._add_bullet(grid_row) + + self._tinted(tk.Label(self.rows_frame, text=self._row_label(slot), anchor="w", + bg=ROW_BG, fg=LABEL_COLOUR), + ROW_BG).grid(row=grid_row, column=1, sticky="w", padx=(2, 8)) + + self._tinted(tk.Label(self.rows_frame, text=qualified_type(slot["type"]), + anchor="w", bg=ROW_BG, fg=TYPE_COLOUR), + ROW_BG).grid(row=grid_row, column=2, sticky="w", padx=(0, 10)) + + field_frame = self._tinted(tk.Frame(self.rows_frame, bg=ROW_BG), ROW_BG) + field_frame.grid(row=grid_row, column=3, sticky="w", padx=(0, 4)) + + summary_var = tk.StringVar() + self._tinted(tk.Label(field_frame, textvariable=summary_var, bg=ROW_BG, + fg="#1f4e79", anchor="w", width=FIELD_WIDTH - 3), + ROW_BG).grid(row=0, column=0, sticky="w") + self._tinted(tk.Button(field_frame, text="»", width=2, relief="flat", bg=ROW_BG, + fg="#1f4e79", command=self._open_meta_editor), + ROW_BG).grid(row=0, column=1) + + self.meta_position_var = tk.StringVar() + self._tinted(tk.Label(self.rows_frame, textvariable=self.meta_position_var, + bg=ROW_BG, fg="#555555"), + ROW_BG).grid(row=grid_row, column=4, sticky="w", padx=(6, 6)) + + self.meta_row = {"slot": slot, "summary_var": summary_var, + "groups": [False, False, False, False], + "loaded_groups": [False, False, False, False], + "position": (0, 0), "loaded_position": (0, 0)} + self._load_meta_row() + + def _build_footer(self): + footer = self.footer_frame = tk.Frame(self.frame, padx=8, pady=6) + footer.pack(side=tk.BOTTOM, fill=tk.X) + # The footer has no colour of its own to restore, so its own default is the one + # the tint has to put back. + self._tinted(footer, footer.cget("bg")) + + self.status_var = tk.StringVar(value="") + status = tk.Label(footer, textvariable=self.status_var, anchor="w", fg="#333333") + self._tinted(status, status.cget("bg")) + status.pack(side=tk.LEFT, fill=tk.X, expand=True) + + ttk.Button(footer, text="Close", width=9, + command=self.close).pack(side=tk.RIGHT, padx=(6, 0)) + ttk.Button(footer, text="Save", width=9, + command=self.save).pack(side=tk.RIGHT, padx=(6, 0)) + ttk.Button(footer, text="Refresh", width=9, + command=self.refresh).pack(side=tk.RIGHT) + + # -- loading -- + + def _set_state_dot(self, canvas_widget, colour): + canvas_widget.delete("all") + canvas_widget.create_oval(3, 4, 12, 13, fill=colour, outline="#666666") + + def _load_row(self, row): + """Puts the DOM's current value into one row's widget and remembers it as loaded.""" + slot = row["slot"] + raw = effective_value(self.comp, slot) + text = display_text(slot, raw) + widget = row["widget"] + + if row["kind"] == "text": + widget.configure(state="normal") + widget.delete(0, tk.END) + widget.insert(0, text) + if row["editable"]: + widget.configure(state="normal", fg=LABEL_COLOUR, bg=EDITABLE_FIELD_BG) + else: + widget.configure(state="readonly", fg=READONLY_TEXT) + else: + widget.configure(state="readonly") + widget.set(text) + if not row["editable"]: + widget.configure(state="disabled") + + if row["state_dot"] is not None: + if is_null(raw): + colour = DOT_NULL + elif str(raw).strip().lower() in ("true", "1"): + colour = DOT_TRUE + else: + colour = DOT_FALSE + self._set_state_dot(row["state_dot"], colour) + + row["loaded_text"] = text + + def _load_path(self): + """The component's absolute path, as references write it.""" + root = app_state.xml_root_element + self.path_var.set(get_component_path(self.comp, root) if root is not None else "") + + def _load_meta_row(self): + summary, flags, position = meta_summary(self.comp) + self.meta_row["summary_var"].set(summary) + self.meta_row["groups"] = list(flags) + self.meta_row["loaded_groups"] = list(flags) + self.meta_row["position"] = position + self.meta_row["loaded_position"] = position + self.meta_position_var.set(f"x {position[0]} y {position[1]}") + + # -- the meta editor behind the row's chevron -- + + def _open_meta_editor(self): + """The two things meta actually holds, side by side: the groups and the position. + + The position is in grid units, the same figure a drag writes and `meta_scale` + multiplies to reach pixels — not a pixel count, which is why 49 and 5 are ordinary + values here. Each fits in one byte, so 0-255 is the whole of what meta can carry. + """ + window = app_state.window + editor = tk.Toplevel(window) + editor.title("Meta") + editor.transient(window) + editor.resizable(False, False) + editor.grab_set() + editor.geometry(f"+{window.winfo_x() + 160}+{window.winfo_y() + 140}") + + body = tk.Frame(editor, padx=14, pady=12) + body.pack(fill=tk.BOTH, expand=True) + + columns = tk.Frame(body) + columns.pack(fill=tk.X) + + heading_font = tkfont.Font(family="Segoe UI", size=9, weight="bold") + + groups_column = tk.Frame(columns) + groups_column.grid(row=0, column=0, sticky="nw") + tk.Label(groups_column, text="Groups", font=heading_font).pack(anchor="w", + pady=(0, 4)) + group_variables = [] + for index in range(4): + variable = tk.BooleanVar(value=self.meta_row["groups"][index]) + tk.Checkbutton(groups_column, text=f"Group {index + 1}", variable=variable, + anchor="w").pack(anchor="w") + group_variables.append(variable) + + position_column = tk.Frame(columns) + position_column.grid(row=0, column=1, sticky="nw", padx=(28, 0)) + tk.Label(position_column, text="Position", font=heading_font).grid( + row=0, column=0, columnspan=2, sticky="w", pady=(0, 4) + ) + + loaded_x, loaded_y = self.meta_row["position"] + x_var = tk.StringVar(value=str(loaded_x)) + y_var = tk.StringVar(value=str(loaded_y)) + for row_index, (axis, variable) in enumerate((("x", x_var), ("y", y_var)), start=1): + tk.Label(position_column, text=axis).grid(row=row_index, column=0, + sticky="w", pady=2) + tk.Entry(position_column, textvariable=variable, width=6, relief="solid", + bd=1, highlightthickness=0).grid(row=row_index, column=1, + sticky="w", padx=(6, 0), pady=2) + + def read_coordinate(variable, axis): + """One coordinate as an int, or None with the reason named for the axis.""" + text = variable.get().strip() + try: + value = int(text) + except ValueError: + return None, f"{axis} must be a whole number." + if not META_COORD_MIN <= value <= META_COORD_MAX: + return None, (f"{axis} must be between {META_COORD_MIN} and " + f"{META_COORD_MAX}.") + return value, None + + def apply_meta(): + new_x, x_error = read_coordinate(x_var, "x") + new_y, y_error = read_coordinate(y_var, "y") + problems = [problem for problem in (x_error, y_error) if problem] + if problems: + messagebox.showerror("Meta", "\n".join(problems), parent=editor) + return + + self.meta_row["groups"] = [variable.get() for variable in group_variables] + self.meta_row["position"] = (new_x, new_y) + self.meta_row["summary_var"].set(group_label(self.meta_row["groups"])) + self.meta_position_var.set(f"x {new_x} y {new_y}") + editor.destroy() + self._update_tint() + self.status_var.set("Meta changed — Save to write it to the file.") + + buttons = tk.Frame(body) + buttons.pack(fill=tk.X, pady=(12, 0)) + ttk.Button(buttons, text="OK", width=9, command=apply_meta).pack(side=tk.LEFT) + ttk.Button(buttons, text="Cancel", width=9, + command=editor.destroy).pack(side=tk.LEFT, padx=(10, 0)) + + editor.bind("", lambda _event: apply_meta()) + editor.bind("", lambda _event: editor.destroy()) + + # -- the two buttons -- + + def refresh(self): + """Re-reads every value from the DOM, discarding edits that were not saved.""" + for row in self.rows: + self._load_row(row) + if self.meta_row is not None: + self._load_meta_row() + self._load_path() + self._update_tint() + self.status_var.set("Refreshed from the document.") + + def save(self): + """Writes the edited rows back, as one undoable step. + + Nothing is written until every edited row parses, so a typo in one field cannot + leave the component half-updated. + """ + if not self._still_in_document(): + messagebox.showwarning( + "Properties", + "This component is no longer in the document, so nothing was written.", + parent=app_state.window, + ) + self.status_var.set("Component was deleted — nothing saved.") + return + + changes = [] + problems = [] + + for row in self.rows: + if not row["editable"]: + continue + slot = row["slot"] + current = (row["widget"].get() or "").strip() + if current == row["loaded_text"].strip(): + continue + + raw, error = parse_text(slot, current) + if error: + problems.append(f"{self._row_label(slot)}: {error}") + continue + + changes.append((self.comp, slot["name"], stored_value(self.comp, slot), raw)) + + if self.meta_row is not None: + groups = self.meta_row["groups"] + position = self.meta_row["position"] + if (groups != self.meta_row["loaded_groups"] + or position != self.meta_row["loaded_position"]): + meta_prop = self.comp.find("prop[@name='meta']") + before = meta_prop.get("val") if meta_prop is not None else None + # Two writes, because each one preserves what the other owns: the + # position pass keeps the group bits it reads, then the group pass keeps + # the position it has just been given. + update_meta_property(self.comp, before, position[0], position[1]) + repositioned = self.comp.find("prop[@name='meta']").get("val") + update_meta_groups(self.comp, repositioned, groups) + after = self.comp.find("prop[@name='meta']").get("val") + # Applied above so the packed integer is computed once; recorded here so + # undo puts back exactly the string the file carried, absent included. + changes.append((self.comp, "meta", before, after)) + + if problems: + messagebox.showerror( + "Properties", + "These values were not accepted, so nothing was written:\n\n" + + "\n".join(f" • {problem}" for problem in problems), + parent=app_state.window, + ) + self.status_var.set("Nothing saved — some values are out of range.") + return + + if not changes: + self.status_var.set("No changes to save.") + return + + for element, prop_name, _before, after in changes: + if prop_name != "meta": + set_prop_value(element, prop_name, after) + + undo.push(undo.SlotValueCommand(list(changes))) + app_state.mark_dirty() + + self.refresh() + if self.on_change is not None: + self.on_change() + + count = len(changes) + self.status_var.set( + f"{count} slot{'' if count == 1 else 's'} changed. " + "Ctrl+S writes the file." + ) + app_state.set_status( + f"{self.comp.get('name', '?')}: {count} slot{'' if count == 1 else 's'} changed." + ) + + # -- plumbing -- + + def _still_in_document(self): + """An undo can take the component out of the document while this is on screen.""" + root = app_state.xml_root_element + if root is None: + return False + return any(element is self.comp for element in root.iter()) + + def _sync_bar(self, bar, first, last): + """Moves a scrollbar's thumb, and hides the bar entirely 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.scroll_canvas.winfo_exists(): + self.scroll_canvas.yview_scroll(-1 * (event.delta // 120), "units") + + def _on_shift_wheel(self, event): + if self.scroll_canvas.winfo_exists(): + self.scroll_canvas.xview_scroll(-1 * (event.delta // 120), "units") + + def confirm_discard(self): + """Asks before pending edits are thrown away. True means it is safe to leave. + + 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. + """ + if not self.has_unsaved_changes(): + return True + + answer = messagebox.askyesnocancel( + "Unsaved changes", + f"'{self.comp.get('name', '?')}' has changes that have not been saved.\n\n" + "Save them before leaving?", + parent=app_state.window, + ) + if answer is None: + return False + if answer: + self.save() + return not self.has_unsaved_changes() + return True + + def close(self): + """Hands back to whoever put this on screen, once pending edits are settled. + + Nothing here destroys the frame: the caller owns it, and is the only thing that + knows what belongs in its place. + """ + if not self.confirm_discard(): + return False + 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_properties_panel(parent, comp, on_change=None, on_close=None, + on_dirty_change=None): + """A Properties panel for one component, built into `parent` but not yet placed. + + `on_change` repaints the other views after a Save; `on_close` is what the Close + button asks for; `on_dirty_change` fires when pending edits appear or clear. The + caller packs `panel.frame` wherever it wants it. + """ + if comp is None or parent is None: + return None + return PropertiesPanel(parent, comp, on_change, on_close, on_dirty_change) diff --git a/sax_dom.py b/sax_dom.py index 4118050..24b244b 100644 --- a/sax_dom.py +++ b/sax_dom.py @@ -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 , creating it in the block the other props sit in. + + A .sax keeps every ahead of the child 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 , 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(): diff --git a/sax_file.py b/sax_file.py index 97b3093..61e0765 100644 --- a/sax_file.py +++ b/sax_file.py @@ -61,6 +61,8 @@ def refresh_treeview_from_dom(xml_root): return file_name = os.path.basename(app_state.current_file_path) + # Inserted plain. refresh_dirty_display puts the unsaved tag on or off, and runs + # after every rebuild, so a fresh row picks up the right state there. root_node = tree.insert("", "end", text=file_name, open=True) app_element = xml_root.find("app") diff --git a/schema.py b/schema.py index c40178b..1077a3a 100644 --- a/schema.py +++ b/schema.py @@ -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 , 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 @@ -240,6 +277,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 diff --git a/undo.py b/undo.py index 6c1e61a..c59d7b4 100644 --- a/undo.py +++ b/undo.py @@ -8,7 +8,7 @@ import graph free of cycles (sax_file and wiresheet both push commands here). import app_state 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 = [] @@ -137,6 +137,36 @@ class MetaMoveCommand: update_meta_property(element, old_val, new_x, new_y) +class SlotValueCommand: + """One Save from the Properties dialog: every slot it changed, as one undoable step. + + A change is recorded as the '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 AddComponentCommand: """Placing a component.