diff --git a/CLAUDE.md b/CLAUDE.md index 49bc4f8..d98a9b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,11 +53,18 @@ operations + interpreter: `execute_call`, which **reuses the CLI command functions** (no behavioral drift). Every mutation saves to disk and emits `changed`. - `viewport.py` — embedded `pyvistaqt.QtInteractor`; click a board to select. -- `panels.py` — parts list + selected-part inspector (editable length/yaw/tilt) - + quick-action buttons. `command_bar.py` — text + push-to-talk + transcript, - with slow work (LLM/dictate/TTS) on a `QThreadPool` (`workers.py`). -- One visible **selection** (`scene.selection`) is shared by 3D click, the - list, and voice — so "delete that" is unambiguous. +- `panels.py` — parts list (ExtendedSelection: Ctrl/Shift multi-select) + + selected-part inspector (editable length/yaw/tilt) + quick-action buttons. + `command_bar.py` — text + push-to-talk + transcript, with slow work + (LLM/dictate/TTS) on a `QThreadPool` (`workers.py`). +- `numpad.py` — a numberpad control panel (2/4/6/8 move, 1/3/7/9 rotate, +/− + raise/lower, 0/. front/iso, 5 fit) that also responds to the **physical + numpad keys** (MainWindow.keyPressEvent forwards them when not typing). +- **Multi-selection**: `controller.selected` is a list driven by 3D Ctrl+click + (`viewport.picked` carries an additive flag) and list multi-select. Group ops + (`move_selected`/`rotate_selected`/stand/lay/sand/delete) apply to all selected + in one undo step via `scene.batch()`. Voice "move these" works because the + selected ids are fed into the interpreter prompt. Scene file location: `$WOODSHOP_SCENE` or `~/.local/share/woodshop/scene.json`. Named projects: `~/.local/share/woodshop/projects/.json`. diff --git a/README.md b/README.md index 9b15cde..ce941a4 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,16 @@ python scripts/gen_wood_tools.py # register the wood-* CmdForge tools woodshop # launches the unified desktop app ``` -One window with the **3D viewport** (click a board to select it), a **parts -panel** (list + selected-part inspector + quick-action buttons), and a -**command bar** at the bottom where you type or push-to-talk (🎤). Mouse, -keyboard, and voice all drive the same scene and the same visible selection, so -"delete that" / the Delete button / saying "delete the front-left leg" are -interchangeable. Menus cover New/Open/Save projects, Export STL/STEP, Save -Image, Undo/Redo, camera views, and Build templates. +One window with the **3D viewport** (click a board to select it; Ctrl+click to +select several), a **parts panel** (list + selected-part inspector + +quick-action buttons), a **numberpad control panel** (move/rotate the selection +by clicking or with your keyboard's numpad — 2/4/6/8 move, 1/3/7/9 rotate, +/− +raise/lower, 0/. front/iso, 5 fit), and a **command bar** where you type or +push-to-talk (🎤). Mouse, keyboard, and voice all drive the same scene and the +same visible selection — so "move these 4 inches", the numpad 8 key, and the +move button are interchangeable, and act on every selected board at once (one +undo). Menus cover New/Open/Save projects, Export STL/STEP, Save Image, +Undo/Redo, camera views, and Build templates. ### Standalone tools (headless / scripting) diff --git a/src/woodshop/driver.py b/src/woodshop/driver.py index a2b6b60..f9dbb7b 100644 --- a/src/woodshop/driver.py +++ b/src/woodshop/driver.py @@ -59,6 +59,8 @@ Rules: - Refer to boards that ALREADY exist by their real id (p1, p2, ...) or their name. - For a board you place earlier in THIS response, refer to it later as $1, $2, ... numbered by the order you place boards in this response (the first wood-place is $1). +- "these" / "them" / "the selected ones" refer to the currently-selected boards + listed under the scene; emit one call per selected board (e.g. wood-move for each). - Legs and uprights must be stood up: place the board, then wood-stand it. - For wood-join, "part_b" is the board being attached (it gets moved into place); "to" is the board it attaches to. Anchor is "end" (far end) or "start". diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py index 7ad5f8d..a7ae740 100644 --- a/src/woodshop/gui/controller.py +++ b/src/woodshop/gui/controller.py @@ -67,6 +67,7 @@ class Controller(QObject): self.scene_path = Path(scene_path) if scene_path else default_scene_path() self.scene = Scene.load(self.scene_path) self._schemas: str | None = None + self.selected: list[str] = [self.scene.selection] if self.scene.selection else [] # ----- persistence / notify ---------------------------------------- def save(self) -> None: @@ -78,55 +79,111 @@ class Controller(QObject): self.logged.emit("ws", message) self.changed.emit() - # ----- selection --------------------------------------------------- - def select(self, ref: str | None) -> None: - if ref: - try: - self.scene.select(ref) - except SceneError: - return - else: - self.scene.selection = None + # ----- selection (single + multi) ---------------------------------- + def _valid(self, ids): + have = {p.id for p in self.scene.parts} + return [i for i in ids if i in have] + + def set_selected(self, ids) -> None: + """Replace the whole selection set. Primary = last in the list.""" + self.selected = self._valid(list(dict.fromkeys(ids))) + self.scene.selection = self.selected[-1] if self.selected else None self.changed.emit() + def select(self, ref: str | None) -> None: + """Single-select by id/name (e.g. from a 3D click without Ctrl).""" + if not ref: + self.set_selected([]) + return + try: + part = self.scene.get_part(ref) + except SceneError: + return + self.set_selected([part.id]) + + def toggle(self, ref: str | None) -> None: + """Ctrl+click: add/remove a board from the selection.""" + if not ref: + return + try: + pid = self.scene.get_part(ref).id + except SceneError: + return + ids = list(self.selected) + ids.remove(pid) if pid in ids else ids.append(pid) + self.set_selected(ids) + + def target_ids(self) -> list[str]: + """The boards an action applies to: the multi-selection, else the primary.""" + if self.selected: + return list(self.selected) + return [self.scene.selection] if self.scene.selection else [] + @property def selected_id(self) -> str | None: return self.scene.selection # ----- direct operations (buttons / menus) ------------------------- - def _do(self, fn, *args, **kwargs) -> None: + def _do(self, fn) -> None: try: - msg = fn(*args, **kwargs) + msg = fn() except (SceneError, ValueError, KeyError) as exc: self.logged.emit("sys", str(exc).strip('"')) return + # A single op selects its result (e.g. placing a board selects it). + self.selected = [self.scene.selection] if self.scene.selection else [] self._commit(msg if isinstance(msg, str) else None) + def _do_group(self, op, verb: str) -> None: + """Apply `op(part_id)` to every selected board as a single undo step.""" + ids = self.target_ids() + if not ids: + self.logged.emit("sys", "Nothing selected.") + return + try: + with self.scene.batch(): + for pid in ids: + op(pid) + except (SceneError, ValueError, KeyError) as exc: + self.logged.emit("sys", str(exc).strip('"')) + return + self.selected = self._valid(self.selected) # drop any deleted ids + if self.scene.selection not in {p.id for p in self.scene.parts}: + self.scene.selection = self.selected[-1] if self.selected else None + n = len(ids) + self._commit(f"{verb} {n} board{'s' if n > 1 else ''}.") + def place(self, stock: str, length_in: float): self._do(lambda: f"Placed {self.scene.place(stock, length_in).id}.") - def stand(self, ref=None): self._do(lambda: f"Set {self.scene.stand(ref).id} standing up.") - def lay(self, ref=None): self._do(lambda: f"Laid {self.scene.stand(ref, 0.0).id} flat.") - def sand(self, ref=None): self._do(lambda: f"Sanded {self.scene.finish(ref).id}.") - def delete(self, ref=None): self._do(lambda: self.scene.delete(ref)) + # group-aware (act on the whole selection) + def stand(self): self._do_group(lambda pid: self.scene.stand(pid), "Stood up") + def lay(self): self._do_group(lambda pid: self.scene.stand(pid, 0.0), "Laid flat") + def sand(self): self._do_group(lambda pid: self.scene.finish(pid), "Sanded") + def delete(self): self._do_group(lambda pid: self.scene.delete(pid), "Deleted") + + def move_selected(self, dx=0.0, dy=0.0, dz=0.0): + self._do_group(lambda pid: self.scene.move(pid, dx, dy, dz), "Moved") + + def rotate_selected(self, dyaw=0.0, dtilt=0.0): + def op(pid): + p = self.scene.get_part(pid) + self.scene.orient(pid, yaw=p.yaw_deg + dyaw, tilt=p.tilt_deg + dtilt) + self._do_group(op, "Rotated") + + def rotate_90(self): self.rotate_selected(dyaw=90) + + # single-part (act on the primary selection) def undo(self): self._do(self.scene.undo) def redo(self): self._do(self.scene.redo) def clear(self): self._do(self.scene.clear) - def duplicate(self, ref=None): self._do(lambda: f"Copied to {self.scene.copy(ref).id}.") + def duplicate(self): self._do(lambda: f"Copied to {self.scene.copy(self.scene.selection).id}.") def rename(self, ref, name): self._do(lambda: f"Named {self.scene.rename(ref, name).id}.") + def set_length(self, ref, length_in): self._do(lambda: f"Cut {self.scene.set_length(ref, length_in).id}.") def rotate(self, ref=None, yaw=None, tilt=None, roll=None): self._do(lambda: f"Oriented {self.scene.orient(ref, yaw=yaw, tilt=tilt, roll=roll).id}.") - def rotate_90(self, ref=None): - part = self.scene.resolve(ref) if ref or self.scene.selection else None - if part: - self.rotate(part.id, yaw=(part.yaw_deg + 90) % 360) - - def set_length(self, ref, length_in): self._do(lambda: f"Cut {self.scene.set_length(ref, length_in).id}.") - def move(self, ref=None, dx=0.0, dy=0.0, dz=0.0): - self._do(lambda: f"Moved {self.scene.move(ref, dx, dy, dz).id}.") - # ----- project / export -------------------------------------------- def open_project(self, name): self._do(lambda: cli.cmd_open(self.scene, SimpleNamespace(name=name))) def save_project(self, name): @@ -163,7 +220,9 @@ class Controller(QObject): """Interpret a spoken/typed command and apply it. Returns a spoken summary. (Slow — call from a worker thread.)""" self.save() # ensure disk reflects current state - scene_text = cli.cmd_status(self.scene, None) + sel = ", ".join(self.selected) if self.selected else "none" + scene_text = (cli.cmd_status(self.scene, None) + + f"\nCurrently selected ('these' / 'them' / 'the selected'): {sel}") calls = driver.interpret(text, self.schemas(), scene_text=scene_text) messages = driver.dispatch(calls, verbose=False, executor=self.execute_call) self._commit() diff --git a/src/woodshop/gui/main_window.py b/src/woodshop/gui/main_window.py index 51c49cc..9b54043 100644 --- a/src/woodshop/gui/main_window.py +++ b/src/woodshop/gui/main_window.py @@ -5,12 +5,13 @@ from __future__ import annotations from PySide6.QtCore import Qt, QThreadPool from PySide6.QtGui import QAction, QKeySequence from PySide6.QtWidgets import (QFileDialog, QInputDialog, QMainWindow, QMessageBox, - QSplitter) + QSplitter, QVBoxLayout, QWidget) from ..cutlist import board_feet from ..scene import list_projects from .command_bar import CommandBar from .controller import Controller +from .numpad import NumpadPanel from .panels import PartsPanel from .viewport import Viewport @@ -25,11 +26,18 @@ class MainWindow(QMainWindow): self.controller = Controller(scene_path) self.viewport = Viewport() self.parts = PartsPanel(self.controller) + self.numpad = NumpadPanel(self.controller, self.viewport) self.command = CommandBar(self.controller, self.pool) + right = QWidget() + rlayout = QVBoxLayout(right) + rlayout.setContentsMargins(0, 0, 0, 0) + rlayout.addWidget(self.parts, 1) + rlayout.addWidget(self.numpad) + top = QSplitter(Qt.Horizontal) top.addWidget(self.viewport) - top.addWidget(self.parts) + top.addWidget(right) top.setStretchFactor(0, 3) top.setStretchFactor(1, 1) @@ -40,11 +48,23 @@ class MainWindow(QMainWindow): split.setStretchFactor(1, 1) self.setCentralWidget(split) - self.viewport.picked.connect(self.controller.select) + self.viewport.picked.connect(self._on_pick) self.controller.changed.connect(self._on_changed) self._build_menus() self._on_changed() # initial render + status + def _on_pick(self, pid: str, additive: bool): + self.controller.toggle(pid) if additive else self.controller.select(pid) + + def keyPressEvent(self, event): + # Physical numpad drives the move/rotate panel — but only when the user + # isn't typing in the command box. + if (event.modifiers() & Qt.KeypadModifier) and not self.command.input.hasFocus(): + if self.numpad.trigger(event.key()): + event.accept() + return + super().keyPressEvent(event) + # ----- menus ------------------------------------------------------- def _act(self, menu, text, slot, shortcut=None): a = QAction(text, self) @@ -95,12 +115,14 @@ class MainWindow(QMainWindow): # ----- slots ------------------------------------------------------- def _on_changed(self): - self.viewport.render_scene(self.controller.scene) scene = self.controller.scene + self.viewport.render_scene(scene, self.controller.selected) bf = sum(board_feet(p.stock, p.length_in) for p in scene.parts) + sel = self.controller.selected + sel_txt = (f"{len(sel)} selected" if len(sel) > 1 + else (sel[0] if sel else "none")) self.statusBar().showMessage( - f"{len(scene.parts)} part(s) · {bf:.1f} board-feet · " - f"selection: {scene.selection or 'none'}") + f"{len(scene.parts)} part(s) · {bf:.1f} board-feet · selection: {sel_txt}") def _camera(self, fn): fn() diff --git a/src/woodshop/gui/numpad.py b/src/woodshop/gui/numpad.py new file mode 100644 index 0000000..dcee32c --- /dev/null +++ b/src/woodshop/gui/numpad.py @@ -0,0 +1,83 @@ +"""Numberpad control panel: move/rotate the selection without talking, by +clicking buttons laid out like a numpad — or by pressing the physical numpad +keys (MainWindow forwards them via `action_for`). + +Layout (mirrors a keyboard numpad): + 7 ⟲yaw 8 +Y 9 ⟳yaw + 4 −X 5 Fit 6 +X + 1 ⤓tilt 2 −Y 3 ⤒tilt + 0 Front . Iso +Z↑ / −Z↓ +""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import (QDoubleSpinBox, QGridLayout, QGroupBox, QHBoxLayout, + QLabel, QPushButton, QVBoxLayout) + +from .controller import Controller + + +class NumpadPanel(QGroupBox): + def __init__(self, controller: Controller, viewport, parent=None): + super().__init__("Move / Rotate", parent) + self.c = controller + self.vp = viewport + + root = QVBoxLayout(self) + + steps = QHBoxLayout() + self.step = QDoubleSpinBox(); self.step.setRange(0.05, 48); self.step.setValue(1.0) + self.step.setSuffix(" in"); self.step.setSingleStep(0.5) + self.angle = QDoubleSpinBox(); self.angle.setRange(1, 180); self.angle.setValue(15) + self.angle.setSuffix(" °") + steps.addWidget(QLabel("Step")); steps.addWidget(self.step) + steps.addWidget(QLabel("Angle")); steps.addWidget(self.angle) + root.addLayout(steps) + + # key -> (button label, callable). Buttons and physical keys share this. + self._actions = { + Qt.Key_7: ("7\n⟲ yaw", lambda: self.c.rotate_selected(dyaw=-self._a())), + Qt.Key_8: ("8\n+Y ↑", lambda: self.c.move_selected(dy=self._s())), + Qt.Key_9: ("9\n⟳ yaw", lambda: self.c.rotate_selected(dyaw=self._a())), + Qt.Key_4: ("4\n−X ←", lambda: self.c.move_selected(dx=-self._s())), + Qt.Key_5: ("5\nFit", self.vp.fit), + Qt.Key_6: ("6\n+X →", lambda: self.c.move_selected(dx=self._s())), + Qt.Key_1: ("1\n⤓ tilt", lambda: self.c.rotate_selected(dtilt=-self._a())), + Qt.Key_2: ("2\n−Y ↓", lambda: self.c.move_selected(dy=-self._s())), + Qt.Key_3: ("3\n⤒ tilt", lambda: self.c.rotate_selected(dtilt=self._a())), + Qt.Key_0: ("0\nFront", self.vp.set_front), + Qt.Key_Period: (".\nIso", self.vp.set_iso), + Qt.Key_Plus: ("+\nZ ↑", lambda: self.c.move_selected(dz=self._s())), + Qt.Key_Minus: ("−\nZ ↓", lambda: self.c.move_selected(dz=-self._s())), + } + + grid = QGridLayout() + positions = { + Qt.Key_7: (0, 0), Qt.Key_8: (0, 1), Qt.Key_9: (0, 2), + Qt.Key_4: (1, 0), Qt.Key_5: (1, 1), Qt.Key_6: (1, 2), + Qt.Key_1: (2, 0), Qt.Key_2: (2, 1), Qt.Key_3: (2, 2), + Qt.Key_0: (3, 0), Qt.Key_Period: (3, 1), + Qt.Key_Plus: (4, 0), Qt.Key_Minus: (4, 1), + } + for key, (label, _) in self._actions.items(): + b = QPushButton(label) + b.setMinimumHeight(36) + b.clicked.connect(lambda _=False, k=key: self.trigger(k)) + r, col = positions[key] + grid.addWidget(b, r, col) + root.addLayout(grid) + root.addWidget(QLabel("Tip: use the keyboard numpad too.")) + + def _s(self) -> float: + return self.step.value() + + def _a(self) -> float: + return self.angle.value() + + def trigger(self, key) -> bool: + """Run the action bound to a numpad key. Returns True if handled.""" + entry = self._actions.get(key) + if entry: + entry[1]() + return True + return False diff --git a/src/woodshop/gui/panels.py b/src/woodshop/gui/panels.py index 42f6216..a17149d 100644 --- a/src/woodshop/gui/panels.py +++ b/src/woodshop/gui/panels.py @@ -4,9 +4,10 @@ that solves "delete that" ambiguity).""" from __future__ import annotations from PySide6.QtCore import Qt -from PySide6.QtWidgets import (QDoubleSpinBox, QFormLayout, QGridLayout, QGroupBox, - QHBoxLayout, QInputDialog, QLabel, QListWidget, - QListWidgetItem, QPushButton, QVBoxLayout, QWidget) +from PySide6.QtWidgets import (QAbstractItemView, QDoubleSpinBox, QFormLayout, + QGridLayout, QGroupBox, QInputDialog, QLabel, + QListWidget, QListWidgetItem, QPushButton, + QVBoxLayout, QWidget) from .controller import Controller @@ -21,6 +22,7 @@ class PartsPanel(QWidget): root.addWidget(QLabel("Parts")) self.list = QListWidget() + self.list.setSelectionMode(QAbstractItemView.ExtendedSelection) # Ctrl/Shift multi-select self.list.itemSelectionChanged.connect(self._on_row_selected) root.addWidget(self.list, 1) @@ -65,16 +67,14 @@ class PartsPanel(QWidget): def refresh(self) -> None: self._loading = True self.list.clear() - sel_row = -1 - for row, p in enumerate(self.c.scene.parts): + selected = set(self.c.selected) + for p in self.c.scene.parts: name = f" · {p.name}" if p.name else "" item = QListWidgetItem(f"{p.id} {p.stock} {p.length_in:g}\"{name}") item.setData(Qt.UserRole, p.id) self.list.addItem(item) - if p.id == self.c.selected_id: - sel_row = row - if sel_row >= 0: - self.list.setCurrentRow(sel_row) + if p.id in selected: + item.setSelected(True) part = self._selected_part() if part: @@ -99,9 +99,8 @@ class PartsPanel(QWidget): def _on_row_selected(self) -> None: if self._loading: return - items = self.list.selectedItems() - if items: - self.c.select(items[0].data(Qt.UserRole)) + ids = [it.data(Qt.UserRole) for it in self.list.selectedItems()] + self.c.set_selected(ids) def _rename(self) -> None: part = self._selected_part() diff --git a/src/woodshop/gui/viewport.py b/src/woodshop/gui/viewport.py index 3aeb828..bc4d553 100644 --- a/src/woodshop/gui/viewport.py +++ b/src/woodshop/gui/viewport.py @@ -2,15 +2,16 @@ selection, and emits a signal when a board is clicked.""" from __future__ import annotations -from PySide6.QtCore import Signal -from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import (QApplication, QHBoxLayout, QPushButton, + QVBoxLayout, QWidget) from ..scene import Scene from ..viewer import _PALETTE, _part_mesh, _quiet_vtk class Viewport(QWidget): - picked = Signal(str) # part id clicked in the 3D view + picked = Signal(str, bool) # (part id, additive?) — additive when Ctrl held def __init__(self, parent=None): super().__init__(parent) @@ -51,16 +52,18 @@ class Viewport(QWidget): def _on_pick(self, actor) -> None: pid = self._actor_to_pid.get(actor) if pid: - self.picked.emit(pid) + additive = bool(QApplication.keyboardModifiers() & Qt.ControlModifier) + self.picked.emit(pid, additive) - def render_scene(self, scene: Scene) -> None: + def render_scene(self, scene: Scene, selected_ids=None) -> None: + selected_ids = set(selected_ids or ([scene.selection] if scene.selection else [])) cam = None if self._first else self.plotter.camera_position self.plotter.clear() self._actor_to_pid.clear() labels, pts = [], [] for i, part in enumerate(scene.parts): - selected = part.id == scene.selection + selected = part.id in selected_ids actor = self.plotter.add_mesh( _part_mesh(part), color="#f5d76e" if selected else _PALETTE[i % len(_PALETTE)], @@ -88,6 +91,10 @@ class Viewport(QWidget): self.plotter.camera_position = cam # keep the user's viewpoint self.plotter.render() + def set_front(self): self.plotter.view_xz(); self.plotter.render() + def set_iso(self): self.plotter.view_isometric(); self.plotter.render() + def fit(self): self.plotter.reset_camera(); self.plotter.render() + def close_viewport(self) -> None: try: self.plotter.close() diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index 67d99ba..57ef3a6 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -19,6 +19,7 @@ import copy import json import math import os +from contextlib import contextmanager from dataclasses import dataclass, field, fields, asdict from pathlib import Path @@ -172,10 +173,23 @@ class Scene: # ----- undo / redo -------------------------------------------------- def _checkpoint(self) -> None: + if getattr(self, "_suppress", False): # inside a batch — one undo covers it + return self._undo.append(json.dumps(self._raw(), sort_keys=True)) del self._undo[:-50] # keep the last 50 steps self._redo.clear() # a new action invalidates the redo history + @contextmanager + def batch(self): + """Group several operations into a single undo step (e.g. moving a + multi-selection).""" + self._checkpoint() + self._suppress = True + try: + yield + finally: + self._suppress = False + def _restore(self, snapshot: dict) -> None: restored = Scene.from_dict(snapshot) restored._undo = self._undo diff --git a/tests/test_gui_controller.py b/tests/test_gui_controller.py index 0e9fd29..1229ecc 100644 --- a/tests/test_gui_controller.py +++ b/tests/test_gui_controller.py @@ -55,6 +55,28 @@ def test_select_and_undo_redo(tmp_path): assert len(c.scene.parts) == 2 +def test_toggle_multiselect(tmp_path): + c = _controller(tmp_path) + c.place("2x4", 24) + c.place("2x4", 24) + c.select("p1") + c.toggle("p2") + assert set(c.selected) == {"p1", "p2"} + c.toggle("p1") # ctrl-click again removes it + assert c.selected == ["p2"] + + +def test_group_move_is_single_undo(tmp_path): + c = _controller(tmp_path) + for _ in range(3): + c.place("2x4", 24) + c.set_selected(["p1", "p2", "p3"]) + c.move_selected(dy=4) # "move these 4 inches in +y" + assert all(p.position_in[1] == 4 for p in c.scene.parts) + c.undo() # one undo reverts the whole group + assert all(p.position_in[1] == 0 for p in c.scene.parts) + + def test_unknown_tool_is_safe(tmp_path): c = _controller(tmp_path) assert "unknown" in c.execute_call("wood-bogus", {}).lower() diff --git a/tests/test_scene.py b/tests/test_scene.py index e4ef22a..cb0dba0 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -179,6 +179,19 @@ def test_new_action_clears_redo(): s.redo() +def test_batch_is_one_undo(): + s = Scene() + s.place("2x4", 24) + s.place("2x4", 24) + with s.batch(): + s.move("p1", dx=5) + s.move("p2", dx=5) + assert s.get_part("p1").position_in[0] == 5 + s.undo() # single undo reverts both moves + assert s.get_part("p1").position_in[0] == 0 + assert s.get_part("p2").position_in[0] == 0 + + def test_clear(): s = Scene() s.place("2x4", 24)