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.

start_action(ActionKind.X)  →  [mouse interactions]  →  end_action()

Files involved

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).

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='')

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='')

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:

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:

def _rdown_xxx(viewer: WolfMapViewer, ctx: MouseContext) -> None: ...

MouseContext

@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

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

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:

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:

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.

from wolfhece._action_kind import SELECT_BY_VECTOR_ACTIONS, HEAVY_GL_ACTIONS, ...

if self.action in SELECT_BY_VECTOR_ACTIONS:
    ...

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:

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

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:

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):

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):

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:

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:

ActionKind.MY_VARIANT_A: _rdown_my_new_action,
ActionKind.MY_VARIANT_B: _rdown_my_new_action,

Step 4 — Trigger from a menu or toolbar

# 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.

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:

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:

python -m pytest tests/viewer/test_viewer_plugin_handlers.py -q

Checklist

☐ 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