From 75df6d0a6edd9dc850f56039ce40f8afa79c0d48 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Sun, 18 Oct 2026 14:29:13 +0300 Subject: [PATCH] Wiresheet: left-drag rectangle selection, group move, Ctrl+A select all Rectangle selection moves to the left button on bare sheet (Ctrl adds); right-click is the menu again. Dragging a selected box by its title bar moves the whole selection as one undoable step. Co-Authored-By: Claude Opus 5.5 --- app_state.py | 2 +- link_drag.py | 84 ++++++++++++--------- main.py | 205 ++++++++++++++++++++++++++++++++++----------------- 3 files changed, 188 insertions(+), 103 deletions(-) diff --git a/app_state.py b/app_state.py index 7cb2f76..1a899a6 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.177" +VERSION = "0.0.0.178" 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/link_drag.py b/link_drag.py index 4886a93..92c7da7 100644 --- a/link_drag.py +++ b/link_drag.py @@ -11,9 +11,9 @@ either end. While dragging, the cursor points along the flow of the link over a would take it, and turns into a no-entry sign over one that would not, so a bad drop is visible before the button is released. -A right press that never moves still opens the component's context menu. A right drag -that starts off a slot row draws a selection rectangle instead, and selects the boxes -wholly inside it on release; with Ctrl held they join the current selection. A left press +A right press that never moves still opens the component's context menu. A left drag +that starts on bare sheet draws a selection rectangle, and selects the boxes wholly +inside it on release; with Ctrl held they join the current selection. A left press anywhere on a box selects it, but only a press on its title bar moves it; on a slot row it draws a link instead, as in Workbench. """ @@ -41,7 +41,7 @@ CTRL_MASK = 0x0004 _cursors = {} # role -> Tk cursor spec, built on first use _press = None # pending right press: {"hit", "x", "y", "event"} _drag = None # live link drag: {"hit", "anchor"} -_marquee = None # right press off a slot: {"x", "y", "cx", "cy", "state", "active"} +_marquee = None # left press on bare sheet: {"x", "y", "cx", "cy", "state", "active"} _menu_callback = None # opens the ordinary right-click menu for an event _select_boxes = None # makes a list of comp tags the box selection _link_dialog = None # opens the Link dialog: (source, target, src_slot, tgt_slot) @@ -434,9 +434,34 @@ def _finish(event): return "link" -# Right button: on a slot it drags a link. Anywhere else, dragging pulls out a selection -# rectangle that selects every box wholly inside it on release (Ctrl adds them to the -# current selection). A press that never moves is the menu. +# Right button: on a slot it drags a link, and a press that never moves is the menu. + +def on_right_press(event): + if _start_press(event) is None: + _menu_callback(event) + + +def on_right_motion(event): + _drag_motion(event) + + +def on_right_release(event): + if _finish(event) == "click": + _menu_callback(event) + + +# Left button on bare sheet: dragging pulls out a selection rectangle that selects every +# box wholly inside it on release (Ctrl adds them to the current selection). + +def _on_bare_sheet(cx, cy): + """True if no box or link lies under a canvas point.""" + canvas = app_state.canvas + for item in canvas.find_overlapping(cx - 3, cy - 3, cx + 3, cy + 3): + tags = canvas.gettags(item) + if "component" in tags or "link" in tags: + return False + return True + def _marquee_motion(event): """Draws the selection rectangle once the pointer has moved far enough.""" @@ -486,30 +511,6 @@ def _marquee_finish(event): return True -def on_right_press(event): - global _marquee - _marquee = None - if _start_press(event) is None: - canvas = app_state.canvas - _marquee = {"x": event.x, "y": event.y, "cx": canvas.canvasx(event.x), - "cy": canvas.canvasy(event.y), "state": event.state, "active": False} - - -def on_right_motion(event): - if _marquee is not None: - _marquee_motion(event) - else: - _drag_motion(event) - - -def on_right_release(event): - if _marquee is not None: - if not _marquee_finish(event): - _menu_callback(event) - elif _finish(event) == "click": - _menu_callback(event) - - # Left button: anywhere on a box selects it. Only the title bar moves it; a slot row # drags out a link, and the rest of the box does nothing more than select. @@ -519,6 +520,19 @@ def _on_title_bar(comp_tag, cy): def on_left_press(event): + global _marquee + _marquee = None + canvas = app_state.canvas + cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y) + if _on_bare_sheet(cx, cy): + app_state.drag_data["tag"] = None + if not event.state & CTRL_MASK: + # A plain click on bare sheet still clears the selection, drag or not. + wiresheet.deselect_link() + _select_boxes([]) + _marquee = {"x": event.x, "y": event.y, "cx": cx, "cy": cy, + "state": event.state, "active": False} + return _box_handlers["press"](event) # selection, and arms a box move if _start_press(event) is not None: app_state.canvas.delete("slot_hover") @@ -528,14 +542,18 @@ def on_left_press(event): def on_left_motion(event): - if _press is not None or _drag is not None: + if _marquee is not None: + _marquee_motion(event) + elif _press is not None or _drag is not None: _drag_motion(event) else: _box_handlers["motion"](event) def on_left_release(event): - if _finish(event) is None: + if _marquee is not None: + _marquee_finish(event) + elif _finish(event) in (None, "click"): _box_handlers["release"](event) diff --git a/main.py b/main.py index 9f5feb7..23bb77b 100644 --- a/main.py +++ b/main.py @@ -1344,6 +1344,24 @@ def select_link_on_open_sheet(link_elem): app_state.set_status("That link is not painted on this sheet.", warn=True) +def select_all_boxes(event=None): + """Ctrl+A: selects every box on the open wiresheet.""" + # Bound application-wide like Del, so a text field keeps its own select-all. + if editor_panel_showing(): + return None + focused = app_state.window.focus_get() + if focused is not None: + if focused.winfo_toplevel() is not app_state.window: + return None + if focused.winfo_class() in TEXT_ENTRY_CLASSES: + return None + wiresheet.deselect_link() # links and boxes are never selected together + select_canvas_boxes(list(app_state.canvas_comp_map)) + count = len(app_state.selected_canvas_tags) + app_state.set_status(f"{count} component{'s' if count != 1 else ''} selected.") + return "break" + + def delete_selected_component(event=None): """Deletes the highlighted canvas component (or selected link) and all its descendants recursively.""" # Del is bound application-wide, so it also reaches a Properties field being typed in @@ -1532,7 +1550,16 @@ def on_canvas_click(event): _toggle_canvas_box(group_tag) return - select_canvas_boxes([]) + # Pressing one of several selected boxes keeps them all selected, so a drag moves + # them together; a press that never moves narrows the selection to it on release. + keep_group = (group_tag and not wire_tag and group_tag in app_state.selected_canvas_tags + and len(app_state.selected_canvas_tags) > 1) + app_state.drag_data["group"] = [] + app_state.drag_data["moved"] = False + app_state.drag_data["collapse_to"] = group_tag if keep_group else None + + if not keep_group: + select_canvas_boxes([]) if wire_tag: wiresheet.select_link(wire_tag) return @@ -1541,20 +1568,26 @@ def on_canvas_click(event): app_state.drag_data["tag"] = group_tag app_state.drag_data["x"] = cx app_state.drag_data["y"] = cy - select_canvas_boxes([group_tag]) + if keep_group: + # Del and the menus still act on the box pressed last. + app_state.selected_canvas_tag = group_tag + else: + select_canvas_boxes([group_tag]) - # Where the box starts out. The drag is measured as an offset from this pair + # Where each moving box starts out. The drag is measured as an offset from these # rather than accumulated per motion event, so repeated snapping cannot drift. - origin = wiresheet.get_box_origin(group_tag) - app_state.drag_data["box_x"] = origin[0] if origin else None - app_state.drag_data["box_y"] = origin[1] if origin else None - - # Snapshot the pre-drag meta so the move can be pushed as one undoable step. - dragged = app_state.canvas_comp_map.get(group_tag) - meta_prop = dragged.find("prop[@name='meta']") if dragged is not None else None - app_state.drag_data["meta_before"] = meta_prop.get("val") if meta_prop is not None else None - - # Bring the clicked box clear of any neighbour it happens to sit under. + # Each entry also snapshots the pre-drag meta, so the move is one undoable step. + moving = list(app_state.selected_canvas_tags) if keep_group else [group_tag] + for tag in moving: + origin = wiresheet.get_box_origin(tag) + comp = app_state.canvas_comp_map.get(tag) + meta_prop = comp.find("prop[@name='meta']") if comp is not None else None + app_state.drag_data["group"].append({ + "tag": tag, "origin": origin, + "meta_before": meta_prop.get("val") if meta_prop is not None else None, + }) + # Bring the moving boxes clear of any neighbour they happen to sit under. + canvas.tag_raise(tag) canvas.tag_raise(group_tag) wiresheet.redraw_links() @@ -2071,18 +2104,17 @@ def on_canvas_drag(event): return canvas = app_state.canvas - group_tag = app_state.drag_data["tag"] cx = canvas.canvasx(event.x) cy = canvas.canvasy(event.y) + group = app_state.drag_data.get("group") or [] - box_x = app_state.drag_data.get("box_x") - origin = wiresheet.get_box_origin(group_tag) - - if box_x is None or origin is None: - # Nothing to align against, so the box just follows the pointer one to one. - canvas.move(group_tag, cx - app_state.drag_data["x"], cy - app_state.drag_data["y"]) + if not group or any(entry["origin"] is None for entry in group): + # Nothing to align against, so the boxes just follow the pointer one to one. + for tag in [entry["tag"] for entry in group] or [app_state.drag_data["tag"]]: + canvas.move(tag, cx - app_state.drag_data["x"], cy - app_state.drag_data["y"]) app_state.drag_data["x"] = cx app_state.drag_data["y"] = cy + app_state.drag_data["moved"] = True wiresheet.redraw_links() return @@ -2091,40 +2123,66 @@ def on_canvas_drag(event): # Canvas coordinates carry the sheet's zoom, so the step and the margin do too. step = app_state.meta_scale * app_state.zoom margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom - - # Follow the pointer, but land the box on a step every step of the way, and keep it - # out of the margin strip along the top and left canvas edges. - wanted_x = box_x + (cx - app_state.drag_data["x"]) - wanted_y = app_state.drag_data["box_y"] + (cy - app_state.drag_data["y"]) - # The box's corner also stays on the sheet, the furthest meta can store it. + # A box's corner also stays on the sheet, the furthest meta can store it. limit = wiresheet.META_COORD_MAX * step - target_x = min(limit, max(margin, round(wanted_x / step) * step)) - target_y = min(limit, max(margin, round(wanted_y / step) * step)) - shift_x = target_x - origin[0] - shift_y = target_y - origin[1] + # Follow the pointer, but keep every moving box out of the margin strip along the + # top and left canvas edges and on the sheet -- the whole group stops together, so + # the boxes keep their places relative to one another. + dx = cx - app_state.drag_data["x"] + dy = cy - app_state.drag_data["y"] + xs = [entry["origin"][0] for entry in group] + ys = [entry["origin"][1] for entry in group] + dx = min(limit - max(xs), max(margin - min(xs), dx)) + dy = min(limit - max(ys), max(margin - min(ys), dy)) - if shift_x or shift_y: - canvas.move(group_tag, shift_x, shift_y) - # Wires are not part of the dragged group, so they are re-routed against the + shifted = False + for entry in group: + # Land each box on a step every step of the way. + start_x, start_y = entry["origin"] + target_x = min(limit, max(margin, round((start_x + dx) / step) * step)) + target_y = min(limit, max(margin, round((start_y + dy) / step) * step)) + origin = wiresheet.get_box_origin(entry["tag"]) + if origin is None: + continue + shift_x = target_x - origin[0] + shift_y = target_y - origin[1] + if shift_x or shift_y: + canvas.move(entry["tag"], shift_x, shift_y) + shifted = True + + if shifted: + app_state.drag_data["moved"] = True + # Wires are not part of the dragged groups, so they are re-routed against the # new positions. wiresheet.redraw_links() def on_canvas_release(event): + collapse_to = app_state.drag_data.pop("collapse_to", None) + if collapse_to and not app_state.drag_data.get("moved"): + # A click that never moved on one of several selected boxes picks it alone. + select_canvas_boxes([collapse_to]) + if not app_state.drag_data["tag"]: return canvas = app_state.canvas step = app_state.meta_scale * app_state.zoom - group_tag = app_state.drag_data["tag"] - origin = wiresheet.get_box_origin(group_tag) + margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom + limit = wiresheet.META_COORD_MAX * step + group = app_state.drag_data.get("group") or [ + {"tag": app_state.drag_data["tag"], "meta_before": None}] - if origin is not None: + changes = [] + failed = [] + for entry in group: + group_tag = entry["tag"] + origin = wiresheet.get_box_origin(group_tag) + if origin is None: + continue current_x, current_y = origin - margin = wiresheet.layout_margin(app_state.meta_scale) * app_state.zoom - limit = wiresheet.META_COORD_MAX * step snapped_x = min(limit, max(margin, round(current_x / step) * step)) snapped_y = min(limit, max(margin, round(current_y / step) * step)) @@ -2139,40 +2197,47 @@ def on_canvas_release(event): raw_y = min(wiresheet.META_COORD_MAX, int(round(snapped_y / step))) comp_element = app_state.canvas_comp_map.get(group_tag) - if comp_element is not None: - meta_str = None - orig_prop = comp_element.find("prop[@name='meta']") - if orig_prop is not None: - meta_str = orig_prop.get("val") + if comp_element is None: + continue + meta_str = None + orig_prop = comp_element.find("prop[@name='meta']") + if orig_prop is not None: + meta_str = orig_prop.get("val") - orig_x, orig_y, _, _, _, _, _, _ = read_meta_property(meta_str) - if orig_x != raw_x or orig_y != raw_y: - meta_before = app_state.drag_data.get("meta_before", meta_str) - update_meta_property(comp_element, meta_str, raw_x, raw_y) - if app_state.live_mode: - new_meta = comp_element.find("prop[@name='meta']") - if new_meta is None or not live_hooks.live_set_prop_value( - comp_element, "meta", new_meta.get("val", "0")): - # Back where the device still has it, so the sheet tells the truth. - if meta_str is None: - remove_prop(comp_element, "meta") - else: - set_prop_value(comp_element, "meta", meta_str) - app_state.drag_data["tag"] = None - wiresheet.render_current_sheet() - live_hooks.warn_failure( - "Move", f"{comp_element.get('name', 'The component')} was not " - "moved on the device") - return - command_class = (undo.LiveMetaMoveCommand if app_state.live_mode - else undo.MetaMoveCommand) - undo.push(command_class( - [(comp_element, meta_before, raw_x, raw_y)], - label=f"move {comp_element.get('name', 'component')}" - )) - app_state.mark_dirty() + orig_x, orig_y, _, _, _, _, _, _ = read_meta_property(meta_str) + if orig_x == raw_x and orig_y == raw_y: + continue + meta_before = entry.get("meta_before") or meta_str + update_meta_property(comp_element, meta_str, raw_x, raw_y) + if app_state.live_mode: + new_meta = comp_element.find("prop[@name='meta']") + if new_meta is None or not live_hooks.live_set_prop_value( + comp_element, "meta", new_meta.get("val", "0")): + # Back where the device still has it, so the sheet tells the truth. + if meta_str is None: + remove_prop(comp_element, "meta") + else: + set_prop_value(comp_element, "meta", meta_str) + failed.append(comp_element.get("name", "?")) + continue + changes.append((comp_element, meta_before, raw_x, raw_y)) app_state.drag_data["tag"] = None + app_state.drag_data["group"] = [] + + if changes: + command_class = (undo.LiveMetaMoveCommand if app_state.live_mode + else undo.MetaMoveCommand) + label = (f"move {changes[0][0].get('name', 'component')}" if len(changes) == 1 + else f"move {len(changes)} components") + undo.push(command_class(changes, label=label)) + app_state.mark_dirty() + + if failed: + wiresheet.render_current_sheet() + live_hooks.warn_failure("Move", f"{', '.join(failed)} not moved on the device") + return + wiresheet.redraw_links() @@ -3249,6 +3314,8 @@ SHORTCUTS = [ ("9", do_zoom_fit), ("KP_9", do_zoom_fit), ("p", wiresheet.print_sheet), + ("a", select_all_boxes), + ("A", select_all_boxes), ] for key, handler in SHORTCUTS: window.bind_all(f"", handler)