Companion plugin — complete reference

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

All five event hooks are demonstrated:

Hook

Companion method signature

Event

rdown

_xxx(self, ctx: MouseContext)

Right mouse-button press

motion

_xxx(self, ctx: MouseContext)

Mouse motion

ldown

_xxx(self, ctx: MouseContext)

Left mouse-button press

key

_xxx(self, kb: KeyboardSnapshot) -> bool

Key press

paint

_xxx(self)

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_spec()no ``import wx`` needed

  • self.proxy.set_status(msg) replaces viewer.set_statusbar_text(msg)

  • self.proxy._viewer.Refresh() replaces viewer.Paint()

  • self.destroy() unregisters every action automatically

1 — wx startup

[1]:
import sys
%gui wx

2 — Create the MapViewer

[2]:
from wolfhece.PyDraw import WolfMapViewer

viewer = WolfMapViewer(None, title="Companion — complete demo", w=1200, h=800)
viewer.Show()
INFO:root:Importing wolfhece modules
INFO:root:wolfhece modules imported
INFO:root:Plugin manager init: skipping auto-discovery because no global configuration is available yet.
[2]:
False

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.

[3]:
from wolfhece.plugins.abc import (
    AbstractUICompanion,
    ActionSpec,
    MenuItem,
    SEPARATOR,
)
from wolfhece._viewer_plugin_handlers import MouseContext, KeyboardSnapshot
from OpenGL.GL import glBegin, glEnd, glVertex2f, glColor4f, glLineWidth, GL_LINES


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

    def get_namespace(self) -> str:
        return 'markers'

    def __init__(self):
        super().__init__()
        self.markers: list[tuple[float, float]] = []
        self._selected: int = -1

    @property
    def selected(self) -> int:
        return self._selected

    def menu_spec(self):
        return ('Markers', [
            MenuItem('Start', self.on_start, 'Right-click=add  left-click=select'),
            MenuItem('Stop', self.on_stop, 'Deactivate'),
            SEPARATOR,
            MenuItem('Show markers', self.on_show),
            MenuItem('Clear markers', self.on_clear),
        ])

    def actions_spec(self):
        return [
            ActionSpec(
                'place',
                rdown=self.rdown_add,
                ldown=self.ldown_select,
                motion=self.motion_coords,
                key=self.key_handler,
                paint=self.paint_markers,
            ),
        ]

    def start(self) -> None:
        self.proxy.start_action(
            'place',
            'Right-click: add  |  Left-click: select  |  Ctrl+Z: undo  |  Ctrl+C: clear',
        )

    def stop(self) -> None:
        self.proxy.end_action()

    def on_start(self, _ctx):
        self.start()

    def on_stop(self, _ctx):
        self.stop()

    def on_show(self, _ctx):
        if not self.markers:
            self.proxy.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.proxy.show_info(f'{len(self.markers)} marker(s):\n{lines}', title='Markers')

    def on_clear(self, _ctx):
        if not self.markers:
            self.proxy.show_info('No markers.', title='Markers')
            return
        if self.proxy.confirm(f'Delete all {len(self.markers)} marker(s)?', default='no'):
            self.markers.clear()
            self._selected = -1
            self.proxy._viewer.Refresh()
            print('All markers cleared.')

    def rdown_add(self, ctx: MouseContext) -> None:
        self.markers.append((ctx.x_snap, ctx.y_snap))
        print(f'[Marker #{len(self.markers)}]  X={ctx.x_snap:.3f}  Y={ctx.y_snap:.3f}')
        self.proxy._viewer.Refresh()

    def ldown_select(self, ctx: MouseContext) -> None:
        if not self.markers:
            return
        d2 = [(x - ctx.x) ** 2 + (y - ctx.y) ** 2 for (x, y) in self.markers]
        self._selected = int(min(range(len(d2)), key=d2.__getitem__))
        x, y = self.markers[self._selected]
        self.proxy.set_status(f'Selected marker #{self._selected + 1} at ({x:.3f}, {y:.3f})')
        self.proxy._viewer.Refresh()

    def motion_coords(self, ctx: MouseContext) -> None:
        self.proxy.set_status(f'X={ctx.x:.2f}  Y={ctx.y:.2f}')

    def key_handler(self, kb: KeyboardSnapshot) -> bool:
        if kb.key_code == 27:
            self.stop()
            return True
        if kb.ctrl and kb.key_code in (ord('Z'), ord('z')):
            if self.markers:
                removed = self.markers.pop()
                self._selected = min(self._selected, len(self.markers) - 1)
                self.proxy._viewer.Refresh()
                print(f'Removed marker at ({removed[0]:.3f}, {removed[1]:.3f})')
            return True
        if kb.ctrl and kb.key_code in (ord('C'), ord('c')):
            self.markers.clear()
            self._selected = -1
            self.proxy._viewer.Refresh()
            print('All markers cleared.')
            return True
        return False

    def paint_markers(self) -> None:
        if not self.markers:
            return

        dx = max(1e-9, float(self.proxy._viewer.xmax - self.proxy._viewer.xmin))
        h = 0.01 * dx

        for i, (x, y) in enumerate(self.markers):
            if i == self._selected:
                glColor4f(1.0, 0.9, 0.1, 1.0)
                lw = 3.0
            else:
                glColor4f(1.0, 0.2, 0.2, 1.0)
                lw = 2.0

            glLineWidth(lw)
            glBegin(GL_LINES)
            glVertex2f(x - h, y)
            glVertex2f(x + h, y)
            glVertex2f(x, y - h)
            glVertex2f(x, y + h)
            glEnd()

