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:
+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.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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user