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 |
|---|---|
|
|
|
Per-action handlers + dispatch tables |
|
|
|
|
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…')
Applies
.lower()to the supplied value.Attempts to coerce to
ActionKind; falls back to the raw string (backward compatibility).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 |
|---|---|---|
|
|
Right mouse button pressed |
|
|
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 |
|---|---|
|
Non-exclusive |
|
Shift-projection logic tightly coupled to local context
( |
|
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).
_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 |
|---|---|
|
Node selection by vector contour (inside / outside / along / tmp variants) |
|
Selection via the active vector (inside / all, zone 1 or 2) |
|
Node-by-node selection (standard + results) |
|
Landmap texture loading (full / low) |
|
Actions that accumulate polygon vertices (motion visual feedback) |
|
Actions stressing OpenGL rendering (tooltip refresh suppressed) |
|
Upstream watershed computation (global / limited to sub-basin) |
|
Upstream watershed selection |
|
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
WolfMapViewerat module level (circular import). UseTYPE_CHECKINGfor type annotations only.Call
v.end_action(…)when the action is complete.Use
ctx.x_snap/ctx.y_snapto honour grid snapping (active whenctx.altisTrue).For dynamically assigned viewer attributes (e.g.
active_fig_options), usegetattr(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 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