Add Rename to the component right-click menu

Rename is offered from both the canvas and the navigation tree. Names are
checked against Sedona's Component.checkName rules (1-7 ASCII chars, a
letter first, then letters, digits or '_') and must be unique among
siblings. In file mode link paths through the old name are rewritten and
the rename is one undo step; in live mode it goes to the device via Sox
and warns if the device rejects it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 20:22:49 +03:00
co-authored by Claude Opus 5.5
parent a2d4be6ea8
commit 088fe69964
4 changed files with 180 additions and 1 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 configparser
import os import os
VERSION = "0.0.0.136" VERSION = "0.0.0.137"
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
+71
View File
@@ -1387,6 +1387,71 @@ def open_link_dialog(source_element, target_element):
ttk.Button(btn_frame, text="Cancel", width=10, command=dialog.destroy).pack(side=tk.LEFT, padx=5) ttk.Button(btn_frame, text="Cancel", width=10, command=dialog.destroy).pack(side=tk.LEFT, padx=5)
def open_rename_dialog(comp):
"""Asks for a new name, checking Sedona's naming rules as it is typed."""
window = app_state.window
root = app_state.xml_root_element
if root is None or comp.tag != "comp":
return
parent = find_parent_element(root, comp)
old_name = comp.get("name", "")
dialog = tk.Toplevel(window)
dialog.title("Rename Component")
dialog.resizable(False, False)
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)
tk.Label(main_frame, text=f"New name for '{old_name}' [id: {comp.get('id', '?')}]:",
anchor="w").grid(row=0, column=0, sticky="w")
name_var = tk.StringVar(value=old_name)
entry = ttk.Entry(main_frame, textvariable=name_var, width=24)
entry.grid(row=1, column=0, sticky="we", pady=(4, 0))
tk.Label(main_frame, text="1-7 characters: a letter, then letters, digits or '_'.",
anchor="w", fg="#666666").grid(row=2, column=0, sticky="w", pady=(4, 0))
error_var = tk.StringVar(value="")
tk.Label(main_frame, textvariable=error_var, anchor="w", fg="#b00020",
wraplength=320, justify="left").grid(row=3, column=0, sticky="w")
btn_frame = tk.Frame(main_frame)
btn_frame.grid(row=4, column=0, 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 current_error():
name = name_var.get()
if name == old_name:
return None
return sax_file.check_component_name(name, parent, comp)
def on_change(*_):
err = current_error()
error_var.set(err or "")
ok_button.state(["disabled"] if err else ["!disabled"])
def on_ok(*_):
if current_error() is not None:
return
new_name = name_var.get()
dialog.destroy()
# An open panel may show the old name, so it is refreshed along with the sheet.
if new_name != old_name and sax_file.rename_component(comp, new_name):
refresh_all_views()
ok_button.configure(command=on_ok)
name_var.trace_add("write", on_change)
entry.bind("<Return>", on_ok)
dialog.bind("<Escape>", lambda _: dialog.destroy())
entry.focus_set()
entry.select_range(0, tk.END)
def fill_component_popup(menu, comp): def fill_component_popup(menu, comp):
"""Fills a popup with the actions that apply to one component. """Fills a popup with the actions that apply to one component.
@@ -1415,6 +1480,12 @@ def fill_component_popup(menu, comp):
command=lambda: show_user_service(comp), command=lambda: show_user_service(comp),
) )
menu.add_separator() menu.add_separator()
if comp.tag == "comp":
# The app itself is named by its appName property, not by a component name.
menu.add_command(
label="Rename...",
command=lambda: open_rename_dialog(comp),
)
menu.add_command( menu.add_command(
label="Delete Component", label="Delete Component",
command=lambda: delete_component(comp), command=lambda: delete_component(comp),
+86
View File
@@ -344,6 +344,92 @@ def save_file_as():
MAX_NAME_LENGTH = 7 # every component name in a Sedona app observes this MAX_NAME_LENGTH = 7 # every component name in a Sedona app observes this
def check_component_name(name, parent=None, element=None):
"""Why `name` cannot name a component, or None if it can.
Mirrors Sedona's Component.checkName -- 1 to 7 ASCII characters, a letter first, then
letters, digits or '_' -- plus the uniqueness among siblings that OfflineApp enforces.
`element` is the component being renamed, which may keep its own name.
"""
if len(name) == 0:
return "The name is empty."
if len(name) > MAX_NAME_LENGTH:
return f"The name is longer than {MAX_NAME_LENGTH} characters."
for i, c in enumerate(name):
if "A" <= c <= "Z" or "a" <= c <= "z":
continue
if i == 0:
return "The name must start with a letter (A-Z or a-z)."
if "0" <= c <= "9" or c == "_":
continue
return f"'{c}' is not allowed; use only letters, digits and '_'."
if parent is not None:
for sibling in parent:
if sibling.tag == "comp" and sibling is not element and sibling.get("name") == name:
return f"'{name}' is already used by another component in the same parent."
return None
def rename_component(comp_element, new_name):
"""Renames a component and rewrites the link paths that ran through it.
Returns True when the rename happened. The name is validated here as well as in the
dialog, so nothing can put a name into the file that Sedona would refuse to load.
"""
root = app_state.xml_root_element
if root is None or comp_element.tag != "comp":
return False
old_name = comp_element.get("name", "")
if new_name == old_name:
return False
parent = find_parent_element(root, comp_element)
err = check_component_name(new_name, parent, comp_element)
if err is not None:
messagebox.showwarning("Rename Component", f"'{new_name}' is not a valid name.\n\n{err}")
return False
if app_state.live_mode:
if not live_hooks.live_rename_component(comp_element, new_name):
live_hooks.warn_failure("Rename Component",
f"{old_name} was not renamed on the device")
return False
old_path = get_component_path(comp_element, root)
new_path = old_path[:-len(old_name)] + new_name
# Links address components by path, so every one through the old name moves with it.
link_changes = []
links_elem = root.find("links")
if links_elem is not None:
for link in links_elem:
if link.tag != "link":
continue
for attr in ("from", "to"):
ref = link.get(attr, "")
if ref.startswith(old_path + ".") or ref.startswith(old_path + "/"):
link_changes.append((link, attr, ref, new_path + ref[len(old_path):]))
comp_element.set("name", new_name)
for link, attr, _, new_ref in link_changes:
link.set(attr, new_ref)
if not app_state.live_mode:
undo.push(undo.RenameComponentCommand(
comp_element, old_name, new_name, link_changes,
label=f"rename {old_name} to {new_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"{old_name} renamed to {new_name}{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()
+22
View File
@@ -248,3 +248,25 @@ class DeleteComponentCommand:
links_parent.remove(link) links_parent.remove(link)
if self.element in list(self.parent): if self.element in list(self.parent):
self.parent.remove(self.element) self.parent.remove(self.element)
class RenameComponentCommand:
"""A rename, plus every link whose path ran through the component's old name."""
def __init__(self, element, old_name, new_name, link_changes, label="rename"):
# link_changes: list of (link_element, attr, old_value, new_value)
self.element = element
self.old_name = old_name
self.new_name = new_name
self.link_changes = link_changes
self.label = label
def undo(self):
self.element.set("name", self.old_name)
for link, attr, old_value, _ in self.link_changes:
link.set(attr, old_value)
def redo(self):
self.element.set("name", self.new_name)
for link, attr, _, new_value in self.link_changes:
link.set(attr, new_value)