Companion plugin — complete reference

This notebook mirrors ``plugin_action_complete.ipynb`` using AbstractCompanion.

All five event hooks are demonstrated:

Hook

Companion method signature

Event

rdown

_xxx(self, viewer, ctx: MouseContext)

Right mouse-button press

motion

_xxx(self, viewer, ctx: MouseContext)

Mouse motion

ldown

_xxx(self, viewer, ctx: MouseContext)

Left mouse-button press

key

_xxx(self, viewer, kb: KeyboardSnapshot) -> bool

Key press

paint

_xxx(self, viewer)

OpenGL paint hook

The companion built here lets the user:

  • Right-click → add a marker at the snapped cursor

  • Left-click → select / highlight the nearest marker

  • Mouse motion → show live coordinates in the status bar

  • Ctrl+Z → undo last marker

  • Ctrl+C → clear all markers

  • Paint hook → draw crosses on every marker in OpenGL

What the companion adds vs the raw API

  • All state is encapsulated — self.markers, self._selected

  • Menu is declared once in menu_build()no ``import wx`` needed

  • self._set_status(msg) replaces viewer.set_statusbar_text(msg)

  • self._force_redraw() replaces viewer.Paint()

  • self.destroy() unregisters every action automatically

1 — wx startup

[ ]:
import sys
%gui wx

2 — Create the MapViewer

[ ]:
from wolfhece.PyDraw import WolfMapViewer

viewer = WolfMapViewer(None, title="Companion — complete demo", w=1200, h=800)
viewer.Show()

3 — The MarkerCompanion

All state, menu entries, and event handlers live in a single class. Notice that there is no ``import wx`` anywhere — the companion helpers abstract it away.

[ ]:
import numpy as np

from wolfhece._menu_companion_abc import (
    AbstractCompanion,
    ActionItem,
    SEPARATOR,
)
from wolfhece._viewer_plugin_handlers import MouseContext, KeyboardSnapshot


class MarkerCompanion(AbstractCompanion):
    """Places and manages coloured markers on the map."""

    # ---------------------------------------------------------------
    # State
    # ---------------------------------------------------------------
    def __init__(self, viewer):
        super().__init__(viewer, namespace='markers')
        #: List of (x, y) world-coordinate pairs — read from the notebook.
        self.markers: list[tuple[float, float]] = []
        #: Index of the currently selected marker (-1 = none).
        self._selected: int = -1

    @property
    def selected(self) -> int:
        """Index of the currently selected marker (-1 = none)."""
        return self._selected

    # ---------------------------------------------------------------
    # Menu
    # ---------------------------------------------------------------
    def menu_build(self) -> None:
        self._build_menu('Markers', [
            ActionItem('Start', self._on_start,
                       'Activate: right-click = add, left-click = select'),
            ActionItem('Stop',  self._on_stop,
                       'Deactivate the marker action'),
            SEPARATOR,
            ActionItem('Show markers',  self._on_show,  'Display all markers'),
            ActionItem('Clear markers', self._on_clear, 'Delete all markers'),
        ])
        # One register_action call covers all five hooks.
        self._register_action(
            self._action_id('place'),
            rdown  = self._rdown_add,
            ldown  = self._ldown_select,
            motion = self._motion_coords,
            key    = self._key_handler,
            paint  = self._paint_markers,   # ← wires up the OpenGL overlay
        )

    # ---------------------------------------------------------------
    # Public API — callable directly from the notebook.
    # ---------------------------------------------------------------
    def start(self) -> None:
        """Activate: right-click = add, left-click = select."""
        self._start_action(
            self._action_id('place'),
            'Right-click: add  |  Left-click: select  |  Ctrl+Z: undo  |  Ctrl+C: clear',
        )

    def stop(self) -> None:
        """Deactivate the marker action."""
        self._end_action()

    # ---------------------------------------------------------------
    # Menu-item callbacks
    # ---------------------------------------------------------------
    def _on_start(self, _event) -> None:
        self.start()

    def _on_stop(self, _event) -> None:
        self.stop()

    def _on_show(self, _event) -> None:
        if not self.markers:
            self._show_info("No markers yet.", title="Markers")
            return
        lines = "\n".join(
            f"  {i+1:3d}.  X={x:.3f}   Y={y:.3f}"
            for i, (x, y) in enumerate(self.markers)
        )
        self._show_info(f"{len(self.markers)} marker(s):\n{lines}", title="Markers")

    def _on_clear(self, _event) -> None:
        if not self.markers:
            self._show_info("No markers to clear.", title="Markers")
            return
        if self._confirm(f"Delete all {len(self.markers)} marker(s)?", default='no'):
            self.markers.clear()
            self._selected = -1
            self._force_redraw()
            print("All markers cleared.")

    # ---------------------------------------------------------------
    # rdown handler — right-click adds a marker
    # ---------------------------------------------------------------
    def _rdown_add(self, viewer, ctx: MouseContext) -> None:
        self.markers.append((ctx.x_snap, ctx.y_snap))
        n = len(self.markers)
        print(f"[Marker #{n}]  X={ctx.x_snap:.3f}  Y={ctx.y_snap:.3f}")
        self._force_redraw()

    # ---------------------------------------------------------------
    # ldown handler — left-click selects the nearest marker
    # ---------------------------------------------------------------
    def _ldown_select(self, viewer, ctx: MouseContext) -> None:
        if not self.markers:
            return
        pts = np.array(self.markers)
        dists = np.hypot(pts[:, 0] - ctx.x, pts[:, 1] - ctx.y)
        self._selected = int(np.argmin(dists))
        x, y = self.markers[self._selected]
        print(f"Selected marker #{self._selected + 1}  X={x:.3f}  Y={y:.3f}  "
              f"(dist={dists[self._selected]:.1f})")
        self._force_redraw()

    # ---------------------------------------------------------------
    # motion handler — live coordinate display in the status bar
    # ---------------------------------------------------------------
    def _motion_coords(self, viewer, ctx: MouseContext) -> None:
        self._set_status(
            f"X={ctx.x:.2f}  Y={ctx.y:.2f}  |  {len(self.markers)} marker(s)"
        )

    # ---------------------------------------------------------------
    # key handler — Ctrl+Z undo, Ctrl+C clear
    # Return True to consume the event (prevents default viewer behaviour).
    # ---------------------------------------------------------------
    def _key_handler(self, viewer, kb: KeyboardSnapshot) -> bool:
        if not kb.ctrl:
            return False

        if kb.key_code == ord('Z'):
            if self.markers:
                removed = self.markers.pop()
                if self._selected >= len(self.markers):
                    self._selected = len(self.markers) - 1
                print(f"Undo — removed X={removed[0]:.3f}  Y={removed[1]:.3f}")
                self._force_redraw()
            return True

        if kb.key_code == ord('C'):
            self.markers.clear()
            self._selected = -1
            print("All markers cleared.")
            self._force_redraw()
            return True

        return False

    # ---------------------------------------------------------------
    # paint hook — draw crosses via the ABC helper (no raw GL needed)
    # Called after all data layers, before the viewer's UI overlays.
    # ---------------------------------------------------------------
    def _paint_markers(self, viewer) -> None:
        half = self._viewport_fraction(0.008)   # scales with zoom
        self._draw_crosses(
            self.markers,
            half,
            selected_idx=self._selected,
        )

