Source code for wolfhece.plugins.abc

"""Abstract companion base class.

This module intentionally focuses on :class:`AbstractUICompanion`.

Related helpers now live in dedicated modules:

- :mod:`wolfhece.plugins.types` for declarative menu/action dataclasses,
- :mod:`wolfhece.plugins.state` for resettable state containers,
- :mod:`wolfhece.plugins.actions` for interactive action helpers.

For backward compatibility, their main symbols are re-exported here.
"""
from __future__ import annotations

import logging
from abc import ABC
from typing import Iterable

from .actions import MultiStepAction
from .state import CompanionState
from .types import (
    ActionSpec,
    Keys,
    MenuEntry,
    MenuItem,
    MultiStepSpec,
    SEPARATOR,
    Separator,
    StepTransition,
    StepSpec,
    SubMenuSpec,
)

__all__ = [
    'AbstractCompanionModel',
    'AbstractUICompanion',
    'ActionSpec',
    'Keys',
    'StepTransition',
    'StepSpec',
    'MultiStepSpec',
    'MenuItem',
    'ViewerProxy',
    'CompanionState',
    'MenuEntry',
    'MultiStepAction',
    'SEPARATOR',
    'Separator',
    'SubMenuSpec',
]


# Compatibility re-exports:
# Historically, CompanionState, MultiStepAction and declarative menu/action
# types lived in this module. They now live in dedicated modules but remain
# available here to preserve import stability.

from .viewer_proxy import ViewerProxy


# ---------------------------------------------------------------------------
# Optional domain-model contract
# ---------------------------------------------------------------------------

