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 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 23:39:06 +03:00
co-authored by Claude Opus 5.5
parent 232eea99f4
commit 08b4dec532
6 changed files with 249 additions and 2 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+37
View File
@@ -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):
+132
View File
@@ -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("<<ListboxSelect>>", update_buttons)
dialog.bind("<Alt-Up>", lambda _: move(-1))
dialog.bind("<Alt-Down>", lambda _: move(1))
dialog.bind("<Return>", on_ok)
dialog.bind("<Escape>", lambda _: dialog.destroy())
fill(0)
listbox.focus_set()
def _has_reorderable_children(element):
return sum(1 for child in element if child.tag == "comp") >= 2
def fill_component_popup(menu, comp, selection=None):
"""Fills a popup with the actions that apply to one component.
+37
View File
@@ -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()
+41
View File
@@ -378,3 +378,44 @@ class LiveRenameCommand(RenameComponentCommand):
raise LiveUndoError(f"{self.old_name} was not renamed to {self.new_name} "
"on the device")
super().redo()
def apply_child_order(parent, ordered_children):
"""Puts `parent`'s <comp> children in the given order; its other children stay put."""
children = list(parent)
comp_slots = [i for i, child in enumerate(children) if child.tag == "comp"]
for slot, child in zip(comp_slots, ordered_children):
children[slot] = child
parent[:] = children
class ReorderChildrenCommand:
"""A new order for a component's children."""
def __init__(self, parent, old_order, new_order, label="reorder"):
self.parent = parent
self.old_order = list(old_order)
self.new_order = list(new_order)
self.label = label
def undo(self):
apply_child_order(self.parent, self.old_order)
def redo(self):
apply_child_order(self.parent, self.new_order)
class LiveReorderChildrenCommand(ReorderChildrenCommand):
"""A reorder on the device: the old order goes back there before the DOM takes it."""
def undo(self):
if not live_hooks.live_reorder_children(self.parent, self.old_order):
raise LiveUndoError(f"The old order of {self.parent.get('name', 'app')} "
"could not be put back on the device")
super().undo()
def redo(self):
if not live_hooks.live_reorder_children(self.parent, self.new_order):
raise LiveUndoError(f"{self.parent.get('name', 'app')} was not reordered "
"on the device")
super().redo()