4 — Attach the menu and activate

[ ]:
mc = MarkerCompanion(viewer)
mc.menu_build()

# Start the action directly from the notebook.
mc.start()

print(mc)

5 — Inspect the markers

[ ]:
print(f"{len(mc.markers)} marker(s)  |  selected index: {mc.selected}")
for i, (x, y) in enumerate(mc.markers):
    flag = " ← selected" if i == mc.selected else ""
    print(f"  {i+1:3d}.  X={x:.3f}   Y={y:.3f}{flag}")

6 — Export markers as a numpy array

[ ]:
if mc.markers:
    pts = np.array(mc.markers, dtype=float)
    print(f"Shape: {pts.shape}")
    print(pts)
else:
    print("No markers yet.")

7 — Cleanup

[ ]:
mc.stop()
mc.destroy()   # unregisters 'markers.place' automatically

print("Companion destroyed.")
print(mc)

Appendix — helper reference

Public API (call from notebook cells)

Method

Description

companion.start()

Activate the action

companion.stop()

Deactivate the action

companion.destroy()

Unregister all actions and remove the menu

Protected helpers (use inside the companion class only)

Companion method

Equivalent raw call

When to use

self._set_status(msg)

viewer.set_statusbar_text(msg)

motion handler, live feedback

self._force_redraw()

viewer.Paint()

after changing drawn data

self._start_action(id, hint)

viewer.start_action(id, hint)

inside start()

self._end_action()

viewer.end_action()

inside stop() — resets action, active_vertex, fires sculpt/assets callbacks

OpenGL paint helpers (no import OpenGL required)

Wire the paint hook with paint=self._paint_xxx in _register_action, then call these helpers from inside the method body.

Method

Description

self._viewport_fraction(f=0.008)

Returns f × viewport_width in world units — use as cross/dot size that scales with zoom

self._draw_crosses(points, half_size, color, *, selected_idx, selected_color, line_width)

Cross markers at each (x, y) point; optional single selection highlight

self._draw_polyline(points, color, *, closed, line_width)

Connected line string; closed=True adds a closing segment

self._draw_segments(segments, color, *, line_width)

Individual segments as (x1, y1, x2, y2) tuples

self._draw_points(points, color, *, point_size)

Filled GL dots at each point

Minimal paint handler example:

def _paint_markers(self, viewer) -> None:
    half = self._viewport_fraction(0.008)
    self._draw_crosses(self.points, half, selected_idx=self._selected)

Dialog helpers (no import wx required)

Method

Description

self._show_info(msg, title='')

Information dialog

self._show_error(msg, title='')

Error dialog

self._confirm(msg, default='yes')

Yes/No confirmation — returns bool

self._ask_text(msg, default='')

Free-text prompt — returns str \| None

self._ask_float(msg, default=0)

Float prompt — returns float \| None

self._ask_integer(msg, min_value=, max_value=)

Integer spin dialog

self._ask_single_choice(msg, title, choices)

List picker — returns str \| None

self._ask_file_open(msg, wildcard=)

File-open dialog

self._ask_file_save(msg, wildcard=)

File-save dialog

self._ask_directory(msg)

Directory chooser

Event handler signatures

def _rdown_xxx(self, viewer, ctx: MouseContext) -> None: ...
def _ldown_xxx(self, viewer, ctx: MouseContext) -> None: ...
def _motion_xxx(self, viewer, ctx: MouseContext) -> None: ...
def _key_xxx(self, viewer, kb: KeyboardSnapshot) -> bool: ...
def _paint_xxx(self, viewer) -> None: ...          # wire with paint=self._paint_xxx

MouseContext quick reference

Attribute

Description

ctx.x, ctx.y

Raw world coordinates

ctx.x_snap, ctx.y_snap

Grid-snapped world coordinates

ctx.x_pixel, ctx.y_pixel

Screen pixel coordinates

ctx.shift, ctx.ctrl, ctx.alt

Keyboard modifiers

KeyboardSnapshot quick reference

Attribute

Description

kb.key_code

ord('A'), wx.WXK_F1, etc.

kb.ctrl, kb.shift, kb.alt

Modifiers

kb.is_down

True = key-down event