diff --git a/app_state.py b/app_state.py index 5d39cb0..8a717c0 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.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 diff --git a/main.py b/main.py index 7be3b21..2b89ef1 100644 --- a/main.py +++ b/main.py @@ -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("<>", 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 <>; 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 diff --git a/sax_file.py b/sax_file.py index f590c43..cbc59fc 100644 --- a/sax_file.py +++ b/sax_file.py @@ -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