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.