4 — Attach the menu and activate

[4]:
mc = MarkerCompanion()
viewer.attach_companion(mc)  # configures and attaches the companion to the viewer
mc.start()

print(mc)
INFO:root:ACTION : Right-click: add  |  Left-click: select  |  Ctrl+Z: undo  |  Ctrl+C: clear
<MarkerCompanion ns='markers' menu=attached actions=[markers.place]>

5 — Inspect the markers

[5]:
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}")
3 marker(s)  |  selected index: -1
    1.  X=6.312   Y=31.052
    2.  X=23.462   Y=28.229
    3.  X=11.958   Y=16.192

6 — Export markers as a numpy array

[7]:
import numpy as np
if mc.markers:
    pts = np.array(mc.markers, dtype=float)
    print(f"Shape: {pts.shape}")
    print(pts)
else:
    print("No markers yet.")
Shape: (3, 2)
[[ 6.31183086 31.05173189]
 [23.4619883  28.22887989]
 [11.95753486 16.19181287]]

7 — Cleanup

[8]:
mc.stop()
viewer.detach_companion(mc)  # destroys and detaches the companion from the viewer

print("Companion destroyed.")
print(mc)
INFO:root:ACTION : End of action
Companion destroyed.
<MarkerCompanion ns='markers' menu=not built actions=[—]>

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

Proxy helpers (inside companion methods)

Helper

Equivalent raw call

self.proxy.register_action(...)

viewer.register_action(...)

self.proxy.start_action(id, hint)

viewer.start_action(id, hint)

self.proxy.end_action()

viewer.end_action()

self.proxy.set_status(msg)

viewer.set_statusbar_text(msg)

self.proxy.show_info(...)

wx.MessageBox(...) info

self.proxy.confirm(...)

wx.MessageBox(...) yes/no

self.proxy._viewer.Refresh()

viewer.Paint()

OpenGL paint pattern

def paint_markers(self) -> None:
    half = (self.proxy._viewer.xmax - self.proxy._viewer.xmin) * 0.008
    # draw with raw OpenGL calls (glBegin/glVertex/glEnd)

Event handler signatures

def rdown_xxx(self, ctx: MouseContext) -> None: ...
def ldown_xxx(self, ctx: MouseContext) -> None: ...
def motion_xxx(self, ctx: MouseContext) -> None: ...
def key_xxx(self, kb: KeyboardSnapshot) -> bool: ...
def paint_xxx(self) -> None: ...