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:
+1
-1
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
|
|||||||
import configparser
|
import configparser
|
||||||
import os
|
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")
|
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||||
DEFAULT_GRID_SIZE = 10
|
DEFAULT_GRID_SIZE = 10
|
||||||
DEFAULT_ADD_TIMESTAMP = True
|
DEFAULT_ADD_TIMESTAMP = True
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ left_pane_width = 230
|
|||||||
trace_dasp = True
|
trace_dasp = True
|
||||||
|
|
||||||
[Device]
|
[Device]
|
||||||
host = 192.168.1.29
|
host = 192.168.1.251
|
||||||
port = 1876
|
port = 1876
|
||||||
user = admin
|
user = admin
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,43 @@ def live_rename_component(element, new_name):
|
|||||||
return False
|
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 ───────────────────────────────────────────────
|
# ── Link / Unlink ───────────────────────────────────────────────
|
||||||
|
|
||||||
def _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name):
|
def _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name):
|
||||||
|
|||||||
@@ -1370,6 +1370,10 @@ def on_tree_right_click(event):
|
|||||||
if app_element is not None:
|
if app_element is not None:
|
||||||
tree_root_context_menu.add_command(
|
tree_root_context_menu.add_command(
|
||||||
label="Properties", command=lambda: show_properties(app_element))
|
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()
|
tree_root_context_menu.add_separator()
|
||||||
if app_state.live_mode and app_state.device_session and app_state.device_session.is_connected:
|
if app_state.live_mode and app_state.device_session and app_state.device_session.is_connected:
|
||||||
for action_name in _APP_ACTIONS:
|
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
|
selection = [app_state.tree_element_map[row] for row in selected_rows
|
||||||
if row in app_state.tree_element_map]
|
if row in app_state.tree_element_map]
|
||||||
fill_component_popup(tree_comp_context_menu, comp_element, selection)
|
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)
|
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)
|
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):
|
def fill_component_popup(menu, comp, selection=None):
|
||||||
"""Fills a popup with the actions that apply to one component.
|
"""Fills a popup with the actions that apply to one component.
|
||||||
|
|
||||||
|
|||||||
+37
@@ -483,6 +483,43 @@ def rename_component(comp_element, new_name):
|
|||||||
return True
|
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():
|
def allocate_component_id():
|
||||||
"""Lowest unused id, matching how this app's ids are dense with no gaps."""
|
"""Lowest unused id, matching how this app's ids are dense with no gaps."""
|
||||||
used = set()
|
used = set()
|
||||||
|
|||||||
@@ -378,3 +378,44 @@ class LiveRenameCommand(RenameComponentCommand):
|
|||||||
raise LiveUndoError(f"{self.old_name} was not renamed to {self.new_name} "
|
raise LiveUndoError(f"{self.old_name} was not renamed to {self.new_name} "
|
||||||
"on the device")
|
"on the device")
|
||||||
super().redo()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user