[docs] class AbstractCompanionModel(ABC): """Pure business model contract for companion plugins. Keep all business state and domain transitions in a model class that does not depend on wx/viewer/OpenGL APIs. The UI companion can then compose this model and stay focused on event wiring and rendering. This default contract is intentionally lightweight to support plugins that only need simple dataclasses. Override :meth:`reset` when your model must clear transient state. """
[docs] def reset(self) -> None: """Reset transient domain state (no-op by default)."""
# --------------------------------------------------------------------------- # AbstractUICompanion — the ABC # ---------------------------------------------------------------------------
[docs] class AbstractUICompanion(ABC): """Abstract base class for companion objects that integrate with WolfMapViewer. Viewer access policy -------------------- Companion code should use :attr:`proxy` as the primary entry point for viewer integration (actions, menus, dialogs, redraw, OpenGL helpers). During the migration period, direct viewer access is still possible via the private bridge attribute ``self.proxy._viewer`` for advanced cases where no dedicated helper exists yet. A *companion* is an object that: 1. Owns the transient state needed by its interactive actions. 2. Registers (and later cleans up) custom mouse/keyboard handlers in the viewer's action dispatch tables. 3. Optionally appends its own ``wx.Menu`` to the viewer's menu bar (declare it through :meth:`menu_spec`). Subclasses **must** implement :meth:`start`. All other methods are either concrete helpers or optional overrides. In particular, :meth:`menu_spec` has a no-op default — companions that interact purely via mouse/keyboard events do not need a menu. """
[docs] proxy: ViewerProxy
[docs] model : AbstractCompanionModel | None
# ------------------------------------------------------------------ # Construction # ------------------------------------------------------------------ def __init__(self) -> None: """Initialize the companion instance. The viewer reference is not available at this stage. Do not attempt to access the viewer or proxy here. Use :meth:`configure` instead. """ pass
[docs] def create_model(self) -> 'AbstractCompanionModel | None': """Create the companion's optional pure business model. Override this to keep domain logic independent from UI concerns while staying in the same plugin module (companion + model in one file). Default implementation returns ``None``. """ return None
[docs] def get_namespace(self) -> str: """Return the namespace string used to prefix action ids. The default is the companion's class name lowercased. Override in a subclass to choose a different prefix:: class MyCompanion(AbstractUICompanion): def get_namespace(self) -> str: return 'myfeature' """ return type(self).__name__.lower()
[docs] def configure(self): """Configure the companion's runtime behaviour. """ #: Runtime bridge — the sole owner of the viewer reference, dialog #: provider, namespace, menu state, and registered action ids. #: Attach the viewer after construction via ``self.proxy.attach(viewer)``. self.proxy: ViewerProxy = ViewerProxy(type(self).__name__, namespace=self.get_namespace()) #: Optional pure business model owned by the companion. #: Override :meth:`create_model` to provide one. self.model: AbstractCompanionModel | None = self.create_model()
# ------------------------------------------------------------------ # Override hooks — declare your menu and actions # ------------------------------------------------------------------
[docs] def menu_spec(self) -> 'tuple[str, list[MenuEntry]] | None': """Declare the companion's menu as a ``(title, items)`` pair. Override this to add a menu without writing wx code:: def menu_spec(self) -> tuple[str, list[MenuEntry]] | None: return (_('My Tool'), [ MenuItem(_('Run'), self._on_run, _('Execute the tool')), SEPARATOR, SubMenuSpec(_('Settings'), [ MenuItem(_('Configure\u2026'), self._on_configure), ]), ]) Return ``None`` (the default) when no top-level menu is needed. Called automatically by :meth:`build`. Do not call directly. """ return None
[docs] def menu_host(self) -> str: """Return where this companion menu should be hosted. Supported values are: - ``'companions_root'``: create a submenu under the shared viewer menu "Companions" (default). - ``'top_level'``: create a direct top-level menu entry in the viewer menubar. Override this in companions that must remain top-level. """ return 'companions_root'
[docs] def actions_spec(self) -> 'Iterable[ActionSpec | MultiStepSpec] | None': """Declare interactive handlers to register with the viewer. Override this method declaratively by returning :class:`ActionSpec` and/or :class:`MultiStepSpec` rows. The proxy performs registration and handler adaptation:: def actions_spec(self): return [ ActionSpec(self._pick.action_id, ldown=self._ldown, key=self._key), MultiStepSpec( 'wall', steps=[ StepSpec(hint='Pick first point', ldown=self._step1), StepSpec(hint='Pick second point', ldown=self._step2), ], ), ] For :class:`MultiStepSpec`, handlers can return :class:`StepTransition` values (or equivalent strings) to drive the runtime state machine. Return ``None`` (or an empty iterable) when there is no action to register. Called automatically by :meth:`build`. Do not call directly. """
# ------------------------------------------------------------------ # Override hooks — lifecycle callbacks # ------------------------------------------------------------------
[docs] def start(self) -> None: """Activate the companion's primary interactive action. Default behaviour is declarative and driven by :meth:`actions_spec`: - select the :class:`ActionSpec` marked ``primary=True``, - otherwise fall back to the first declared :class:`ActionSpec`, - call :meth:`ViewerProxy.start_action` with its ``action_id`` and optional ``start_message``. Override this method when startup needs additional business logic. :raises RuntimeError: If no actions are declared or more than one :class:`ActionSpec` is marked primary. """ primary = self._primary_action_spec() if primary is None: raise RuntimeError( f"{type(self).__name__}: no ActionSpec declared. " "Override start() or return at least one ActionSpec from actions_spec()." ) self.proxy.start_action(primary.action_id, primary.start_message)
[docs] def stop(self) -> None: """Deactivate the companion's primary interactive action. The default implementation calls :meth:`proxy.end_action`. Override to add post-processing (e.g. printing a summary):: def stop(self) -> None: self.proxy.end_action() print(f"{len(self.points)} item(s) recorded.") """ self.proxy.end_action()
# ------------------------------------------------------------------ # Framework-managed lifecycle (called by the viewer owner) # ------------------------------------------------------------------
[docs] def build(self) -> None: """Build and attach the companion's menu to the viewer menubar. The default implementation calls :meth:`menu_spec` to build any declared menu, then :meth:`actions_spec` to wire up interactive handlers. Override this method directly only for advanced cases that the two hooks cannot express. **Preferred approach** — override :meth:`menu_spec` and/or :meth:`actions_spec`:: def menu_spec(self) -> tuple[str, list[MenuEntry]] | None: return (_('My Feature'), [ MenuItem(_('Do something'), self._on_do), SEPARATOR, SubMenuSpec(_('Tools'), [ MenuItem(_('Export'), self._on_export), ]), ]) def actions_spec(self): return [ ActionSpec(self._pick.action_id, ldown=self._ldown), ] **Advanced — full wx control** (bypass both hooks):: def build(self) -> None: if self.proxy._menu is not None: return import wx self.proxy._menu = wx.Menu() ... self.proxy.register_action(...) Implementations **must** be idempotent. """ spec = self.menu_spec() if spec is not None: title, items = spec self.proxy.build_menu(title, items, host=self.menu_host()) action_specs = list(self.actions_spec() or []) if action_specs: self.proxy.register_actions(action_specs)
[docs] def destroy(self) -> None: """Unregister all custom actions, remove the companion's menu, and release viewer resources. Call this when the companion is no longer needed. The method is safe to call multiple times. Override this method to add companion-specific teardown:: def destroy(self) -> None: # custom pre-teardown ... super().destroy() :raises RuntimeError: Never — all wx errors are caught and logged. """ self.proxy.destroy()
# ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------
[docs] def _iter_action_specs(self) -> 'Iterable[ActionSpec]': """Yield action specs normalized to :class:`ActionSpec`. ``MultiStepSpec`` entries are normalized to a synthetic :class:`ActionSpec` for primary-action selection logic. """ for spec in self.actions_spec() or []: if isinstance(spec, ActionSpec): yield spec continue if isinstance(spec, MultiStepSpec): step = spec.first_step yield ActionSpec( action_id=spec.action_id, rdown=step.rdown, motion=step.motion, ldown=step.ldown, key=step.key, paint=step.paint, overload=spec.overload, primary=spec.primary, start_message=spec.effective_start_message, ) continue raise TypeError( f"{type(self).__name__}.actions_spec(): unsupported spec type {type(spec)!r}. " "Expected ActionSpec or MultiStepSpec." )
[docs] def _primary_action_spec(self) -> 'ActionSpec | None': """Return the action spec used by the default :meth:`start` method. Selection rules: 1. If one spec has ``primary=True``, use it. 2. If none are marked primary, use the first declared spec. 3. If multiple specs are marked primary, raise ``RuntimeError``. """ specs = list(self._iter_action_specs()) if not specs: return None primary_specs = [spec for spec in specs if spec.primary] if len(primary_specs) > 1: ids = ', '.join(str(spec.action_id) for spec in primary_specs) raise RuntimeError( f"{type(self).__name__}: multiple primary ActionSpec entries ({ids}). " "Mark at most one ActionSpec with primary=True." ) if primary_specs: return primary_specs[0] return specs[0]
# ------------------------------------------------------------------ # Dunder helpers # ------------------------------------------------------------------ def __repr__(self) -> str: registered = ', '.join(self.proxy._registered_action_ids) or '—' return ( f"<{type(self).__name__} " f"ns='{self.proxy._namespace}' " f"menu={'attached' if self.proxy._menu else 'not built'} " f"actions=[{registered}]>" )