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
+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