From 08b4dec532a4d93c45a32cef362f1c4ec9b03213 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Sat, 10 Oct 2026 23:39:06 +0300 Subject: [PATCH] Tree: Reorder... for a component's children Right-click a component (or the app) with two or more children to move them up and down; OK applies the new order, which is the order they run in. On a device the order goes there first via Sox reorder, checked against the device's children, and the file is left alone if it is refused. Undoable. Also: device host in editor.properties. Co-Authored-By: Claude Opus 5.5 --- app_state.py | 2 +- editor.properties | 2 +- live_hooks.py | 37 +++++++++++++ main.py | 132 ++++++++++++++++++++++++++++++++++++++++++++++ sax_file.py | 37 +++++++++++++ undo.py | 41 ++++++++++++++ 6 files changed, 249 insertions(+), 2 deletions(-) diff --git a/app_state.py b/app_state.py index 7ee309f..df71690 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.165" +VERSION = "0.0.0.166" PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") DEFAULT_GRID_SIZE = 10 DEFAULT_ADD_TIMESTAMP = True diff --git a/editor.properties b/editor.properties index 23d76a0..693fc10 100644 --- a/editor.properties +++ b/editor.properties @@ -7,7 +7,7 @@ left_pane_width = 230 trace_dasp = True [Device] -host = 192.168.1.29 +host = 192.168.1.251 port = 1876 user = admin diff --git a/live_hooks.py b/live_hooks.py index 381e83c..425d997 100644 --- a/live_hooks.py +++ b/live_hooks.py @@ -155,6 +155,43 @@ def live_rename_component(element, new_name): return False +# ── Reorder Children ──────────────────────────────────────────── + +def live_reorder_children(parent_element, ordered_children): + """In live mode, sends a Sox reorder to the device. Returns True on success. + + The device takes the order only when it names every child it has, no more and no + fewer, so the list is checked against what the device last said before it is sent. + """ + ds = _session() + if ds is None: + return False + parent_id = _comp_id(parent_element) + if parent_id is None: + _fail(f"'{parent_element.get('name', '?')}' has no component id.") + return False + child_ids = [] + for child in ordered_children: + cid = _comp_id(child) + if cid is None: + _fail(f"'{child.get('name', '?')}' has no component id.") + return False + child_ids.append(cid) + + client = ds.sox_client + cached = client.cache_get(parent_id) + if cached is not None and set(cached.children) != set(child_ids): + _fail("The editor's list of children no longer matches the device's. " + "Reconnect to read the device again.") + return False + try: + client.reorder(parent_id, child_ids) + return True + except Exception as e: + _fail(f"The device rejected the reorder: {e}") + return False + + # ── Link / Unlink ─────────────────────────────────────────────── def _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name): diff --git a/main.py b/main.py index 54ddb41..fb82387 100644 --- a/main.py +++ b/main.py @@ -1370,6 +1370,10 @@ def on_tree_right_click(event): if app_element is not None: tree_root_context_menu.add_command( label="Properties", command=lambda: show_properties(app_element)) + if _has_reorderable_children(app_element): + tree_root_context_menu.add_command( + label="Reorder...", + command=lambda: open_reorder_dialog(current_element(app_element))) tree_root_context_menu.add_separator() if app_state.live_mode and app_state.device_session and app_state.device_session.is_connected: for action_name in _APP_ACTIONS: @@ -1393,6 +1397,12 @@ def on_tree_right_click(event): selection = [app_state.tree_element_map[row] for row in selected_rows if row in app_state.tree_element_map] fill_component_popup(tree_comp_context_menu, comp_element, selection) + if _has_reorderable_children(comp_element): + # After Rename..., with the other entries that change the component itself. + rename_index = tree_comp_context_menu.index("Rename...") + tree_comp_context_menu.insert_command( + rename_index + 1, label="Reorder...", + command=lambda: open_reorder_dialog(current_element(comp_element))) tree_comp_context_menu.post(event.x_root, event.y_root) @@ -1806,6 +1816,128 @@ def open_rename_dialog(comp): entry.select_range(0, tk.END) +def _child_display_names(parent, children): + """Names for a reorder list. On a device a child not yet read is a "..." row, so + its name is read from the device rather than shown as dots.""" + names = [child.get("name", "?") for child in children] + if not (app_state.live_mode and "..." in names): + return names + ds = app_state.device_session + try: + ds.load_children(int(parent.get("id"))) + except Exception: + return names + for i, child in enumerate(children): + if names[i] == "...": + cached = ds.sox_client.cache_get(int(child.get("id", -1))) + if cached is not None: + names[i] = cached.name + return names + + +def open_reorder_dialog(parent): + """Lists a component's children to move up and down; OK applies the new order.""" + window = app_state.window + original = [child for child in parent if child.tag == "comp"] + if len(original) < 2: + return + names = dict(zip(map(id, original), _child_display_names(parent, original))) + order = list(original) + + dialog = tk.Toplevel(window) + dialog.title(f"Reorder {parent.get('name', 'app')}") + dialog.transient(window) + dialog.grab_set() + dialog.geometry(f"+{window.winfo_x() + 100}+{window.winfo_y() + 60}") + + main_frame = tk.Frame(dialog, padx=10, pady=10) + main_frame.pack(fill=tk.BOTH, expand=True) + main_frame.columnconfigure(0, weight=1) + main_frame.rowconfigure(0, weight=1) + + list_frame = tk.Frame(main_frame) + list_frame.grid(row=0, column=0, sticky="nsew") + listbox = tk.Listbox(list_frame, width=28, height=min(max(len(order), 10), 20), + exportselection=False, activestyle="none") + scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=listbox.yview) + listbox.configure(yscrollcommand=scrollbar.set) + listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + side = tk.Frame(main_frame, padx=10) + side.grid(row=0, column=1, sticky="n") + up_button = ttk.Button(side, text="Move Up", width=14) + up_button.pack(fill=tk.X) + down_button = ttk.Button(side, text="Move Down", width=14) + down_button.pack(fill=tk.X, pady=(6, 0)) + reset_button = ttk.Button(side, text="Reset", width=14) + reset_button.pack(fill=tk.X, pady=(40, 0)) + + btn_frame = tk.Frame(main_frame) + btn_frame.grid(row=1, column=0, columnspan=2, pady=(10, 0)) + ok_button = ttk.Button(btn_frame, text="OK", width=10) + ok_button.pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Cancel", width=10, + command=dialog.destroy).pack(side=tk.LEFT, padx=5) + + def selected_index(): + sel = listbox.curselection() + return sel[0] if sel else None + + def update_buttons(*_): + index = selected_index() + up_button.state(["!disabled"] if index not in (None, 0) else ["disabled"]) + down_button.state(["!disabled"] if index not in (None, len(order) - 1) + else ["disabled"]) + reset_button.state(["!disabled"] if order != original else ["disabled"]) + + def fill(select=None): + listbox.delete(0, tk.END) + for child in order: + listbox.insert(tk.END, names[id(child)]) + if select is not None: + listbox.selection_set(select) + listbox.activate(select) + listbox.see(select) + update_buttons() + + def move(step): + index = selected_index() + if index is None or not 0 <= index + step < len(order): + return + order[index], order[index + step] = order[index + step], order[index] + fill(index + step) + + def reset(): + # Back to the order the dialog opened with, keeping the same row selected. + index = selected_index() + picked = order[index] if index is not None else None + order[:] = original + fill(order.index(picked) if picked is not None else None) + + def on_ok(*_): + dialog.destroy() + if sax_file.reorder_children(parent, order): + refresh_all_views() + + up_button.configure(command=lambda: move(-1)) + down_button.configure(command=lambda: move(1)) + reset_button.configure(command=reset) + ok_button.configure(command=on_ok) + listbox.bind("<>", update_buttons) + dialog.bind("", lambda _: move(-1)) + dialog.bind("", lambda _: move(1)) + dialog.bind("", on_ok) + dialog.bind("", lambda _: dialog.destroy()) + + fill(0) + listbox.focus_set() + + +def _has_reorderable_children(element): + return sum(1 for child in element if child.tag == "comp") >= 2 + + def fill_component_popup(menu, comp, selection=None): """Fills a popup with the actions that apply to one component. diff --git a/sax_file.py b/sax_file.py index acb554a..0b3135e 100644 --- a/sax_file.py +++ b/sax_file.py @@ -483,6 +483,43 @@ def rename_component(comp_element, new_name): return True +def reorder_children(parent, new_order): + """Gives `parent`'s child components a new order, which is the order they run in. + + Returns True when the order changed. On a device the new order goes there first, + and the file is left alone if it does not. + """ + root = app_state.xml_root_element + if root is None: + return False + old_order = [child for child in parent if child.tag == "comp"] + new_order = list(new_order) + if new_order == old_order: + return False + if (len(new_order) != len(old_order) + or {id(c) for c in new_order} != {id(c) for c in old_order}): + return False + + name = parent.get("name", "app") + if app_state.live_mode: + if not live_hooks.live_reorder_children(parent, new_order): + live_hooks.warn_failure("Reorder", f"{name} was not reordered on the device") + return False + + undo.apply_child_order(parent, new_order) + command_class = (undo.LiveReorderChildrenCommand if app_state.live_mode + else undo.ReorderChildrenCommand) + undo.push(command_class(parent, old_order, new_order, label=f"reorder {name}")) + + app_state.mark_dirty() + refresh_treeview_from_dom(root) + wiresheet.render_current_sheet() + + where = " on the device" if app_state.live_mode else "" + app_state.set_status(f"{name} reordered{where}.") + return True + + def allocate_component_id(): """Lowest unused id, matching how this app's ids are dense with no gaps.""" used = set() diff --git a/undo.py b/undo.py index 78acff8..ac6649a 100644 --- a/undo.py +++ b/undo.py @@ -378,3 +378,44 @@ class LiveRenameCommand(RenameComponentCommand): raise LiveUndoError(f"{self.old_name} was not renamed to {self.new_name} " "on the device") super().redo() + + +def apply_child_order(parent, ordered_children): + """Puts `parent`'s children in the given order; its other children stay put.""" + children = list(parent) + comp_slots = [i for i, child in enumerate(children) if child.tag == "comp"] + for slot, child in zip(comp_slots, ordered_children): + children[slot] = child + parent[:] = children + + +class ReorderChildrenCommand: + """A new order for a component's children.""" + + def __init__(self, parent, old_order, new_order, label="reorder"): + self.parent = parent + self.old_order = list(old_order) + self.new_order = list(new_order) + self.label = label + + def undo(self): + apply_child_order(self.parent, self.old_order) + + def redo(self): + apply_child_order(self.parent, self.new_order) + + +class LiveReorderChildrenCommand(ReorderChildrenCommand): + """A reorder on the device: the old order goes back there before the DOM takes it.""" + + def undo(self): + if not live_hooks.live_reorder_children(self.parent, self.old_order): + raise LiveUndoError(f"The old order of {self.parent.get('name', 'app')} " + "could not be put back on the device") + super().undo() + + def redo(self): + if not live_hooks.live_reorder_children(self.parent, self.new_order): + raise LiveUndoError(f"{self.parent.get('name', 'app')} was not reordered " + "on the device") + super().redo()