Mouse Action System =================== This document describes the complete architecture of the interactive action system in ``WolfMapViewer`` (``wolfhece/PyDraw.py``) and provides a step-by-step guide for adding a new action with mouse interaction. Overview -------- An **action** is a temporary mouse-interaction mode stored in ``self.action`` (a ``WolfMapViewer`` attribute). It drives the behaviour of all event handlers. .. code-block:: text start_action(ActionKind.X) → [mouse interactions] → end_action() Files involved ~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 45 55 * - File - Role * - ``wolfhece/_action_kind.py`` - ``ActionKind`` enumeration + group ``frozenset`` constants * - ``wolfhece/_viewer_plugin_handlers.py`` - Per-action handlers + dispatch tables * - ``wolfhece/PyDraw.py`` - ``WolfMapViewer``: ``start_action``, ``end_action``, ``_endactions``, mouse event handlers * - ``wolfhece/PyGuiHydrology.py`` - ``On_Mouse_Right_Down`` / ``On_Mouse_Motion`` overrides for hydrology actions The ``ActionKind`` enumeration ------------------------------- Every recognised action is declared in ``wolfhece/_action_kind.py`` as a member of ``ActionKind(str, Enum)``. .. code-block:: python from wolfhece._action_kind import ActionKind Inheriting from ``str`` guarantees backward compatibility: ``ActionKind.SCULPT == 'sculpt'`` is ``True``. All values are **lowercase** because ``start_action`` applies ``.lower()`` before storing in ``self.action``. Starting and ending an action ------------------------------ ``start_action(action, message='')`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python self.start_action(ActionKind.MOVE_VECTOR) self.start_action(ActionKind.MOVE_VECTOR, 'Select 2 points…') 1. Applies ``.lower()`` to the supplied value. 2. Attempts to coerce to ``ActionKind``; falls back to the raw string (backward compatibility). 3. Logs the entry and refreshes the status bar. .. note:: Always pass an ``ActionKind`` — never assign ``self.action`` directly. ``end_action(message='')`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python self.end_action(_('End move vector')) Resets ``self.action`` to ``None``, clears internal state (active vertex, cloud vertex id, sculpt, assets) and logs the exit. Mouse handler architecture: dispatch tables -------------------------------------------- Principle ~~~~~~~~~ ``On_Mouse_Right_Down`` and ``On_Mouse_Motion`` delegate per-action processing to two **dispatch tables** defined in ``wolfhece/_viewer_plugin_handlers.py``: .. list-table:: :header-rows: 1 :widths: 40 25 35 * - Table - Type - Triggered by * - ``ACTION_RDOWN_HANDLERS`` - ``dict[ActionKind, Handler]`` - Right mouse button pressed * - ``ACTION_MOTION_HANDLERS`` - ``dict[ActionKind, Handler]`` - Mouse motion while an action is active A *Handler* has the signature: .. code-block:: python def _rdown_xxx(viewer: WolfMapViewer, ctx: MouseContext) -> None: ... ``MouseContext`` ~~~~~~~~~~~~~~~~ .. code-block:: python @dataclass(slots=True) class MouseContext: x: float # raw world X coordinate y: float # raw world Y coordinate x_snap: float # grid-snapped X (== x when snapping is off) y_snap: float # grid-snapped Y (== y when snapping is off) alt: bool # Alt key held ctrl: bool # Ctrl key held shift: bool # Shift key held All coordinates are in **world space** (map units, not pixels). Grid snapping is active when ``alt=True``. Usage in ``On_Mouse_Right_Down`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python if self.action in ACTION_RDOWN_HANDLERS: ctx = MouseContext(x, y, x_snap, y_snap, alt=alt, ctrl=ctrl, shift=shiftdown) ACTION_RDOWN_HANDLERS[self.action](self, ctx) Usage in ``On_Mouse_Motion`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python elif self.action in ACTION_MOTION_HANDLERS: _ctx = MouseContext(x=x, y=y, x_snap=x_snap, y_snap=y_snap, alt=altdown, ctrl=False, shift=shiftdown) ACTION_MOTION_HANDLERS[self.action](self, _ctx) What remains inline in ``On_Mouse_Motion`` ------------------------------------------- Three blocks intentionally bypass the dispatch table: .. list-table:: :header-rows: 1 :widths: 40 60 * - Block - Reason * - ``POLYGON_VERTEX_ACTIONS`` - Non-exclusive ``if`` that runs *before* the others; 3 lines * - ``MODIFY_VERTICES`` / ``INSERT_VERTICES`` - Shift-projection logic tightly coupled to local context (``active_vertex``, ``active_vector._mylimits``, ``x_snap``) * - ``DISTANCE_ALONG_VECTOR`` - 4-line temporary vertex update ``POLYGON_VERTEX_ACTIONS`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~ For actions that build a polygon through successive clicks (``SELECT_BY_VECTOR_INSIDE``, ``CAPTURE_VERTICES``, ``DYNAMIC_PARALLEL``, ``LAZ_TMP_VECTOR``, ``CREATE_POLYGON_TILES``), ``On_Mouse_Motion`` automatically tracks the last vertex to provide live visual feedback: .. code-block:: python if self.action in POLYGON_VERTEX_ACTIONS: if self.active_vector is not None and self.active_vector.nbvertices > 0: self.active_vector.myvertices[-1].x = x self.active_vector.myvertices[-1].y = y self.active_vector.reset_linestring() if self.active_vector.parentzone is not None: self.active_vector.parentzone.reset_listogl() This block is independent: it runs even when the action is also registered in ``ACTION_MOTION_HANDLERS`` (e.g. ``DYNAMIC_PARALLEL``). What remains inline in ``On_Mouse_Button`` (wheel) ---------------------------------------------------- ``On_Mouse_Button`` is **not** migrated to a dispatch table because: * Only one action-dependent case exists (``DYNAMIC_PARALLEL`` + Shift); * Everything else (zoom, image scaling) is action-independent. ``_endactions`` ---------------- Called on right double-click or Return key. **Not** migrated to a dispatch table because every branch does something entirely different (wx dialogs, I/O decisions, per-action cleanup). No deduplication is possible — cost outweighs benefit. Action groups (``frozenset``) ------------------------------ Defined in ``wolfhece/_action_kind.py``. Replace fragile ``'substring' in self.action`` tests. .. code-block:: python from wolfhece._action_kind import SELECT_BY_VECTOR_ACTIONS, HEAVY_GL_ACTIONS, ... if self.action in SELECT_BY_VECTOR_ACTIONS: ... .. list-table:: :header-rows: 1 :widths: 45 55 * - Constant - Contents * - ``SELECT_BY_VECTOR_ACTIONS`` - Node selection by vector contour (inside / outside / along / tmp variants) * - ``SELECT_ACTIVE_VECTOR_ACTIONS`` - Selection via the active vector (inside / all, zone 1 or 2) * - ``SELECT_NODE_ACTIONS`` - Node-by-node selection (standard + results) * - ``PICK_LANDMAP_ACTIONS`` - Landmap texture loading (full / low) * - ``POLYGON_VERTEX_ACTIONS`` - Actions that accumulate polygon vertices (motion visual feedback) * - ``HEAVY_GL_ACTIONS`` - Actions stressing OpenGL rendering (tooltip refresh suppressed) * - ``FIND_UPSTREAM_WATERSHED_ACTIONS`` - Upstream watershed computation (global / limited to sub-basin) * - ``SELECT_UPSTREAM_WATERSHED_ACTIONS`` - Upstream watershed selection * - ``SELECT_UPSTREAM_RIVERS_ACTIONS`` - Upstream river selection To distinguish two variants within a group: .. code-block:: python elif self.action in FIND_UPSTREAM_WATERSHED_ACTIONS: limit = (self.action == ActionKind.FIND_UPSTREAM_WATERSHED_LIMIT) vect = watershed.get_vector_from_upstream_node(node, limit_to_sub=limit) Adding a new action: step-by-step guide ----------------------------------------- Step 1 — Declare in ``wolfhece/_action_kind.py`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python class ActionKind(str, Enum): ... # --- my new feature --- MY_NEW_ACTION = 'my new action' # always lowercase If the action fits an existing group, add the value to the corresponding ``frozenset``. If a new group is needed: .. code-block:: python MY_NEW_GROUP: frozenset[ActionKind] = frozenset({ ActionKind.MY_NEW_ACTION, ActionKind.MY_OTHER_ACTION, }) Step 2 — Write the handlers in ``wolfhece/_viewer_plugin_handlers.py`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Right-click handler (required if right-click does something): .. code-block:: python def _rdown_my_new_action(v: 'WolfMapViewer', ctx: MouseContext) -> None: """Short docstring describing what this handler does.""" if v.active_vector is None: logging.warning(_('No vector selected')) return # … business logic … v.end_action(_('End of my action')) **Rules**: * Never import ``WolfMapViewer`` at module level (circular import). Use ``TYPE_CHECKING`` for type annotations only. * Call ``v.end_action(…)`` when the action is complete. * Use ``ctx.x_snap`` / ``ctx.y_snap`` to honour grid snapping (active when ``ctx.alt`` is ``True``). * For dynamically assigned viewer attributes (e.g. ``active_fig_options``), use ``getattr(v, 'attr', None)`` rather than direct access. Motion handler (optional — live visual feedback): .. code-block:: python def _motion_my_new_action(v: 'WolfMapViewer', ctx: MouseContext) -> None: if v.active_vector is None: return # update the visual preview v.active_vector.myvertices[-1].x = ctx.x v.active_vector.myvertices[-1].y = ctx.y Step 3 — Register in the dispatch tables ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ At the bottom of ``wolfhece/_viewer_plugin_handlers.py``, add an entry: .. code-block:: python ACTION_RDOWN_HANDLERS: dict[ActionKind, _Handler] = { ... ActionKind.MY_NEW_ACTION: _rdown_my_new_action, } ACTION_MOTION_HANDLERS: dict[ActionKind, _Handler] = { ... ActionKind.MY_NEW_ACTION: _motion_my_new_action, # if needed } When several action variants share the **same** handler: .. code-block:: python ActionKind.MY_VARIANT_A: _rdown_my_new_action, ActionKind.MY_VARIANT_B: _rdown_my_new_action, Step 4 — Trigger from a menu or toolbar ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python # Inside OnPopupItemSelected, OnMenu, etc. elif itemlabel == _('My action'): self.start_action(ActionKind.MY_NEW_ACTION, _('Click to select a point…')) Step 5 — Handle completion in ``_endactions`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``_endactions`` is called on right double-click or Return key. The local variable ``locaction`` holds the action that was active at the time of the trigger. .. code-block:: python elif locaction == ActionKind.MY_NEW_ACTION: # completion logic (dialogs, validation, cleanup…) self.end_action(_('End of my action')) Step 6 — Optional groups ~~~~~~~~~~~~~~~~~~~~~~~~~ If the action should participate in polygon vertex tracking during ``On_Mouse_Motion`` (last vertex follows the cursor), add it to ``POLYGON_VERTEX_ACTIONS`` in ``_action_kind.py``. If the action stresses OpenGL rendering, add it to ``HEAVY_GL_ACTIONS`` (suppresses tooltip refresh). Step 7 — Unit tests ~~~~~~~~~~~~~~~~~~~~ ``tests/viewer/test_viewer_plugin_handlers.py`` contains 124 tests that require no wx dependency (all ``MagicMock``). Add one test class per handler: .. code-block:: python class TestRdownMyNewAction(unittest.TestCase): def _make_viewer(self): v = MagicMock() v.active_vector = MagicMock() return v def test_no_active_vector_warns(self): v = self._make_viewer() v.active_vector = None ctx = MouseContext(x=1., y=2., x_snap=1., y_snap=2., alt=False, ctrl=False, shift=False) _rdown_my_new_action(v, ctx) v.end_action.assert_not_called() def test_nominal(self): v = self._make_viewer() ctx = MouseContext(x=10., y=20., x_snap=10., y_snap=20., alt=False, ctrl=False, shift=False) _rdown_my_new_action(v, ctx) v.end_action.assert_called_once() Run with: .. code-block:: bash python -m pytest tests/viewer/test_viewer_plugin_handlers.py -q Checklist --------- .. code-block:: text ☐ ActionKind.MY_NEW_ACTION = 'my new action' in _action_kind.py ☐ Added to relevant frozensets (POLYGON_VERTEX_ACTIONS, HEAVY_GL_ACTIONS…) ☐ _rdown_my_new_action() in _viewer_plugin_handlers.py ☐ _motion_my_new_action() in _viewer_plugin_handlers.py (if visual feedback needed) ☐ Registered in ACTION_RDOWN_HANDLERS ☐ Registered in ACTION_MOTION_HANDLERS (if motion handler) ☐ start_action(ActionKind.MY_NEW_ACTION, …) from the menu/toolbar ☐ Branch in _endactions (if completion requires processing) ☐ Unit tests in test_viewer_plugin_handlers.py