Source code for fury.ui.core

"""UI core module that describe UI abstract class."""

import abc

import numpy as np

from fury.actor import Text, create_mesh
from fury.geometry import buffer_to_geometry
from fury.lib import (
    EventType,
    plane_geometry,
)
from fury.material import _create_mesh_material
from fury.primitive import prim_ring
from fury.ui import UIContext
from fury.ui.helpers import UI_Z_RANGE, Anchor, get_anchor_to_multiplier


[docs] class UI(object, metaclass=abc.ABCMeta): """ An umbrella class for all UI elements. While adding UI elements to the scene, we go over all the sub-elements that come with it and add those to the scene automatically. Parameters ---------- position : (float, float) Absolute pixel coordinates `(x, y)` which, in combination with `x_anchor` and `y_anchor`, define the initial placement of this UI component. x_anchor : str, optional Define the horizontal anchor point for `position`. Can be "LEFT", "CENTER", or "RIGHT". y_anchor : str, optional Define the vertical anchor point for `position`. Can be "BOTTOM", "CENTER", or "TOP". z_order : int, optional The initial Z-order of the UI component. Attributes ---------- position : (float, float) Absolute coordinates (x, y) of the lower-left corner of this UI component. center : (float, float) Absolute coordinates (x, y) of the center of this UI component. on_left_mouse_button_pressed: function Callback function for when the left mouse button is pressed. on_left_mouse_button_released: function Callback function for when the left mouse button is released. on_left_mouse_button_clicked: function Callback function for when clicked using the left mouse button (i.e. pressed -> released). on_left_mouse_double_clicked: function Callback function for when left mouse button is double clicked (i.e pressed -> released -> pressed -> released). on_left_mouse_button_dragged: function Callback function for when dragging using the left mouse button. on_right_mouse_button_pressed: function Callback function for when the right mouse button is pressed. on_right_mouse_button_released: function Callback function for when the right mouse button is released. on_right_mouse_button_clicked: function Callback function for when clicking using the right mouse button (i.e. pressed -> released). on_right_mouse_double_clicked: function Callback function for when right mouse button is double clicked (i.e pressed -> released -> pressed -> released). on_right_mouse_button_dragged: function Callback function for when dragging using the right mouse button. on_middle_mouse_button_pressed: function Callback function for when the middle mouse button is pressed. on_middle_mouse_button_released: function Callback function for when the middle mouse button is released. on_middle_mouse_button_clicked: function Callback function for when clicking using the middle mouse button (i.e. pressed -> released). on_middle_mouse_double_clicked: function Callback function for when middle mouse button is double clicked (i.e pressed -> released -> pressed -> released). on_middle_mouse_button_dragged: function Callback function for when dragging using the middle mouse button. on_key_press: function Callback function for when a keyboard key is pressed. """
[docs] def __init__( self, *, position=(0, 0), x_anchor=Anchor.LEFT, y_anchor=Anchor.TOP, z_order=0 ): """Init scene.""" self._position = np.array([0, 0]) self._children = [] self._anchors = [x_anchor, y_anchor] self.z_order = z_order self._setup() # Setup needed actors and sub UI components. self.set_position(position, x_anchor, y_anchor) self.left_button_state = "released" self.right_button_state = "released" self.middle_button_state = "released" self.on_left_mouse_button_pressed = lambda event: None self.on_left_mouse_button_dragged = lambda event: None self.on_left_mouse_button_released = lambda event: None self.on_left_mouse_button_clicked = lambda event: None self.on_left_mouse_double_clicked = lambda event: None self.on_right_mouse_button_pressed = lambda event: None self.on_right_mouse_button_released = lambda event: None self.on_right_mouse_button_clicked = lambda event: None self.on_right_mouse_double_clicked = lambda event: None self.on_right_mouse_button_dragged = lambda event: None self.on_middle_mouse_button_pressed = lambda event: None self.on_middle_mouse_button_released = lambda event: None self.on_middle_mouse_button_clicked = lambda event: None self.on_middle_mouse_double_clicked = lambda event: None self.on_middle_mouse_button_dragged = lambda event: None self.on_key_press = lambda event: None self.on_hover = lambda event: None self.on_dishover = lambda event: None self.on_focus = lambda event: None self.on_blur = lambda event: None
@abc.abstractmethod def _setup(self): """ Set up this UI component. This is where you should create all your needed actors and sub UI components. """ msg = "Subclasses of UI must implement `_setup(self)`." raise NotImplementedError(msg) @abc.abstractmethod def _get_actors(self): """Get the actors composing this UI component.""" msg = "Subclasses of UI must implement `_get_actors(self)`." raise NotImplementedError(msg) @property def actors(self): """ Get actors composing this UI component. Returns ------- list List of actors composing this UI component. """ return self._get_actors()
[docs] def perform_position_validation(self, x_anchor, y_anchor): """ Perform validation checks for anchor string and the 'size' property. Parameters ---------- x_anchor : str Horizontal anchor string to validate (e.g., "LEFT", "CENTER", "RIGHT"). y_anchor : str Vertical anchor string to validate (e.g., "TOP", "CENTER", "BOTTOM"). """ if not hasattr(self, "size"): msg = "Subclasses of UI must implement property `size`." raise NotImplementedError(msg) if x_anchor not in [Anchor.LEFT, Anchor.CENTER, Anchor.RIGHT]: raise ValueError( f"x_anchor should be one of these {', '.join([Anchor.LEFT, Anchor.CENTER, Anchor.RIGHT])} but received {x_anchor}" # noqa: E501 ) if y_anchor not in [Anchor.TOP, Anchor.CENTER, Anchor.BOTTOM]: raise ValueError( f"y_anchor should be one of these {', '.join([Anchor.TOP, Anchor.CENTER, Anchor.BOTTOM])} but received {y_anchor}" # noqa: E501 )
[docs] def set_actor_position(self, actor, center_position, z_order): """ Set the position of the PyGfx actor. Parameters ---------- actor : Mesh The PyGfx mesh actor whose position needs to be set. center_position : tuple or ndarray A 2-element array `(x, y)` representing the desired center position of the actor. z_order : int The Z-order of the UI component. """ canvas_size = UIContext.canvas_size actor.local.x = center_position[0] actor.local.y = canvas_size[1] - center_position[1] actor.local.z = np.interp(z_order, UIContext.z_order_bounds, UI_Z_RANGE)
[docs] def set_position(self, coords, x_anchor=Anchor.LEFT, y_anchor=Anchor.TOP): """ Position this UI component according to the specified anchor. Parameters ---------- coords : (float, float) Absolute pixel coordinates (x, y). These coordinates are interpreted based on `x_anchor` and `y_anchor`. x_anchor : str, optional Define the horizontal anchor point for `coords`. Can be "LEFT", "CENTER", or "RIGHT". y_anchor : str, optional Define the vertical anchor point for `coords`. Can be "TOP", "CENTER", or "BOTTOM". """ self.perform_position_validation(x_anchor=x_anchor, y_anchor=y_anchor) self._position = np.array(coords) self._anchors = [x_anchor.upper(), y_anchor.upper()] self._update_actors_position()
[docs] def get_position( self, x_anchor=Anchor.LEFT, y_anchor=Anchor.TOP, ): """ Get the position of this UI component according to the specified anchor. Parameters ---------- x_anchor : str, optional Define the horizontal anchor point for the returned coordinates. Can be "LEFT", "CENTER", or "RIGHT". y_anchor : str, optional Define the vertical anchor point for the returned coordinates. Can be "BOTTOM", "CENTER", or "TOP". Returns ------- (float, float) The (x, y) pixel coordinates of the specified anchor point. """ ANCHOR_TO_MULTIPLIER = get_anchor_to_multiplier() self.perform_position_validation(x_anchor=x_anchor, y_anchor=y_anchor) size = self.size return np.array( [ self._position[0] + size[0] * ( ANCHOR_TO_MULTIPLIER[x_anchor.upper()] - ANCHOR_TO_MULTIPLIER[self._anchors[0].upper()] ), self._position[1] + size[1] * ( ANCHOR_TO_MULTIPLIER[y_anchor.upper()] - ANCHOR_TO_MULTIPLIER[self._anchors[1].upper()] ), ] )
@property def z_order(self): """ Get the Z-order of this UI element. Returns ------- int Z-order of the UI. """ return self._z_order @z_order.setter def z_order(self, z_order): """ Set the Z-order of this UI element. Parameters ---------- z_order : int The new integer Z-order value. Raises ------ ValueError If the provided `z_order` is not an integer. """ if not isinstance(z_order, int): raise ValueError("Z-order must be an integer.") self._z_order = z_order for child in self._children: child.z_order = z_order UIContext.z_order_bounds = z_order @abc.abstractmethod def _update_actors_position(self): """Update the position of the internal actors.""" msg = "Subclasses of UI must implement `_set_actors_position(self)`." raise NotImplementedError(msg) @property def size(self): """ Get width and height of this UI component. Returns ------- (int, int) Width and Height of UI component in pixels. """ return np.asarray(self._get_size(), dtype=int) @abc.abstractmethod def _get_size(self): """ Get the actual size of the UI component. Returns ------- (int, int) Width and height of the UI component in pixels. """ msg = "Subclasses of UI must implement property `size`." raise NotImplementedError(msg)
[docs] def set_visibility(self, visibility): """ Set visibility of this UI component. Parameters ---------- visibility : bool If `True`, the UI component will be visible. If `False`, it will be hidden. """ for actor in self.actors: actor.visible = visibility for child in self._children: child.set_visibility(visibility)
[docs] def handle_events(self, actor): """ Attach event handlers to the UI object. Parameters ---------- actor : Mesh The PyGfx mesh to which event handlers should be attached. """ actor.add_event_handler(self.mouse_button_down_callback, EventType.POINTER_DOWN) actor.add_event_handler(self.mouse_button_up_callback, EventType.POINTER_UP) actor.add_event_handler(self.mouse_move_callback, EventType.POINTER_DRAG) actor.add_event_handler(self.key_press_callback, EventType.KEY_UP) actor.add_event_handler(self.pointer_enter_callback, EventType.POINTER_ENTER) actor.add_event_handler(self.pointer_leave_callback, EventType.POINTER_LEAVE)
[docs] def mouse_button_down_callback(self, event): """ Handle mouse button press event. Parameters ---------- event : PointerEvent The PyGfx pointer event object. """ if event.button == 1: self.left_button_click_callback(event) elif event.button == 2: self.right_button_click_callback(event) elif event.button == 3: self.middle_button_click_callback(event)
[docs] def mouse_button_up_callback(self, event): """ Handle mouse button release event. Parameters ---------- event : PointerEvent The PyGfx pointer event object. """ if event.button == 1: self.left_button_release_callback(event) elif event.button == 2: self.right_button_release_callback(event) elif event.button == 3: self.middle_button_release_callback(event)
[docs] def left_button_click_callback(self, event): """ Handle left mouse button press event. Parameters ---------- event : PointerEvent The PyGfx pointer event object. """ self.left_button_state = "pressing" if UIContext.hot_ui is not None: if ( UIContext.active_ui is not None and UIContext.active_ui is not UIContext.hot_ui ): UIContext.active_ui.on_blur(event) UIContext.active_ui = UIContext.hot_ui UIContext.active_ui.on_focus(event) self.on_left_mouse_button_pressed(event)
[docs] def left_button_release_callback(self, event): """ Handle left mouse button release event. Parameters ---------- event : PointerEvent The PyGfx pointer event object. """ if self.left_button_state == "pressing": self.on_left_mouse_button_clicked(event) self.left_button_state = "released" self.on_left_mouse_button_released(event)
[docs] def right_button_click_callback(self, event): """ Handle right mouse button press event. Parameters ---------- event : PointerEvent The PyGfx pointer event object. """ self.right_button_state = "pressing" self.on_right_mouse_button_pressed(event)
[docs] def right_button_release_callback(self, event): """ Handle right mouse button release event. Parameters ---------- event : PointerEvent The PyGfx pointer event object. """ if self.right_button_state == "pressing": self.on_right_mouse_button_clicked(event) self.right_button_state = "released" self.on_right_mouse_button_released(event)
[docs] def middle_button_click_callback(self, event): """ Handle middle mouse button press event. Parameters ---------- event : PointerEvent The PyGfx pointer event object. """ self.middle_button_state = "pressing" self.on_middle_mouse_button_pressed(event)
[docs] def middle_button_release_callback(self, event): """ Handle middle mouse button release event. Parameters ---------- event : PointerEvent The PyGfx pointer event object. """ if self.middle_button_state == "pressing": self.on_middle_mouse_button_clicked(event) self.middle_button_state = "released" self.on_middle_mouse_button_released(event)
[docs] def mouse_move_callback(self, event): """ Handle mouse move event. Parameters ---------- event : PointerEvent The PyGfx pointer event object. """ left_pressing_or_dragging = ( self.left_button_state == "pressing" or self.left_button_state == "dragging" ) right_pressing_or_dragging = ( self.right_button_state == "pressing" or self.right_button_state == "dragging" ) middle_pressing_or_dragging = ( self.middle_button_state == "pressing" or self.middle_button_state == "dragging" ) if left_pressing_or_dragging: self.left_button_state = "dragging" self.on_left_mouse_button_dragged(event) elif right_pressing_or_dragging: self.right_button_state = "dragging" self.on_right_mouse_button_dragged(event) elif middle_pressing_or_dragging: self.middle_button_state = "dragging" self.on_middle_mouse_button_dragged(event)
[docs] def key_press_callback(self, event): """ Handle key press event. Parameters ---------- event : KeyboardEvent The PyGfx keyboard event object. """ self.on_key_press(event)
[docs] def pointer_enter_callback(self, event): """ Handle pointer enter event. Parameters ---------- event : PointerEvent The PyGfx pointer event object. """ UIContext.hot_ui = self self.on_hover(event)
[docs] def pointer_leave_callback(self, event): """ Handle pointer leave event. Parameters ---------- event : PointerEvent The PyGfx pointer event object. """ if UIContext.hot_ui is self: UIContext.hot_ui = None self.on_dishover(event)
[docs] class Rectangle2D(UI): """ A 2D rectangle sub-classed from UI. Parameters ---------- size : (int, int) The size of the rectangle (width, height) in pixels. position : (float, float) Coordinates (x, y) of the lower-left corner of the rectangle. color : (float, float, float) Must take values in [0, 1]. opacity : float Must take values in [0, 1]. """
[docs] def __init__( self, *, size=(100, 100), position=(0, 0), color=(1, 1, 1), opacity=1.0 ): """Initialize a rectangle.""" super(Rectangle2D, self).__init__(position=position) self.color = color self.opacity = opacity self.resize(size)
def _setup(self): """ Set up this UI component. Create the plane actor used internally. """ geo = plane_geometry(width=1, height=1) mat = _create_mesh_material(material="basic", alpha_mode="auto") self.actor = create_mesh(geometry=geo, material=mat) self.handle_events(self.actor) def _get_actors(self): """ Get the actors composing this UI component. Returns ------- list List of actors composing this UI component. """ return [self.actor] def _get_size(self): """ Get the current size of the rectangle actor. Returns ------- (float, float) The current `(width, height)` of the rectangle in pixels. """ bounds = self.actor.get_bounding_box() minx, miny, minz = bounds[0] maxx, maxy, maxz = bounds[1] return [maxx - minx, maxy - miny] @property def width(self): """ Get the current width of the rectangle. Returns ------- float The width of the rectangle in pixels. """ return self._get_size()[0] @width.setter def width(self, width): """ Set the width of the rectangle. Parameters ---------- width : float New width of the rectangle. """ self.resize((width, self.height)) @property def height(self): """ Get the current height of the rectangle. Returns ------- float The height of the rectangle in pixels. """ return self._get_size()[1] @height.setter def height(self, height): """ Set the height of the rectangle. Parameters ---------- height : float New height of the rectangle. """ self.resize((self.width, height))
[docs] def resize(self, size): """ Set the rectangle size. Parameters ---------- size : (float, float) Rectangle size (width, height) in pixels. """ if tuple(size) == (0, 0): size = np.array([1, 1]) self.actor.geometry = plane_geometry(width=size[0], height=size[1]) self._update_actors_position()
def _update_actors_position(self): """Set the position of the internal actor.""" position = self.get_position(x_anchor=Anchor.CENTER, y_anchor=Anchor.CENTER) self.set_actor_position(self.actor, position, self.z_order) @property def color(self): """ Get the rectangle color. Returns ------- (float, float, float) RGB color. """ return self.actor.material.color[:3] @color.setter def color(self, color): """ Set the rectangle color. Parameters ---------- color : (float, float, float) RGB. Must take values in [0, 1]. """ self.actor.material.color = np.array([*color, 1.0]) @property def opacity(self): """ Get the rectangle opacity. Returns ------- float Opacity value. """ return self.actor.material.opacity @opacity.setter def opacity(self, opacity): """ Set the rectangle opacity. Parameters ---------- opacity : float Degree of transparency. Must be between [0, 1]. """ self.actor.material.opacity = opacity
[docs] class Disk2D(UI): """ A 2D disk UI component. Parameters ---------- outer_radius : int Outer radius of the disk. inner_radius : int, optional Inner radius of the disk. center : (float, float), optional Coordinates (x, y) of the center of the disk. color : (float, float, float), optional Must take values in [0, 1]. opacity : float, optional Must take values in [0, 1]. """
[docs] def __init__( self, outer_radius, *, inner_radius=0, center=(0, 0), color=(1, 1, 1), opacity=1.0, ): """Initialize a 2D Disk.""" self.actor = None self.inner_radius = inner_radius self.outer_radius = outer_radius super(Disk2D, self).__init__( position=center, x_anchor=Anchor.CENTER, y_anchor=Anchor.CENTER ) self.color = color self.opacity = opacity
def _setup(self): """ Set up this UI component. Create the disk actor used internally. """ positions, indices = prim_ring( inner_radius=self.inner_radius, outer_radius=self.outer_radius ) geo = buffer_to_geometry(positions=positions, indices=indices) mat = _create_mesh_material(material="basic", alpha_mode="auto") self.actor = create_mesh(geometry=geo, material=mat) self.handle_events(self.actor) def _get_actors(self): """ Get the actors composing this UI component. Returns ------- list List of actors composing this UI component. """ return [self.actor] def _get_size(self): """ Get the current size of the disk. Returns ------- (float, float) The current `(diameter, diameter)` of the disk in pixels. """ diameter = 2 * self.outer_radius size = (diameter, diameter) return size def _update_actors_position(self): """Set the position of the internal actor.""" position = self.get_position(x_anchor=Anchor.CENTER, y_anchor=Anchor.CENTER) self.set_actor_position(self.actor, position, self.z_order) @property def color(self): """ Get the color of this UI component. Returns ------- (float, float, float) RGB color. """ return self.actor.material.color[:3] @color.setter def color(self, color): """ Set the color of this UI component. Parameters ---------- color : (float, float, float) RGB. Must take values in [0, 1]. """ self.actor.material.color = np.array([*color, 1.0]) @property def opacity(self): """ Get the opacity of this UI component. Returns ------- float Opacity value. """ return self.actor.material.opacity @opacity.setter def opacity(self, opacity): """ Set the opacity of this UI component. Parameters ---------- opacity : float Degree of transparency. Must be between [0, 1]. """ self.actor.material.opacity = opacity @property def outer_radius(self): """ Get the outer radius of the disk. Returns ------- int Outer radius in pixels. """ return self._outer_radius @outer_radius.setter def outer_radius(self, radius): """ Set the outer radius of the disk. Parameters ---------- radius : int New outer radius. """ if self.actor: positions, indices = prim_ring( inner_radius=self.inner_radius, outer_radius=radius ) self.actor.geometry = buffer_to_geometry( positions=positions, indices=indices ) self._outer_radius = radius @property def inner_radius(self): """ Get the inner radius of the disk. Returns ------- int Inner radius in pixels. """ return self._inner_radius @inner_radius.setter def inner_radius(self, radius): """ Set the inner radius of the disk. Parameters ---------- radius : int New inner radius. """ if self.actor: positions, indices = prim_ring( inner_radius=radius, outer_radius=self.outer_radius ) self.actor.geometry = buffer_to_geometry( positions=positions, indices=indices ) self._inner_radius = radius
[docs] class TextBlock2D(UI): """ A 2D text component with optional background. Parameters ---------- text : str, optional The initial text message. font_size : int, optional Size of the text font. font_family : str, optional The font family name. justification : str, optional Horizontal alignment ("left", "center", "right"). vertical_justification : str, optional Vertical alignment ("top", "middle", "bottom"). bold : bool, optional If True, makes text bold. italic : bool, optional If True, makes text italicized. size : (int, int), optional The (width, height) in pixels for the text bounding box. color : (float, float, float), optional RGB color for the text (0-1). bg_color : (float, float, float), optional RGB color for the background (0-1). If None, no background is drawn. position : (float, float), optional Absolute coordinates (x, y) for placement. dynamic_bbox : bool, optional If True, resizes the bounding box to fit the content. """
[docs] def __init__( self, *, text="Text Block", font_size=18, font_family="Arial", justification="left", vertical_justification="top", bold=False, italic=False, size=None, color=(1, 1, 1), bg_color=None, position=(0, 0), dynamic_bbox=False, ): """Initialize the text block instance.""" self.boundingbox = [0, 0, 0, 0] self._message = text self._dynamic_bbox = dynamic_bbox self._bg_size = size self._last_rendered_size = (0, 0) if self._bg_size is None and not self.dynamic_bbox: raise ValueError("TextBlock size is required as it is not dynamic.") self._justification = justification self._vertical_justification = vertical_justification super(TextBlock2D, self).__init__(position=position) self.have_bg = bool(bg_color) self.color = color self.background_color = bg_color self.font_family = font_family self.bold = bold self.italic = italic self.message = text self.font_size = font_size self.update_bounding_box()
def _setup(self): """Set up this UI component.""" self.actor = Text( markdown=self._message, screen_space=True, anchor="middle-center" ) self.background = Rectangle2D() self._children.append(self.background) self.handle_events(self.actor)
[docs] def resize(self, size): """ Resize the TextBlock2D bounding box. Parameters ---------- size : (int, int) The new (width, height) in pixels. """ self.actor.max_width = size[1] self.update_bounding_box(size=size)
[docs] def update_layout(self): """Update the component layout based on current text dimensions.""" current_w, current_h = self.get_text_actor_size() last_w, last_h = self._last_rendered_size if abs(current_w - last_w) > 0.1 or abs(current_h - last_h) > 0.1: self._last_rendered_size = (current_w, current_h) if self.dynamic_bbox: self.update_bounding_box() else: self.update_alignment()
def _get_actors(self): """ Get the actors composing this UI component. Returns ------- list List containing the text actor and background actors. """ return [self.actor]
[docs] def get_formatted_text(self, text): """ Format the given text with markdown syntax for bold/italic styles. Parameters ---------- text : str The raw text to format. Returns ------- str The formatted markdown string. """ affix_char = "" if self.bold: affix_char = "**" elif self.italic: affix_char = "*" lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") formatted_lines = [f"{affix_char}{line}{affix_char}" for line in lines] return "\n".join(formatted_lines)
@property def message(self): """ Get the current text message. Returns ------- str The text message. """ return self._message @message.setter def message(self, text): """ Set the text message. Parameters ---------- text : str The message to display. """ self._message = text self.actor.set_markdown(self.get_formatted_text(text)) if self.dynamic_bbox: self.update_bounding_box() @property def font_size(self): """ Get text font size. Returns ------- int Text font size. """ return self.actor.font_size @font_size.setter def font_size(self, size): """ Set text font size. Parameters ---------- size : int Text font size. """ self.actor.font_size = size if self.dynamic_bbox: self.update_bounding_box() @property def font_family(self): """ Get font family. Returns ------- str Text font family. """ return self.actor.family @font_family.setter def font_family(self, family="Arial"): """ Set font family. Parameters ---------- family : str The font family. """ self.actor.family = family if self.dynamic_bbox: self.update_bounding_box() @property def justification(self): """ Get text justification. Returns ------- str Text justification. """ return self._justification @justification.setter def justification(self, justification): """ Justify text. Parameters ---------- justification : str Possible values are left, center, right. """ self._justification = justification self.update_alignment() @property def vertical_justification(self): """ Get text vertical justification. Returns ------- str Text vertical justification. """ return self._vertical_justification @vertical_justification.setter def vertical_justification(self, vertical_justification): """ Justify text vertically. Parameters ---------- vertical_justification : str Possible values are top, middle, bottom. """ self._vertical_justification = vertical_justification self.update_alignment() @property def bold(self): """ Return whether the text is bold. Returns ------- bool Text is bold if True. """ return self._bold @bold.setter def bold(self, flag): """ Bold/un-bold text. Parameters ---------- flag : bool Sets text bold if True. """ self._bold = flag @property def italic(self): """ Return whether the text is italicised. Returns ------- bool Text is italicised if True. """ return self._italic @italic.setter def italic(self, flag): """ Italicise/un-italicise text. Parameters ---------- flag : bool Italicises text if True. """ self._italic = flag @property def color(self): """ Get text color. Returns ------- (float, float, float) Returns text color in RGB. """ return self.actor.material.color[:3] @color.setter def color(self, color): """ Set text color. Parameters ---------- color : (float, float, float) RGB: Values must be between 0-1. """ if color is None: color = (1, 1, 1) self.actor.material.color = np.array([*color, 1.0]) @property def background_color(self): """ Get the background color. Returns ------- (float, float, float) or None The RGB color of the background, or None if no background exists. """ if not self.have_bg: return None return self.background.color @background_color.setter def background_color(self, color): """ Set the background color. Parameters ---------- color : (float, float, float) or None RGB values (0-1). If None, the background is removed. """ if color is None: # Remove background. self.have_bg = False self.background.set_visibility(False) else: self.have_bg = True self.background.set_visibility(True) self.background.color = color @property def dynamic_bbox(self): """ Check if the bounding box is dynamic. Returns ------- bool True if dynamic, False otherwise. """ return self._dynamic_bbox @dynamic_bbox.setter def dynamic_bbox(self, flag): """ Set the dynamic bounding box state. Parameters ---------- flag : bool If True, the bounding box resizes to content. """ self._dynamic_bbox = flag if flag: self.update_bounding_box()
[docs] def update_alignment(self): """Update the text actor alignment within the bounding box.""" updated_text_position = [0, 0] text_actor_size = self.get_text_actor_size() if self.justification.lower() == "left": self.actor.text_align = "left" updated_text_position[0] = self.boundingbox[0] + text_actor_size[0] // 2 elif self.justification.lower() == "center": self.actor.text_align = "center" updated_text_position[0] = ( self.boundingbox[0] + (self.boundingbox[2] - self.boundingbox[0]) // 2 ) elif self.justification.lower() == "right": self.actor.text_align = "right" updated_text_position[0] = self.boundingbox[2] - text_actor_size[0] // 2 else: msg = "Text can only be justified left, center and right." raise ValueError(msg) if self.vertical_justification.lower() == "top": updated_text_position[1] = self.boundingbox[1] + text_actor_size[1] // 2 elif self.vertical_justification.lower() == "middle": updated_text_position[1] = ( self.boundingbox[1] + (self.boundingbox[3] - self.boundingbox[1]) // 2 ) elif self.vertical_justification.lower() == "bottom": updated_text_position[1] = self.boundingbox[3] - text_actor_size[1] // 2 else: msg = "Vertical justification must be: top, middle or bottom." raise ValueError(msg) self.set_actor_position(self.actor, updated_text_position, self.z_order)
[docs] def update_bounding_box(self, *, size=None): """ Update the text bounding box and background. Parameters ---------- size : (int, int), optional If provided, uses this size. Otherwise, uses the current size. """ if size is None: size = self.size pos = self.get_position() self.boundingbox = [ pos[0], pos[1], pos[0] + size[0], pos[1] + size[1], ] self.background.resize(size) self._bg_size = size self.background.set_position(pos) self.update_alignment()
def _update_actors_position(self): """Update the position of the internal actors.""" self.update_bounding_box()
[docs] def get_text_actor_size(self): """ Get the rendered size of the text actor. Returns ------- (float, float) The (width, height) of the rendered text. """ return ( self.actor._aabb[1][0] - self.actor._aabb[0][0], self.actor._aabb[1][1] - self.actor._aabb[0][1], )
def _get_size(self): """ Get the size of the text block. Returns ------- (float, float) The current size of the text block. """ if self.dynamic_bbox: return self.get_text_actor_size() else: return self._bg_size
class Button2D(UI): """ Base class for interactive 2D Buttons. Parameters ---------- position : (float, float), optional Absolute coordinates (x, y) for placement. size : (int, int), optional Width and height in pixels. is_toggle : bool, optional If True, the button behaves as a toggle switch. """ def __init__(self, position=(0, 0), size=(30, 30), is_toggle=False): """Initialize the button instance.""" self._dims = size self.child = None self.is_toggle = is_toggle self.toggled = False super().__init__(position=position) self.is_hovered = False self.is_pressed = False self._enabled = True self.on_clicked = None self.on_hover = self._handle_hover self.on_dishover = self._handle_dishover self.on_left_mouse_button_pressed = self._handle_down self.on_left_mouse_button_released = self._handle_up self.resize(size) self.update_visual_state() @property def enabled(self): """ Check if the button is enabled. Returns ------- bool True if interactive, False otherwise. """ return self._enabled @enabled.setter def enabled(self, value): """ Set the button enabled state. Parameters ---------- value : bool True to enable, False to disable. """ self._enabled = bool(value) if not self._enabled: self.is_hovered = False self.is_pressed = False self.update_visual_state() @property def toggled(self): """ Check if the button is toggled. Returns ------- bool True if toggled, False otherwise. """ return self._toggled @toggled.setter def toggled(self, value): """ Set the button's toggled state. Parameters ---------- value : bool True to toggle, False otherwise. """ self._toggled = bool(value) self.update_visual_state() def _handle_hover(self, event): """ Handle the hover on the button area. Parameters ---------- event : PointerEvent The PyGfx pointer event. """ if not self._enabled: return self.is_hovered = True self.update_visual_state() def _handle_dishover(self, event): """ Handle the dishover on the button area. Parameters ---------- event : PointerEvent The PyGfx pointer event. """ if not self._enabled: return self.is_hovered = False self.is_pressed = False self.update_visual_state() def _handle_down(self, event): """ Handle the pointer being pressed down. Parameters ---------- event : PointerEvent The PyGfx pointer event. """ if not self._enabled: return self.is_pressed = True self.update_visual_state() def _handle_up(self, event): """ Handle the pointer being released. Triggers a click action if the release occurs while the button is in a pressed state. Parameters ---------- event : PointerEvent The PyGfx pointer event. """ if not self._enabled: return if self.is_pressed: self.do_click() self.is_pressed = False self.update_visual_state() def do_click(self): """Trigger the assigned click callback and handle toggle state.""" if self.is_toggle: self.toggled = not self.toggled if self.on_clicked: self.on_clicked(self) self.update_visual_state() def resolve_state_key(self, available_keys): """ Determine the current visual state key based on priority. The priority order is: 1. 'disabled' (if not enabled) 2. 'pressed' (if is_pressed or toggled is True) 3. 'hover' (if is_hovered) 4. 'default' Parameters ---------- available_keys : list or set The keys available in the subclass (e.g., in a color map). Returns ------- str The key representing the highest priority active state. """ if not self.enabled: return "disabled" if "disabled" in available_keys else "default" is_active = self.is_pressed or (self.is_toggle and self.toggled) if is_active and "pressed" in available_keys: return "pressed" if self.is_hovered and "hover" in available_keys: return "hover" return "default" def update_visual_state(self): """Update the visual appearance of the button.""" pass def _get_size(self): """ Get the current size of the button. Returns ------- (float, float) The current (width, height) of the button in pixels. """ if self.child and hasattr(self.child, "_get_size"): return self.child._get_size() return self._dims def resize(self, size): """ Resize the button and its child components. Parameters ---------- size : (float, float) New width and height in pixels. """ self._dims = size if self.child: if isinstance(self.child, UI): self.child.resize(size) elif hasattr(self.child, "geometry"): self.child.geometry = plane_geometry(width=size[0], height=size[1]) self._update_actors_position() def _update_actors_position(self): """Update the position of the internal actors.""" if self.child: if isinstance(self.child, UI): self.child.set_position(self.get_position()) else: pos = self.get_position(x_anchor=Anchor.CENTER, y_anchor=Anchor.CENTER) self.set_actor_position(self.child, pos, self.z_order) def _get_actors(self): """ Get the actors composing this UI component. Returns ------- list List containing the child actor(s). """ if self.child: if not isinstance(self.child, UI): return [self.child] else: self._children.append(self.child) return [] class Slider2D(UI): """ Base class for interactive 2D Sliders. Parameters ---------- position : (float, float), optional Absolute coordinates (x, y) for placement. initial_value : float, optional The starting value of the slider. min_value : float, optional The minimum value of the slider range. max_value : float, optional The maximum value of the slider range. handle_inner_radius : int, optional The inner radius for disk-shaped handles. handle_outer_radius : int, optional The outer radius for disk-shaped handles. handle_side : int, optional The side length for square-shaped handles. font_size : int, optional The font size for the value label. text_template : str or callable, optional A formatting string or callable for the label. shape : str, optional The handle shape: disk or square. z_order : int, optional The stacking priority. """ def __init__( self, *, position=(0, 0), initial_value=50, min_value=0, max_value=100, handle_inner_radius=0, handle_outer_radius=10, handle_side=20, font_size=16, text_template="{value:.1f} ({ratio:.0%})", shape="disk", z_order=0, ): """ Initialize the 2D slider. Parameters ---------- position : (float, float), optional Absolute coordinates (x, y) for placement. initial_value : float, optional The starting value of the slider. min_value : float, optional The minimum value of the slider range. max_value : float, optional The maximum value of the slider range. handle_inner_radius : int, optional The inner radius for disk-shaped handles. handle_outer_radius : int, optional The outer radius for disk-shaped handles. handle_side : int, optional The side length for square-shaped handles. font_size : int, optional The font size for the value label. text_template : str or callable, optional A formatting string or callable for the label. shape : str, optional The handle shape: disk or square. z_order : int, optional The stacking priority. """ self.default_color = (1, 1, 1) self.active_color = (0, 0, 1) self._value = initial_value range_val = max_value - min_value self._ratio = (initial_value - min_value) / range_val if range_val != 0 else 0 self._min_value = min_value self._max_value = max_value self.text_template = text_template self._handle_inner_radius = handle_inner_radius self._handle_outer_radius = handle_outer_radius self._handle_side = handle_side self._font_size = font_size self.shape = shape self.on_change = lambda ui: None self.on_value_changed = lambda ui: None self.on_moving_slider = lambda ui: None self.track = None self.handle = None self.text = None super().__init__(position=position, z_order=z_order) @property def value(self): """ Get the current numeric value of the slider. Returns ------- float The slider value. """ return self._value @value.setter def value(self, val): """ Set the slider numeric value. Parameters ---------- val : float New numeric value. Will be clamped to [min_value, max_value]. """ val = np.clip(val, self.min_value, self.max_value) self._value = val range_val = self.max_value - self.min_value self._ratio = (val - self.min_value) / range_val if range_val != 0 else 0 self._update_handle_position() self.on_value_changed(self) self.on_change(self) @property def ratio(self): """ Get the current normalized ratio (0 to 1). Returns ------- float The slider ratio. """ return self._ratio @ratio.setter def ratio(self, r): """ Set the slider ratio. Parameters ---------- r : float New ratio value. Will be clamped to [0, 1]. """ self._ratio = np.clip(r, 0, 1) self._value = self.min_value + self._ratio * (self.max_value - self.min_value) self._update_handle_position() self.on_change(self) @property def min_value(self): """ Get the minimum value of the slider. Returns ------- float The minimum value. """ return self._min_value @min_value.setter def min_value(self, val): """ Set the minimum value of the slider. Parameters ---------- val : float The minimum value. """ self._min_value = val self.value = self._value @property def max_value(self): """ Get the maximum value of the slider. Returns ------- float The maximum value. """ return self._max_value @max_value.setter def max_value(self, val): """ Set the maximum value of the slider. Parameters ---------- val : float The maximum value. """ self._max_value = val self.value = self._value def track_click_callback(self, event): """ Handle mouse click events on the slider track. Parameters ---------- event : PointerEvent The PyGfx pointer event. """ self.handle_move_callback(event) @abc.abstractmethod def handle_move_callback(self, event): """ Handle mouse drag events to update the slider state. Parameters ---------- event : PointerEvent The PyGfx pointer event. """ pass def handle_release_callback(self, event): """ Handle the release of the mouse button. Parameters ---------- event : PointerEvent The PyGfx pointer event. """ self.handle.color = self.default_color @abc.abstractmethod def _update_handle_position(self): """Update the position of the track and handle actors.""" pass def _get_actors(self): """ Get the actors composing this UI component. Returns ------- list Empty list, as child UI components are added directly when adding parent. """ return [] def format_text(self): """ Return formatted text to display along the slider. Returns ------- str The formatted text. """ if callable(self.text_template): return self.text_template(self) context = {"value": self.value, "ratio": self.ratio} if hasattr(self, "angle"): context["angle"] = np.rad2deg(self.angle) return self.text_template.format(**context) def _setup(self): """Set up the common slider components.""" if self.shape == "disk": self.handle = Disk2D( outer_radius=self._handle_outer_radius, inner_radius=self._handle_inner_radius, ) elif self.shape == "square": self.handle = Rectangle2D(size=(self._handle_side, self._handle_side)) else: raise ValueError("shape must be 'disk' or 'square'") self.handle.z_order = self.z_order + 1 self.text = TextBlock2D( justification="center", vertical_justification="middle", dynamic_bbox=True, font_size=self._font_size, ) self.text.z_order = self.z_order + 2 self._children.extend([self.handle, self.text])