"""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 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_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])