Palette: drag onto the tree to add inside a component; hover opens branches

A palette drag dropped on a tree row adds the component inside that row's
component, placed under its lowest child. The row under the pointer is
highlighted, and resting on a collapsed branch opens it, loading it from
the device when live without moving the selection or the open sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 21:39:31 +03:00
co-authored by Claude Opus 5.5
parent bc4e28d3f7
commit 4ab4b4d588
3 changed files with 169 additions and 17 deletions
+4 -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.154"
VERSION = "0.0.0.156"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
@@ -54,6 +54,9 @@ CLEAN_TINT = "white"
DIRTY_NAME_COLOUR = "#c00000"
DIRTY_NAME_TAG = "unsaved_file"
# Marks the tree row a palette drag would drop into.
TREE_DROP_TAG = "palette_drop_target"
# A themed Treeview maps the selected state to SystemHighlightText, and that mapping beats
# a row tag's own foreground -- so the file name turned white-on-blue the moment its row
# was selected, which is exactly when the root sheet is the one being looked at. The
+132 -13
View File
@@ -2655,8 +2655,12 @@ def _start_cov_polling():
_cov_poll_id = app_state.window.after(100, poll)
def _live_tree_expand(comp_id, tree_item_id):
"""Called when user expands a tree node in live mode to lazy-load children."""
def _live_tree_expand(comp_id, tree_item_id, select=True, on_loaded=None):
"""Called when user expands a tree node in live mode to lazy-load children.
select=False loads without moving the selection or the open sheet, as a palette drag
hovering over a branch does. on_loaded gets the rebuilt row of the expanded node.
"""
ds = app_state.device_session
if ds is None or not ds.is_connected:
return
@@ -2681,14 +2685,20 @@ def _live_tree_expand(comp_id, tree_item_id):
# Re-open and re-select the node without triggering a wiresheet
# re-render — the sheet is already showing the right content and
# a redundant close_wiresheet + open_wiresheet cycle can lose links.
if not select:
_rebind_open_sheet()
for iid, elem in app_state.tree_element_map.items():
if elem.get("id") == str(comp_id):
app_state.current_sheet_parent = elem
_restoring_selection = True
tree.item(iid, open=True)
tree.selection_set(iid)
tree.see(iid)
tree.after_idle(_clear_restoring_flag)
if select:
app_state.current_sheet_parent = elem
_restoring_selection = True
tree.selection_set(iid)
tree.see(iid)
tree.after_idle(_clear_restoring_flag)
tree.after_idle(fit_tree_column, tree)
if on_loaded is not None:
on_loaded(iid)
break
app_state.set_status(f"Loaded {len(children)} children")
@@ -2701,6 +2711,25 @@ def _live_tree_expand(comp_id, tree_item_id):
threading.Thread(target=_bg, daemon=True).start()
def _rebind_open_sheet():
"""Points the open sheet at its element in a freshly built DOM and repaints it.
A lazy load replaces the whole DOM. The sheet on screen still holds elements of the
old one, and edits made through them would land in a document nobody saves.
"""
old_parent = app_state.current_sheet_parent
if old_parent is None:
wiresheet.render_current_sheet()
return
old_id = old_parent.get("id")
app_element = app_state.xml_root_element.find("app")
app_state.current_sheet_parent = next(
(elem for elem in app_state.xml_root_element.iter()
if elem.tag in ("app", "comp") and old_id is not None and elem.get("id") == old_id),
app_element)
wiresheet.render_current_sheet()
# --- UI Setup ---
window = tk.Tk()
app_state.window = window
@@ -2933,6 +2962,7 @@ tree = ttk.Treeview(
)
# Configured once, for the life of the tree. Only the row's tags change after this.
tree.tag_configure(app_state.DIRTY_NAME_TAG, foreground=app_state.DIRTY_NAME_COLOUR)
tree.tag_configure(app_state.TREE_DROP_TAG, background=app_state.NAV_SELECTION_BG)
app_state.tree = tree
attach_tree_scrollbars(frame_tree, tree)
tree.bind("<<TreeviewSelect>>", on_tree_select)
@@ -3039,10 +3069,15 @@ def refresh_palette_kits():
# --- Palette drag and drop ---
# Tk has no drag-and-drop between widgets, so the gesture is assembled by hand:
# remember what was pressed, start dragging past a small threshold, follow the pointer
# with a borderless ghost window, and on release test the pointer against the canvas.
# with a borderless ghost window, and on release test the pointer against the canvas
# and the navigation tree. A drop on a tree row puts the component inside that row's
# component, which need not be the sheet that is open. Resting on a collapsed branch
# opens it (loading it from the device when live), so a drop can reach any depth.
DRAG_THRESHOLD = 6
HOVER_EXPAND_MS = 700
palette_drag = {"kit": None, "type": None, "origin": None, "active": False, "ghost": None}
palette_drag = {"kit": None, "type": None, "origin": None, "active": False, "ghost": None,
"tree_row": None, "expand_job": None}
def _destroy_drag_ghost():
@@ -3051,8 +3086,54 @@ def _destroy_drag_ghost():
palette_drag["ghost"] = None
def _mark_tree_drop_row(row):
"""Highlights the tree row a drop would land in; None clears the highlight.
Done with a tag rather than the selection, since selecting a row opens its sheet.
"""
previous = palette_drag["tree_row"]
if previous == row:
return
if previous is not None and tree.exists(previous):
tags = [t for t in tree.item(previous, "tags") if t != app_state.TREE_DROP_TAG]
tree.item(previous, tags=tags)
if row is not None:
tree.item(row, tags=list(tree.item(row, "tags")) + [app_state.TREE_DROP_TAG])
palette_drag["tree_row"] = row
_cancel_hover_expand()
if row is not None and tree.get_children(row) and not tree.item(row, "open"):
palette_drag["expand_job"] = window.after(HOVER_EXPAND_MS, _hover_expand, row)
def _cancel_hover_expand():
if palette_drag["expand_job"] is not None:
window.after_cancel(palette_drag["expand_job"])
palette_drag["expand_job"] = None
def _hover_expand(row):
"""Opens the branch a palette drag has rested on, loading it first on a device."""
palette_drag["expand_job"] = None
if not palette_drag["active"] or palette_drag["tree_row"] != row or not tree.exists(row):
return
tree.item(row, open=True) # in code, so no <<TreeviewOpen>>; loaded here instead
tree.after_idle(fit_tree_column, tree)
elem = app_state.tree_element_map.get(row)
comp_id = elem.get("id") if elem is not None else None
if app_state.live_mode and comp_id is not None:
# The load rebuilds the tree, so the highlight goes back on the new row for it.
def relight(new_row):
if palette_drag["active"] and palette_drag["tree_row"] == row:
_mark_tree_drop_row(new_row)
_live_tree_expand(int(comp_id), row, select=False, on_loaded=relight)
def _reset_palette_drag():
_destroy_drag_ghost()
_mark_tree_drop_row(None)
_cancel_hover_expand()
palette_drag.update(kit=None, type=None, origin=None, active=False)
@@ -3100,12 +3181,45 @@ def on_palette_motion(event):
if palette_drag["ghost"] is not None:
palette_drag["ghost"].geometry(f"+{event.x_root + 12}+{event.y_root + 10}")
_mark_tree_drop_row(_tree_row_under_pointer(event.x_root, event.y_root))
def _pointer_over_widget(widget, x_root, y_root):
left = widget.winfo_rootx()
top = widget.winfo_rooty()
return (left <= x_root < left + widget.winfo_width()
and top <= y_root < top + widget.winfo_height())
def _pointer_over_canvas(x_root, y_root):
left = canvas.winfo_rootx()
top = canvas.winfo_rooty()
return (left <= x_root < left + canvas.winfo_width()
and top <= y_root < top + canvas.winfo_height())
return _pointer_over_widget(canvas, x_root, y_root)
def _tree_row_under_pointer(x_root, y_root):
"""The navigation tree row under the pointer, or None off the tree or between rows."""
if app_state.xml_root_element is None or not _pointer_over_widget(tree, x_root, y_root):
return None
row = tree.identify_row(y_root - tree.winfo_rooty())
if not row:
return None
elem = app_state.tree_element_map.get(row)
if elem is not None and elem.get("type") == "":
return None # a "..." stand-in for a child not loaded from the device
return row
def _drop_on_tree_row(kit_name, type_name, row):
"""Adds kit::type inside the component a tree row stands for."""
parent = app_state.tree_element_map.get(row)
if parent is None and tree.parent(row) == "":
parent = app_state.xml_root_element.find("app") # the root row is the app
if parent is None:
return
# Opened first, so the rebuild after the add keeps it open and the new row shows.
tree.item(row, open=True)
grid_x, grid_y = sax_file.grid_position_below_children(parent)
sax_file.add_component(kit_name, type_name, grid_x, grid_y, parent=parent)
def on_palette_release(event):
@@ -3117,6 +3231,11 @@ def on_palette_release(event):
if not was_dragging or not kit_name or not type_name:
return
tree_row = _tree_row_under_pointer(event.x_root, event.y_root)
if tree_row is not None:
_drop_on_tree_row(kit_name, type_name, tree_row)
return
if not _pointer_over_canvas(event.x_root, event.y_root):
return
+33 -3
View File
@@ -16,6 +16,7 @@ from sax_dom import (
find_component_by_path,
find_parent_element,
get_component_path,
read_meta_property,
restore_children,
snapshot_children,
update_meta_property,
@@ -936,9 +937,38 @@ def build_config_values(full_type, overrides=None, device_values=None):
return config_values
def add_component(kit_name, type_name, grid_x, grid_y):
"""Places a new component of kit::type at a grid position. Returns the element."""
parent = current_sheet_container()
def grid_position_below_children(parent):
"""A spot for a component added without a drop point: under the parent's lowest child.
Used when a component is placed from the tree, where there is no point on the sheet
to drop it at. Lining it up on the left margin below everything already there keeps
it clear of the existing boxes in the common case; Tidy sorts out the rest.
"""
min_step = wiresheet.layout_margin(app_state.meta_scale) // app_state.meta_scale
lowest = None
for child in parent:
if child.tag != "comp":
continue
meta_prop = child.find("prop[@name='meta']")
y = read_meta_property(meta_prop.get("val") if meta_prop is not None else None)[1]
lowest = y if lowest is None else max(lowest, y)
if lowest is None:
return min_step, min_step
return min_step, max(min_step, min(wiresheet.META_COORD_MAX, lowest + TREE_DROP_GAP))
# Meta steps between the lowest existing box and one added under it from the tree.
TREE_DROP_GAP = 8
def add_component(kit_name, type_name, grid_x, grid_y, parent=None):
"""Places a new component of kit::type at a grid position. Returns the element.
parent is the component it goes inside; None means the sheet that is open.
"""
if parent is None:
parent = current_sheet_container()
if parent is None:
messagebox.showwarning("Add Component", "Open a SAX file first.")
return None