Source code for wolfhece._menu_companion_abc

"""Abstract base class for menu-based companion objects in WolfMapViewer.

Purpose
-------
This module provides the building blocks to author new companion classes
that integrate with the viewer's menu bar and interactive-action system.

Design goals
------------
* **Encapsulation of state** — each companion instance is the natural home
  for all transient state needed by its actions (picked objects, step
  counters, intermediate geometry, …).  The ABC guarantees a minimal,
  consistent interface while leaving full freedom to subclasses.
* **Reduced boilerplate** — common operations (menu idempotency, guards,
  action registration/cleanup, standard dialogs) are provided once and
  reused by every subclass.
* **Multi-step actions** — :class:`MultiStepAction` encapsulates the
  step-counter, per-step hint messages and state reset for interactions
  that span several mouse events.

Typical usage
-------------
::

    from wolfhece._menu_companion_abc import AbstractCompanion, CompanionState, MultiStepAction

    class MyCompanion(AbstractCompanion):

        def __init__(self, viewer):
            super().__init__(viewer)
            # _make_action() creates a namespaced + viewer-bound MultiStepAction.
            # The action_id becomes e.g. 'mycompanion.place' automatically.
            self._place = self._make_action(
                'place',
                ['Left-click to pick start point',
                 'Left-click to pick end point — Esc to cancel'],
            )
            # Typed local state.
            self._pts: list = []

        def menu_build(self) -> None:
            # No wx import needed — _build_menu handles it.
            self._build_menu(_('My Feature'), [
                ActionItem(_('Place\u2026'), self._on_place, _('Pick two points')),
            ])
            # self._place.action_id is already namespaced ('mycompanion.place').
            self._register_action(
                self._place.action_id,
                ldown=self._ldown_place,
                key=self._key_place,
            )

        # -- event handlers -----------------------------------------------------

        def _on_place(self, event):
            self._pts.clear()
            self._place.start()           # viewer already bound

        def _ldown_place(self, viewer, ctx):
            self._pts.append((ctx.x, ctx.y))
            if not self._place.advance():  # viewer already bound
                # All steps done — finalise.
                logging.info('Two points: %s', self._pts)
                self._place.cancel()       # viewer already bound

        def _key_place(self, viewer, kb):
            if kb.key_code == 27:  # Esc
                self._place.cancel()       # viewer already bound
                return True
            return False

Integration in PyDraw.py
------------------------
::

    from ._my_feature_manager import MyCompanion
    ...
    self._my_feature = MyCompanion(self)
    ...
    self._my_feature.menu_build()
"""
from __future__ import annotations

import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Callable, Iterable, Optional, Union

from ._action_kind import ActionKind
from .dialog_provider import DialogProvider
from .PyTranslate import _

if TYPE_CHECKING:
    import wx
    from .PyDraw import WolfMapViewer
    from ._viewer_plugin_handlers import KeyboardSnapshot, MouseContext

__all__ = [
    'AbstractCompanion',
    'ActionItem',
    'CompanionState',
    'MenuEntry',
    'MultiStepAction',
    'SEPARATOR',
    'Separator',
    'SubMenuSpec',
]


# ---------------------------------------------------------------------------
# CompanionState — typed, resettable state container
# ---------------------------------------------------------------------------

