From 088fe69964e20c0a9c24ee53f66b701d4d84a810 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Fri, 2 Oct 2026 20:22:49 +0300 Subject: [PATCH] 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 --- app_state.py | 2 +- main.py | 71 +++++++++++++++++++++++++++++++++++++++++++ sax_file.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++ undo.py | 22 ++++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) diff --git a/app_state.py b/app_state.py index eff25b4..22a179f 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.136" +VERSION = "0.0.0.137" 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/main.py b/main.py index 06161c7..799d25a 100644 --- a/main.py +++ b/main.py @@ -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) +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("", on_ok) + dialog.bind("", lambda _: dialog.destroy()) + entry.focus_set() + entry.select_range(0, tk.END) + + def fill_component_popup(menu, comp): """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), ) 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( label="Delete Component", command=lambda: delete_component(comp), diff --git a/sax_file.py b/sax_file.py index cf78e38..d53cecd 100644 --- a/sax_file.py +++ b/sax_file.py @@ -344,6 +344,92 @@ def save_file_as(): 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(): """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 c59d7b4..f6d188d 100644 --- a/undo.py +++ b/undo.py @@ -248,3 +248,25 @@ class DeleteComponentCommand: links_parent.remove(link) if self.element in list(self.parent): 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)