"""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.
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]
handler: Callable[..., None]
[docs]
checkable: bool = False
[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()
)
# 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
# ------------------------------------------------------------------
@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
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# 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}]>"
)