[docs] class CompanionState: """Minimal resettable key/value store for companion state. Subclass this to get typed attributes:: class _MyState(CompanionState): start_xy: tuple | None = None end_xy: tuple | None = None class MyCompanion(AbstractCompanion): def __init__(self, viewer): super().__init__(viewer) self._state = _MyState() """ #: Current step index inside a multi-step interaction. Managed by #: :class:`MultiStepAction` when it is used alongside a state object.
[docs] step: int
def __init__(self) -> None: self.step = 0
[docs] def reset(self) -> None: """Reset ``step`` and all public mutable attributes to their class defaults. Attributes whose class-level default is a type (not a value) are skipped so that accidental re-initialisation of class-level type annotations does not cause errors. """ self.step = 0 for name, default in self.__class__.__dict__.items(): if name.startswith('_'): continue if name == 'step': continue if callable(default) or isinstance(default, (classmethod, staticmethod, property)): continue try: setattr(self, name, default) except AttributeError: pass
[docs] def advance(self) -> int: """Increment *step* and return the new value.""" self.step += 1 return self.step
# --------------------------------------------------------------------------- # MultiStepAction — step-machine for interactive actions # ---------------------------------------------------------------------------
[docs] class MultiStepAction: """Wraps a viewer interactive action that spans multiple input steps. Parameters ---------- action_id: The lowercase string (or :class:`ActionKind`) used to identify the action in the viewer's ``start_action`` / ``register_action`` system. step_hints: Ordered list of status-bar / log messages, one per step. The message at index *n* is shown while the user is performing step *n*. state: Optional :class:`CompanionState` to reset automatically when :meth:`start` and :meth:`cancel` are called. If not provided a plain :class:`CompanionState` is created. Typical pattern inside a companion ----------------------------------- :: self._pick_action = MultiStepAction( 'mycompanion pick', ['Click first point', 'Click second point'], state=self._state, ) In the ldown handler:: def _ldown(self, viewer, ctx): # store data … done = not self._pick_action.advance() # viewer already bound if done: self._finalise() self._pick_action.cancel() # resets and ends action """ def __init__( self, action_id: str | ActionKind, step_hints: list[str], state: Optional[CompanionState] = None, viewer: Optional['WolfMapViewer'] = None, ) -> None: if not step_hints: raise ValueError("step_hints must contain at least one entry")
[docs] self.action_id: str = ( action_id.value if isinstance(action_id, ActionKind) else str(action_id) )
[docs] self.step_hints: list[str] = list(step_hints)
[docs] self.state: CompanionState = state if state is not None else CompanionState()
[docs] self._viewer: Optional['WolfMapViewer'] = viewer
[docs] self._active: bool = False
[docs] def _resolve_viewer(self, viewer: Optional['WolfMapViewer']) -> 'WolfMapViewer': """Return *viewer* if provided, else fall back to the bound viewer. Raises ``RuntimeError`` if neither is available. """ v = viewer if viewer is not None else self._viewer if v is None: raise RuntimeError( f"MultiStepAction('{self.action_id}'): no viewer available. " "Pass viewer= to the constructor or to the method call." ) return v
# ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @property
[docs] def is_active(self) -> bool: """``True`` while the action has been started and not yet finished.""" return self._active
@property
[docs] def current_step(self) -> int: """Current zero-based step index.""" return self.state.step
@property
[docs] def current_hint(self) -> str: """Status message for the current step, or empty string if out of range.""" idx = self.state.step if 0 <= idx < len(self.step_hints): return self.step_hints[idx] return ''
@property
[docs] def total_steps(self) -> int: return len(self.step_hints)
# ------------------------------------------------------------------ # Control # ------------------------------------------------------------------
[docs] def start(self, viewer: Optional['WolfMapViewer'] = None, message: str = '') -> None: """Begin the action at step 0. :param viewer: The WolfMapViewer instance. May be omitted when a viewer was supplied to the constructor. :param message: Override for the initial hint (defaults to ``step_hints[0]``). """ v = self._resolve_viewer(viewer) self.state.reset() self._active = True v.start_action(self.action_id, message or self.current_hint)
[docs] def advance(self, viewer: Optional['WolfMapViewer'] = None) -> bool: """Increment the step counter and refresh the viewer's action message. Returns ``True`` if there are more steps to perform, ``False`` when all steps have been completed (the caller should then finalise its logic and call :meth:`cancel` to end the action cleanly). :param viewer: May be omitted when a viewer was supplied to the constructor. """ v = self._resolve_viewer(viewer) self.state.advance() if self.state.step >= len(self.step_hints): self._active = False return False v.start_action(self.action_id, self.current_hint) return True
[docs] def cancel(self, viewer: Optional['WolfMapViewer'] = None, message: str = '') -> None: """Abort or finish the action and reset all state. Safe to call even when the action is not active. :param viewer: May be omitted when a viewer was supplied to the constructor. :param message: Optional status-bar text forwarded to :meth:`WolfMapViewer.end_action`. """ v = self._resolve_viewer(viewer) self.state.reset() self._active = False v.end_action(message)
# --------------------------------------------------------------------------- # Declarative menu types — no wx knowledge required # --------------------------------------------------------------------------- @dataclass
[docs] class ActionItem: """A single clickable (or checkable) menu entry. :param label: Text shown in the menu. :param handler: Callable invoked when the item is selected. Signature: ``(event) -> None`` — ``event`` can be ignored if not needed. :param help: Optional help string shown in the status bar. :param checkable: When ``True`` the item renders as a toggle checkbox. :param enabled: When ``False`` the item is greyed out on creation. """
[docs] label: str
[docs] handler: Callable[..., None]
[docs] help: str = ''
[docs] checkable: bool = False
[docs] enabled: bool = True
[docs] class Separator: """Singleton sentinel that inserts a separator line in the menu. Use the pre-built :data:`SEPARATOR` instance rather than instantiating this class directly. """
[docs] _instance: 'Separator | None' = None
def __new__(cls) -> 'Separator': if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __repr__(self) -> str: return 'SEPARATOR'
#: Pre-built singleton — place it in a menu item list to insert a separator.
[docs] SEPARATOR: Separator = Separator()
@dataclass #: Type alias for anything that can appear inside a menu. # --------------------------------------------------------------------------- # AbstractCompanion — the ABC # ---------------------------------------------------------------------------
[docs] class AbstractCompanion(ABC): """Abstract base class for companion objects that integrate with WolfMapViewer. 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 (override :meth:`menu_build` to add one). Subclasses **must** implement :meth:`start`. All other methods are either concrete helpers or optional overrides. In particular, :meth:`menu_build` has a no-op default — companions that interact purely via mouse/keyboard events do not need a menu. Parameters ---------- viewer: The :class:`~wolfhece.PyDraw.WolfMapViewer` instance that owns this companion. """ def __init__( self, viewer: 'WolfMapViewer', dialogs: DialogProvider | None = None, namespace: str | None = None, ) -> None:
[docs] self._viewer: 'WolfMapViewer' = viewer
#: Dialog provider — inject a mock in tests, use default in production.
[docs] self._dialogs: DialogProvider = dialogs if dialogs is not None else DialogProvider()
#: Namespace prefix prepended to every action id registered by this #: companion. Defaults to the lower-cased class name so two companions #: that independently pick the same local id never collide. #: Override with the *namespace* constructor argument when needed.
[docs] self._namespace: str = ( namespace if namespace is not None else type(self).__name__.lower() )
[docs] self._menu: 'wx.Menu | None' = None
# Track every action_id registered via _register_action so that # destroy() can clean them all up automatically.
[docs] self._registered_action_ids: list[str] = []
# ------------------------------------------------------------------ # Abstract interface # ------------------------------------------------------------------
[docs] def menu_build(self) -> None: """Build and attach the companion's menu to the viewer menubar. The default implementation does nothing — companions that work purely through mouse/keyboard interactions do not need a menu. Override this method when a menu is desired. **Preferred — wx-free approach** using :meth:`_build_menu`:: def menu_build(self) -> None: self._build_menu(_('My Feature'), [ ActionItem(_('Do something'), self._on_do), SEPARATOR, SubMenuSpec(_('Tools'), [ ActionItem(_('Export'), self._on_export), ]), ]) **Alternative — full wx control** (for advanced cases):: def menu_build(self) -> None: if self._menu is not None: return import wx self._menu = wx.Menu() ... Implementations **must** be idempotent. """
@abstractmethod
[docs] def start(self) -> None: """Activate the companion's primary interactive action. Implement this method by calling :meth:`_start_action` with the appropriate action id and status-bar hint:: def start(self) -> None: self._start_action(self._action_id('record'), 'Right-click to capture…') This method is intended to be called from notebook cells or scripts. It should not contain wx logic — delegate that to the menu-item callback (``_on_start``) which itself calls ``self.start()``:: def _on_start(self, _event): self.start() """
[docs] def stop(self) -> None: """Deactivate the companion's primary interactive action. The default implementation calls :meth:`_end_action`. Override to add post-processing (e.g. printing a summary):: def stop(self) -> None: self._end_action() print(f"{len(self.points)} item(s) recorded.") """ self._end_action()
# ------------------------------------------------------------------ # Namespace helpers # ------------------------------------------------------------------
[docs] def _action_id(self, local_id: str) -> str: """Return a fully-qualified action id: ``'{namespace}.{local_id}'``. Using this method (instead of bare strings) guarantees that two companions that independently choose the same *local_id* will never collide in the viewer's action dispatch tables. :param local_id: Short, human-readable name for the action (e.g. ``'pick'``, ``'place wall'``). :returns: ``'{namespace}.{local_id}'`` — e.g. ``'mycompanion.pick'``. """ return f"{self._namespace}.{local_id}"
[docs] def _make_action( self, local_id: str, step_hints: list[str], state: Optional[CompanionState] = None, ) -> 'MultiStepAction': """Create a namespaced, viewer-bound :class:`MultiStepAction`. Shorthand for:: MultiStepAction( self._action_id(local_id), step_hints, state=state, viewer=self._viewer, ) The resulting ``action.action_id`` is already namespaced, so it can be passed directly to :meth:`_register_action`:: self._pick = self._make_action('pick', ['Click a point']) # In menu_build: self._register_action(self._pick.action_id, ldown=self._ldown) :param local_id: Short name scoped to this companion. :param step_hints: One hint message per interaction step. :param state: Optional shared :class:`CompanionState` instance. """ return MultiStepAction( self._action_id(local_id), step_hints, state=state, viewer=self._viewer, )
# ------------------------------------------------------------------ # Declarative menu construction # ------------------------------------------------------------------
[docs] def _build_menu( self, title: str, items: list[MenuEntry], ) -> None: """Build and attach the companion's menu from a declarative spec. No wx knowledge is required. Call this from :meth:`menu_build` instead of writing raw ``wx.Menu`` code. Idempotent — subsequent calls are no-ops once the menu is built. :param title: Label shown in the viewer's menu bar. :param items: Ordered list of :class:`ActionItem`, :data:`SEPARATOR`, or :class:`SubMenuSpec` objects. Example:: def menu_build(self) -> None: self._build_menu(_('My Tool'), [ ActionItem(_('Run'), self._on_run, _('Execute the tool')), SEPARATOR, SubMenuSpec(_('Settings'), [ ActionItem(_('Configure…'), self._on_configure), ]), ]) """ if self._menu is not None: return import wx # deferred — keeps the module importable without wx self._menu = wx.Menu() self._viewer.menubar.Append(self._menu, title) self._fill_wx_menu(self._menu, items)
[docs] def _fill_wx_menu( self, menu: 'wx.Menu', items: list[MenuEntry], ) -> None: """Recursively populate *menu* from *items*. Internal use only.""" import wx for entry in items: if isinstance(entry, Separator): menu.AppendSeparator() elif isinstance(entry, SubMenuSpec): sub = wx.Menu() self._fill_wx_menu(sub, entry.items) menu.AppendSubMenu(sub, entry.label, entry.help) elif isinstance(entry, ActionItem): if entry.checkable: item = menu.AppendCheckItem(wx.ID_ANY, entry.label, entry.help) else: item = menu.Append(wx.ID_ANY, entry.label, entry.help) if not entry.enabled: menu.Enable(item.GetId(), False) menu.Bind(wx.EVT_MENU, entry.handler, item)
# ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------
[docs] def destroy(self) -> None: """Unregister all custom actions and release viewer resources. Call this when the companion is no longer needed. The method is safe to call multiple times. """ for action_id in list(self._registered_action_ids): try: self._viewer.unregister_action(action_id) except Exception: logging.debug( "%s.destroy: could not unregister action '%s'", type(self).__name__, action_id, ) self._registered_action_ids.clear()
# ------------------------------------------------------------------ # Action registration helpers # ------------------------------------------------------------------
[docs] def _register_action( self, action_id: str | ActionKind, *, rdown: Optional[Callable] = None, motion: Optional[Callable] = None, ldown: Optional[Callable] = None, key: Optional[Callable] = None, paint: Optional[Callable] = None, overload: bool = False, ) -> None: """Register a custom action in the viewer and track it for cleanup. All parameters are forwarded to :meth:`~wolfhece.PyDraw.WolfMapViewer.register_action`. The *action_id* is remembered so that :meth:`destroy` can call :meth:`~wolfhece.PyDraw.WolfMapViewer.unregister_action` automatically. :param action_id: Lowercase action identifier. :param rdown: ``(viewer, MouseContext) → None`` — right click. :param motion: ``(viewer, MouseContext) → None`` — mouse move. :param ldown: ``(viewer, MouseContext) → None`` — left click. :param key: ``(viewer, KeyboardSnapshot) → bool`` — key press. :param paint: ``(viewer) → None`` — OpenGL paint hook. :param overload: Save and restore displaced handlers on cleanup. """ key_str = ( action_id.value if isinstance(action_id, ActionKind) else str(action_id) ).lower() self._viewer.register_action( key_str, rdown_handler=rdown, motion_handler=motion, ldown_handler=ldown, key_handler=key, paint_handler=paint, overload=overload, ) if key_str not in self._registered_action_ids: self._registered_action_ids.append(key_str)
[docs] def _unregister_action(self, action_id: str | ActionKind) -> None: """Unregister a single custom action. :param action_id: The id passed to :meth:`_register_action`. """ key_str = ( action_id.value if isinstance(action_id, ActionKind) else str(action_id) ).lower() try: self._viewer.unregister_action(key_str) except Exception: logging.debug( "%s._unregister_action: could not unregister '%s'", type(self).__name__, key_str, ) if key_str in self._registered_action_ids: self._registered_action_ids.remove(key_str)
# ------------------------------------------------------------------ # Action control shortcuts # ------------------------------------------------------------------
[docs] def _start_action( self, action_id: str | ActionKind, message: str = '', ) -> None: """Shortcut for ``self._viewer.start_action(action_id, message)``.""" self._viewer.start_action(action_id, message)
[docs] def _end_action(self, message: str = '') -> None: """End the current viewer action via :meth:`WolfMapViewer.end_action`. Prefer this over calling ``viewer.start_action('', '')`` directly: ``end_action`` additionally resets ``active_vertex``, ``active_cloud_vertex_id``, fires the sculpt / assets end-action callbacks, and logs an "End of action" message. :param message: Optional status-bar text to display after the action ends (forwarded to :meth:`WolfMapViewer.end_action`). """ self._viewer.end_action(message)
# ------------------------------------------------------------------ # Guard helpers # ------------------------------------------------------------------
[docs] def _require_active_array(self) -> bool: """Return ``True`` if the viewer has an active array, else warn and return ``False``.""" if self._viewer.active_array is None: logging.warning(_("No active array — activate/select one first")) return False return True
[docs] def _require(self, condition: bool, warning: str) -> bool: """Generic guard: log *warning* and return ``False`` when *condition* is falsy. :param condition: Pre-evaluated boolean condition. :param warning: Message logged at WARNING level when the guard fails. """ if not condition: logging.warning(warning) return bool(condition)
# ------------------------------------------------------------------ # Dialog helpers — no wx knowledge required # ------------------------------------------------------------------
[docs] def _show_error(self, message: str, title: str = '') -> None: """Display a modal error dialog.""" from .dialog_provider import DialogStyles self._dialogs.show_message( message, caption=title or _("Error"), style=DialogStyles.OK | DialogStyles.ICON_ERROR, parent=self._viewer, )
[docs] def _show_info(self, message: str, title: str = '') -> None: """Display a modal information dialog.""" self._dialogs.show_message( message, caption=title or _("Information"), parent=self._viewer, )
[docs] def _confirm( self, message: str, title: str = '', default: str = 'yes', ) -> bool: """Display a Yes/No confirmation dialog. :param message: Question text. :param title: Dialog title (defaults to *"Confirm"*). :param default: Which button is the default — ``'yes'`` or ``'no'``. :returns: ``True`` if the user clicked *Yes*. """ return self._dialogs.ask_confirmation( message, caption=title or _("Confirm"), default=default, # type: ignore[arg-type] parent=self._viewer, )
[docs] def _ask_text( self, message: str, title: str = '', default: str = '', ) -> str | None: """Prompt the user for a text value. :returns: The entered string, or ``None`` if cancelled. """ return self._dialogs.ask_text( message, caption=title, default=default, parent=self._viewer, )
[docs] def _ask_float( self, message: str, title: str = '', default: float | str = '', ) -> float | None: """Prompt the user for a floating-point value. :returns: The entered float, or ``None`` if cancelled or invalid. """ return self._dialogs.ask_float( message, caption=title, default=default, parent=self._viewer, )
[docs] def _ask_integer( self, message: str, title: str = '', prompt: str = '', default: int = 0, min_value: int = 0, max_value: int = 0, ) -> int | None: """Prompt the user for an integer value via a spin-entry dialog. :returns: The entered integer, or ``None`` if cancelled. """ return self._dialogs.ask_integer( message, prompt=prompt, caption=title, default=default, min_value=min_value, max_value=max_value, parent=self._viewer, )
[docs] def _ask_single_choice( self, message: str, title: str, choices: list[str], preselected: int | None = None, ) -> str | None: """Show a single-choice list dialog. :returns: The selected string, or ``None`` if cancelled. """ return self._dialogs.ask_single_choice( message, caption=title, choices=choices, preselected=preselected, parent=self._viewer, )
[docs] def _ask_file_open( self, message: str, wildcard: str = 'all (*.*)|*.*', default_path: str = '', ) -> str | None: """Open a file-open dialog. :returns: Selected file path, or ``None`` if cancelled. """ return self._dialogs.ask_file_open( message, wildcard=wildcard, default_path=default_path, parent=self._viewer, )
[docs] def _ask_file_save( self, message: str, wildcard: str = 'all (*.*)|*.*', default_path: str = '', default_file: str = '', ) -> str | None: """Open a file-save dialog. :returns: Selected file path, or ``None`` if cancelled. """ return self._dialogs.ask_file_save( message, wildcard=wildcard, default_path=default_path, default_file=default_file, parent=self._viewer, )
[docs] def _ask_directory( self, message: str, default_path: str = '', ) -> str | None: """Open a directory-chooser dialog. :returns: Selected directory path, or ``None`` if cancelled. """ return self._dialogs.ask_directory( message, default_path=default_path, parent=self._viewer, )
# ------------------------------------------------------------------ # Progress helper # ------------------------------------------------------------------
[docs] def _run_with_progress( self, title: str, steps: list[tuple[int, str, Callable]], ) -> None: """Run a series of steps under a progress dialog. Each element of *steps* is ``(percent, label, callable)`` where the callable is invoked with no arguments. The progress dialog is always closed even if an exception occurs. Example:: self._run_with_progress( _("Processing"), [ (25, _("Loading…"), self._load), (75, _("Computing…"), self._compute), (100, _("Saving…"), self._save), ], ) :param title: Dialog title. :param steps: Ordered list of ``(percent, label, fn)`` tuples. """ handle = self._dialogs.create_progress( title, _("Working…"), maximum=100, parent=self._viewer, ) try: for percent, label, fn in steps: handle.update(percent, label) fn() self._viewer.Refresh() finally: handle.close()
# ------------------------------------------------------------------ # Viewer convenience helpers # ------------------------------------------------------------------
[docs] def _set_status(self, message: str) -> None: """Write *message* to the viewer's status bar. Shorthand for ``self._viewer.set_statusbar_text(message)``. Useful in motion handlers to display live coordinates without needing to know the internal viewer API. :param message: Text to display in the status bar. """ self._viewer.set_statusbar_text(message)
[docs] def _force_redraw(self) -> None: """Ask the viewer to repaint immediately. Shorthand for ``self._viewer.Paint()``. Call this after modifying data that is drawn by a paint hook so that the screen reflects the change without waiting for the next natural repaint event. """ self._viewer.Paint()
# ------------------------------------------------------------------ # OpenGL paint helpers # ------------------------------------------------------------------
[docs] def _viewport_fraction(self, fraction: float = 0.008) -> float: """Return *fraction* × viewport width in world units. Useful for computing marker sizes that scale naturally with the current zoom level:: def _paint_markers(self, viewer) -> None: half = self._viewport_fraction(0.008) self._draw_crosses(self.points, half) :param fraction: Fraction of the visible viewport width (default 0.008). :return: World-space distance proportional to the current zoom. """ return (self._viewer.xmax - self._viewer.xmin) * fraction
[docs] def _draw_crosses( self, points: 'Iterable[tuple[float, float]]', half_size: float, color: 'tuple[float, float, float, float]' = (1.0, 0.0, 0.0, 1.0), *, selected_idx: int = -1, selected_color: 'tuple[float, float, float, float]' = (1.0, 0.8, 0.0, 1.0), line_width: float = 2.0, ) -> None: """Draw a cross marker at each ``(x, y)`` point using OpenGL. No ``import OpenGL`` is needed in the companion — this helper handles it internally. Typical use inside a paint handler:: def _paint_markers(self, viewer) -> None: half = self._viewport_fraction(0.008) self._draw_crosses( self.markers, half, selected_idx=self._selected, ) :param points: Sequence of ``(x, y)`` world-coordinate pairs. :param half_size: Half arm-length of each cross, in world units. :param color: RGBA color for unselected crosses (each channel 0–1). :param selected_idx: Index of the cross to highlight (``-1`` = none). :param selected_color: RGBA color for the highlighted cross. :param line_width: OpenGL line width in pixels. """ from OpenGL.GL import ( # noqa: PLC0415 — lazy, GL not always present glBegin, glEnd, glVertex2f, glColor4f, glLineWidth, GL_LINES, ) pts = list(points) if not pts: return glLineWidth(line_width) glBegin(GL_LINES) for i, (x, y) in enumerate(pts): col = selected_color if i == selected_idx else color glColor4f(*col) glVertex2f(x - half_size, y); glVertex2f(x + half_size, y) glVertex2f(x, y - half_size); glVertex2f(x, y + half_size) glEnd() glLineWidth(1.0)
[docs] def _draw_polyline( self, points: 'Iterable[tuple[float, float]]', color: 'tuple[float, float, float, float]' = (0.0, 0.5, 1.0, 1.0), *, closed: bool = False, line_width: float = 1.5, ) -> None: """Draw a polyline (connected line string) through *points* using OpenGL. :: def _paint_path(self, viewer) -> None: self._draw_polyline(self.path, (0.0, 0.7, 1.0, 1.0)) :param points: Sequence of ``(x, y)`` world-coordinate pairs (≥ 2). :param color: RGBA line color (each channel 0–1). :param closed: If ``True`` a closing segment is added back to the first point. :param line_width: OpenGL line width in pixels. """ from OpenGL.GL import ( # noqa: PLC0415 glBegin, glEnd, glVertex2f, glColor4f, glLineWidth, GL_LINE_STRIP, GL_LINE_LOOP, ) pts = list(points) if len(pts) < 2: return primitive = GL_LINE_LOOP if closed else GL_LINE_STRIP glLineWidth(line_width) glColor4f(*color) glBegin(primitive) for x, y in pts: glVertex2f(x, y) glEnd() glLineWidth(1.0)
[docs] def _draw_segments( self, segments: 'Iterable[tuple[float, float, float, float]]', color: 'tuple[float, float, float, float]' = (1.0, 1.0, 0.0, 1.0), *, line_width: float = 1.5, ) -> None: """Draw individual line segments using OpenGL. Each segment is a ``(x1, y1, x2, y2)`` tuple:: def _paint_edges(self, viewer) -> None: self._draw_segments( [(x0, y0, x1, y1) for x0, y0, x1, y1 in self.edges], ) :param segments: Sequence of ``(x1, y1, x2, y2)`` tuples. :param color: RGBA line color (each channel 0–1). :param line_width: OpenGL line width in pixels. """ from OpenGL.GL import ( # noqa: PLC0415 glBegin, glEnd, glVertex2f, glColor4f, glLineWidth, GL_LINES, ) segs = list(segments) if not segs: return glLineWidth(line_width) glColor4f(*color) glBegin(GL_LINES) for x1, y1, x2, y2 in segs: glVertex2f(x1, y1) glVertex2f(x2, y2) glEnd() glLineWidth(1.0)
[docs] def _draw_points( self, points: 'Iterable[tuple[float, float]]', color: 'tuple[float, float, float, float]' = (1.0, 0.0, 0.0, 1.0), *, point_size: float = 4.0, ) -> None: """Draw filled dots at each ``(x, y)`` point using OpenGL. :: def _paint_nodes(self, viewer) -> None: self._draw_points(self.nodes, (0.2, 0.8, 0.2, 1.0), point_size=6.0) :param points: Sequence of ``(x, y)`` world-coordinate pairs. :param color: RGBA color (each channel 0–1). :param point_size: OpenGL point size in pixels. """ from OpenGL.GL import ( # noqa: PLC0415 glBegin, glEnd, glVertex2f, glColor4f, glPointSize, GL_POINTS, ) pts = list(points) if not pts: return glPointSize(point_size) glColor4f(*color) glBegin(GL_POINTS) for x, y in pts: glVertex2f(x, y) glEnd() glPointSize(1.0)
# ------------------------------------------------------------------ # Dunder helpers # ------------------------------------------------------------------ def __repr__(self) -> str: registered = ', '.join(self._registered_action_ids) or '—' return ( f"<{type(self).__name__} " f"ns='{self._namespace}' " f"menu={'attached' if self._menu else 'not built'} " f"actions=[{registered}]>" )