from __future__ import annotations
from typing import Optional
import pyglet
from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED
from pyglet.text.caret import Caret
from pyglet.text.document import AbstractDocument
from typing_extensions import Literal, override
import arcade
from arcade.gui.events import (
UIEvent,
UIMouseDragEvent,
UIMouseEvent,
UIMousePressEvent,
UIMouseScrollEvent,
UIOnChangeEvent,
UITextInputEvent,
UITextMotionEvent,
UITextMotionSelectEvent,
)
from arcade.gui.property import bind
from arcade.gui.surface import Surface
from arcade.gui.widgets import UIWidget
from arcade.gui.widgets.layout import UIAnchorLayout
from arcade.text import FontNameOrNames
from arcade.types import LBWH, RGBA255, Color, RGBOrA255
[docs]
class UILabel(UIWidget):
"""A simple text label. This widget is meant to display user instructions or
information. This label supports multiline text.
If you want to make a scrollable viewing text box, use a
:py:class:`~arcade.gui.UITextArea`.
By default, a label will fit its initial content. If the text is changed use
:py:meth:`~arcade.gui.UILabel.fit_content` to adjust the size.
If the text changes frequently, ensure to set a background color or texture, which will
prevent a full rendering of the whole UI and only render the label itself.
Args:
text: Text displayed on the label.
x: x position (default anchor is bottom-left).
y: y position (default anchor is bottom-left).
width: Width of the label. Defaults to text width if not
specified. See
:py:meth:`~pyglet.text.layout.TextLayout.content_width`.
height: Height of the label. Defaults to text height if not
specified. See
:py:meth:`~pyglet.text.layout.TextLayout.content_height`.
font_name: A list of fonts to use. Arcade will start at the
beginning of the tuple and keep trying to load fonts until
success.
font_size: Font size of font.
text_color: Color of the text.
bold: If enabled, the label's text will be in a **bold** style.
italic: If enabled, the label's text will be in an *italic*
style.
stretch: Stretch font style.
align: Horizontal alignment of text on a line. This only applies
if a width is supplied. Valid options include ``"left"``,
``"center"`` or ``"right"``.
dpi: Resolution of the fonts in the layout. Defaults to 96.
multiline: If enabled, a ``\\n`` will start a new line. Changing
text or font will require a manual call of
:py:meth:`~arcade.gui.UILabel.fit_content` to prevent text
line wrap.
size_hint: A tuple of floats between 0 and 1 defining the amount
of space of the parent should be requested. Default (0, 0)
which fits the content.
size_hint_max: Maximum size hint width and height in pixel.
"""
ADAPTIVE_MULTILINE_WIDTH = 999999
def __init__(
self,
text: str = "",
*,
x: float = 0,
y: float = 0,
width: Optional[float] = None,
height: Optional[float] = None,
font_name=("calibri", "arial"),
font_size: float = 12,
text_color: RGBOrA255 = arcade.color.WHITE,
bold=False,
italic=False,
align="left",
multiline: bool = False,
size_hint=(0, 0),
size_hint_max=None,
**kwargs,
):
# If multiline is enabled and no width is given, we need to fit the
# size to the text. This is done by setting the width to a very
# large value and then fitting the size.
adaptive_multiline = False
if multiline and not width:
width = self.ADAPTIVE_MULTILINE_WIDTH
adaptive_multiline = True
# Use Arcade Text wrapper of pyglet.Label for text rendering
self._label = arcade.Text(
x=0,
y=0,
text=text,
font_name=font_name,
font_size=font_size,
color=text_color,
width=int(width) if width else None,
bold=bold,
italic=italic,
align=align,
anchor_y="bottom", # Position text bottom left to fit into scissor area
multiline=multiline,
**kwargs,
)
if adaptive_multiline:
# +1 is required to prevent line wrap
width = self._label.content_width + 1
super().__init__(
x=x,
y=y,
width=width or self._label.content_width,
height=height or self._label.content_height,
size_hint=size_hint,
size_hint_max=size_hint_max,
**kwargs,
)
# Set the label size. If the width or height was given because border
# and padding can only be applied later, we can avoid ``fit_content``
# and set with and height separately.
if width:
self._label.width = int(width)
if height:
self._label.height = int(height)
bind(self, "rect", self._update_label)
# update size hint when border or padding changes
bind(self, "_border_width", self._update_size_hint_min)
bind(self, "_padding_left", self._update_size_hint_min)
bind(self, "_padding_right", self._update_size_hint_min)
bind(self, "_padding_top", self._update_size_hint_min)
bind(self, "_padding_bottom", self._update_size_hint_min)
self._update_size_hint_min()
[docs]
def fit_content(self):
"""Manually set the width and height of the label to contain the whole text.
Based on the size_hint_min.
If multiline is enabled, the width will be calculated based on longest line of the text.
And size_hint_min will be updated.
"""
if self.multiline:
self._label.width = self.ADAPTIVE_MULTILINE_WIDTH
self._update_size_hint_min()
min_width, min_height = self.size_hint_min or (1, 1)
self.rect = self.rect.resize(
width=min_width,
height=min_height,
)
# rect changes to trigger resizing of the _label automatically
@property
def text(self):
"""Text of the label."""
return self._label.text
@text.setter
def text(self, value):
"""Update text of the label.
This triggers a full render to ensure that previous text is cleared out.
"""
if self._label.text != value:
self._label.text = value
self._update_size_hint_min()
if self._bg_color or self._bg_tex:
self.trigger_render()
else:
self.trigger_full_render()
def _update_label(self):
"""Update the position and size of the label.
So it fits into the content area of the widget.
Should always be called after the content area changed.
"""
# Update Pyglet label size
label = self._label
layout_size = label.width, label.height
if layout_size != self.content_size or label.position != (0, 0):
label.position = 0, 0, 0 # label always drawn in scissor box
label.width = int(self.content_width)
label.height = int(self.content_height)
def _update_size_hint_min(self):
"""Update the minimum size hint based on the label content size."""
min_width = self._label.content_width + 1 # +1 required to prevent line wrap
min_width += self._padding_left + self._padding_right + 2 * self._border_width
min_height = self._label.content_height
min_height += self._padding_top + self._padding_bottom + 2 * self._border_width
self.size_hint_min = (min_width, min_height)
[docs]
def update_font(
self,
font_name: Optional[FontNameOrNames] = None,
font_size: Optional[float] = None,
font_color: Optional[Color] = None,
bold: Optional[bool | str] = None,
italic: Optional[bool] = None,
):
"""Update font of the label.
Args:
font_name: A list of fonts to use. Arcade will start at the
beginning of the tuple and keep trying to load fonts until
success.
font_size: Font size of font.
font_color: Color of the text.
bold: If enabled, the label's text will be in a **bold** style.
italic: If enabled, the label's text will be in an *italic*
"""
font_name = font_name or self._label.font_name
font_size = font_size or self._label.font_size
font_color = font_color or self._label.color
font_bold = bold if bold is not None else self._label.bold
font_italic = italic if italic is not None else self._label.italic
# Check if values actually changed, if then update and trigger render
font_name_changed = self._label.font_name != font_name
font_size_changed = self._label.font_size != font_size
font_color_changed = self._label.color != font_color
font_bold_changed = self._label.bold != font_bold
font_italic_changed = self._label.italic != font_italic
if (
font_name_changed
or font_size_changed
or font_color_changed
or font_bold_changed
or font_italic_changed
):
with self._label:
self._label.font_name = font_name
self._label.font_size = font_size
self._label.color = font_color
self._label.bold = font_bold
self._label.italic = font_italic
self._update_size_hint_min()
# Optimised render behaviour
if self._bg_color or self._bg_tex:
self.trigger_render()
else:
self.trigger_full_render()
@property
def multiline(self) -> bool:
"""Return if the label is in multiline mode."""
return self._label.multiline
[docs]
def do_render(self, surface: Surface):
"""Render the label via py:class:`~arcade.Text`."""
self.prepare_render(surface)
# pyglet rendering automatically applied by arcade.Text
self._label.draw()
[docs]
class UITextWidget(UIAnchorLayout):
"""Adds the ability to add text to a widget.
Use this to create subclass widgets, which have text.
The text can be placed within the widget using
:py:class:`~arcade.gui.UIAnchorLayout` parameters with
:py:meth:`~arcade.gui.UITextWidget.place_text`.
The widget holds reference to one primary :py:class:`~arcade.gui.UILabel`, which is placed in
the widget's layout. This label can be accessed
via :py:attr:`~arcade.gui.UITextWidget.ui_label`.
To change font, font size, or text color, use py:meth:`~arcade.gui.UILabel.update_font`.
Args:
text: Text displayed on the label.
multiline: If enabled, a ``\\n`` will start a new line.
**kwargs: passed to :py:class:`~arcade.gui.UIWidget`.
"""
def __init__(self, *, text: str, multiline: bool = False, **kwargs):
super().__init__(text=text, **kwargs)
self._restrict_child_size = True
self._label = UILabel(
text=text, multiline=multiline
) # UILabel supports width=None for multiline
self.add(self._label)
[docs]
def place_text(
self,
anchor_x: Optional[str] = None,
align_x: float = 0,
anchor_y: Optional[str] = None,
align_y: float = 0,
**kwargs,
) -> UILabel:
"""Place widget's text within the widget using
:py:class:`~arcade.gui.UIAnchorLayout` parameters.
Args:
anchor_x: Horizontal anchor. Valid options are ``left``,
``right``, and ``center``.
align_x: Offset or padding for the horizontal anchor.
anchor_y: Vertical anchor. Valid options are ``top``,
``center``, and ``bottom``.
align_y: Offset or padding for the vertical anchor.
**kwargs: Additional keyword arguments passed to the layout function.
"""
self.remove(self._label)
return self.add(
child=self._label,
anchor_x=anchor_x,
align_x=align_x,
anchor_y=anchor_y,
align_y=align_y,
**kwargs,
)
@property
def text(self):
"""Text of the widget. Modifying this repeatedly will cause significant
lag; calculating glyph position is very expensive.
"""
return self.ui_label.text
@text.setter
def text(self, value):
self.ui_label.text = value
self.trigger_render()
@property
def multiline(self):
"""Get or set the multiline mode.
Newline characters (``"\\n"``) will only be honored when this is set to ``True``.
If you want a scrollable text widget, please use :py:class:`~arcade.gui.UITextArea`
instead.
"""
return self.ui_label.multiline
@property
def ui_label(self) -> UILabel:
"""Internal py:class:`~arcade.gui.UILabel` used for rendering the text."""
return self._label
[docs]
class UIInputText(UIWidget):
"""An input field the user can type text into.
This is useful in returning
string input from the user. A caret is displayed, which the user can move
around with a mouse or keyboard.
A mouse drag selects text, a mouse press moves the caret, and keys can move
around the caret. Arcade confirms that the field is active before allowing
users to type, so it is okay to have multiple of these.
By default, a border is drawn around the input field.
The widget emits a :py:class:`~arcade.gui.UIOnChangeEvent` event when the text changes.
Args:
x: x position (default anchor is bottom-left).
y: y position (default anchor is bottom-left).
width: Width of the text field.
height: Height of the text field.
text: Initial text displayed. This can be modified later
programmatically or by the user's interaction with the
caret.
font_name: A list of fonts to use. Arcade will start at the
beginning of the tuple and keep trying to load fonts until
success.
font_size: Font size of font.
text_color: Color of the text.
multiline: If enabled, a ``\\n`` will start a new line. A
:py:class:`~arcade.gui.UITextWidget` ``multiline`` of True
is the same thing as a :py:class:`~arcade.gui.UITextArea`.
caret_color: An RGBA or RGB color for the caret with each
channel between 0 and 255, inclusive.
border_color: An RGBA or RGB color for the border with each
channel between 0 and 255, inclusive, can be None to remove border.
border_width: Width of the border in pixels.
size_hint: A tuple of floats between 0 and 1 defining the amount
of space of the parent should be requested.
size_hint_min: Minimum size hint width and height in pixel.
size_hint_max: Maximum size hint width and height in pixel.
**kwargs: passed to :py:class:`~arcade.gui.UIWidget`.
"""
# Move layout one pixel into the scissor box so the caret is also shown at
# position 0.
LAYOUT_OFFSET = 1
def __init__(
self,
*,
x: float = 0,
y: float = 0,
width: float = 100,
height: float = 23, # required height for font size 12 + border width 1
text: str = "",
font_name=("Arial",),
font_size: float = 12,
text_color: RGBOrA255 = arcade.color.WHITE,
multiline=False,
caret_color: RGBOrA255 = arcade.color.WHITE,
border_color: Color | None = arcade.color.WHITE,
border_width: int = 2,
size_hint=None,
size_hint_min=None,
size_hint_max=None,
**kwargs,
):
super().__init__(
x=x,
y=y,
width=width,
height=height,
size_hint=size_hint,
size_hint_min=size_hint_min,
size_hint_max=size_hint_max,
**kwargs,
)
self.with_border(color=border_color, width=border_width)
self._active = False
self._text_color = Color.from_iterable(text_color)
self.doc: AbstractDocument = pyglet.text.decode_text(text)
self.doc.set_style(
0,
len(text),
dict(font_name=font_name, font_size=font_size, color=self._text_color),
)
self.layout = pyglet.text.layout.IncrementalTextLayout(
self.doc,
x=0 + self.LAYOUT_OFFSET,
y=0,
z=0.0, # Position
width=int(width - self.LAYOUT_OFFSET),
height=int(height), # Size
multiline=multiline,
)
self.caret = Caret(self.layout, color=Color.from_iterable(caret_color))
self.caret.visible = False
self._blink_state = self._get_caret_blink_state()
self.register_event_type("on_change")
def _get_caret_blink_state(self):
"""Check whether or not the caret is currently blinking or not."""
return self.caret.visible and self._active and self.caret._blink_visible
[docs]
@override
def on_update(self, dt):
"""Update the caret blink state."""
# Only trigger render if blinking state changed
current_state = self._get_caret_blink_state()
if self._blink_state != current_state:
self._blink_state = current_state
self.trigger_full_render()
[docs]
@override
def on_event(self, event: UIEvent) -> Optional[bool]:
"""Handle events for the text input field.
Text input is only active when the user clicks on the input field."""
# If not active, check to activate, return
if not self._active and isinstance(event, UIMousePressEvent):
if self.rect.point_in_rect(event.pos):
self.activate()
# return unhandled to allow other widgets to deactivate
return EVENT_UNHANDLED
# If active check to deactivate
if self._active and isinstance(event, UIMousePressEvent):
if self.rect.point_in_rect(event.pos):
x = int(event.x - self.left - self.LAYOUT_OFFSET)
y = int(event.y - self.bottom)
self.caret.on_mouse_press(x, y, event.button, event.modifiers)
else:
self.deactivate()
# return unhandled to allow other widgets to activate
return EVENT_UNHANDLED
# If active pass all non press events to caret
if self._active:
old_text = self.text
# Act on events if active
if isinstance(event, UITextInputEvent):
self.caret.on_text(event.text)
self.trigger_full_render()
elif isinstance(event, UITextMotionEvent):
self.caret.on_text_motion(event.motion)
self.trigger_full_render()
elif isinstance(event, UITextMotionSelectEvent):
self.caret.on_text_motion_select(event.selection)
self.trigger_full_render()
if isinstance(event, UIMouseEvent) and self.rect.point_in_rect(event.pos):
x = int(event.x - self.left - self.LAYOUT_OFFSET)
y = int(event.y - self.bottom)
if isinstance(event, UIMouseDragEvent):
self.caret.on_mouse_drag(
x, y, event.dx, event.dy, event.buttons, event.modifiers
)
self.trigger_full_render()
elif isinstance(event, UIMouseScrollEvent):
self.caret.on_mouse_scroll(x, y, event.scroll_x, event.scroll_y)
self.trigger_full_render()
if old_text != self.text:
self.dispatch_event("on_change", UIOnChangeEvent(self, old_text, self.text))
if super().on_event(event):
return EVENT_HANDLED
return EVENT_UNHANDLED
@property
def active(self) -> bool:
"""Return if the text input field is active.
An active text input field will show a caret and accept text input."""
return self._active
[docs]
def activate(self):
"""Programmatically activate the text input field."""
self._active = True
self.trigger_full_render()
self.caret.on_activate()
self.caret.position = len(self.doc.text)
[docs]
def deactivate(self):
"""Programmatically deactivate the text input field."""
self._active = False
self.trigger_full_render()
self.caret.on_deactivate()
def _update_layout(self):
# Update Pyglet layout size
layout = self.layout
layout_size = layout.width - self.LAYOUT_OFFSET, layout.height
if layout_size != self.content_size:
layout.begin_update()
layout.width = int(self.content_width - self.LAYOUT_OFFSET)
layout.height = int(self.content_height)
# should not be required, but the caret does not show up on first click without text
layout.x = self.LAYOUT_OFFSET
layout.y = 0
layout.end_update()
@property
def text(self):
"""Text of the input field."""
return self.doc.text
@text.setter
def text(self, value):
if value != self.doc.text:
old_text = self.doc.text
self.doc.text = value
self.dispatch_event("on_change", UIOnChangeEvent(self, old_text, self.text))
# if bg color or texture is set, render this widget only
if self._bg_color or self._bg_tex:
self.trigger_render()
else:
self.trigger_full_render()
[docs]
@override
def do_render(self, surface: Surface):
"""Render the text input field."""
self._update_layout()
self.prepare_render(surface)
self.layout.draw()
[docs]
def on_change(self, event: UIOnChangeEvent):
"""Event handler for text change."""
pass
[docs]
class UITextArea(UIWidget):
"""A text area that allows users to view large documents of text by scrolling
the mouse.
Args:
x: x position (default anchor is bottom-left).
y: y position (default anchor is bottom-left).
width: Width of the text area.
height: Height of the text area.
text: Initial text displayed.
font_name: A list of fonts to use. Arcade will start at the
beginning of the tuple and keep trying to load fonts until
success.
font_size: Font size of font.
text_color: Color of the text.
multiline: If enabled, a ``\\n`` will start a new line.
scroll_speed: Speed of mouse scrolling.
size_hint: A tuple of floats between 0 and 1 defining the amount
of space of the parent should be requested.
size_hint_min: Minimum size hint width and height in pixel.
size_hint_max: Maximum size hint width and height in pixel.
document_mode: Mode of the document. Can be "PLAIN", "ATTRIBUTED", or "HTML".
PLAIN will decode the text as plain text, ATTRIBUTED and HTML will
decode the text as pyglet documents here
https://pyglet.readthedocs.io/en/latest/programming_guide/text.html
**kwargs: passed to :py:class:`~arcade.gui.UIWidget`.
"""
def __init__(
self,
*,
x: float = 0,
y: float = 0,
width: float = 400,
height: float = 40,
text: str = "",
font_name=("arial", "calibri"),
font_size: float = 12,
text_color: RGBA255 = arcade.color.WHITE,
multiline: bool = True,
scroll_speed: Optional[float] = None,
size_hint=None,
size_hint_min=None,
size_hint_max=None,
document_mode: Literal["PLAIN", "ATTRIBUTED", "HTML"] = "PLAIN",
**kwargs,
):
super().__init__(
x=x,
y=y,
width=width,
height=height,
size_hint=size_hint,
size_hint_min=size_hint_min,
size_hint_max=size_hint_max,
**kwargs,
)
# Set how fast the mouse scroll wheel will scroll text in the pane.
# Measured in pixels per 'click'
self.scroll_speed = scroll_speed if scroll_speed is not None else font_size
self.doc: AbstractDocument
if document_mode == "PLAIN":
self.doc = pyglet.text.decode_text(text)
elif document_mode == "ATTRIBUTED":
self.doc = pyglet.text.decode_attributed(text)
elif document_mode == "HTML":
self.doc = pyglet.text.decode_html(text)
self.doc.set_style(
0,
len(text),
dict(
font_name=font_name,
font_size=font_size,
color=Color.from_iterable(text_color),
),
)
self.layout = pyglet.text.layout.ScrollableTextLayout(
self.doc,
width=int(self.content_width),
height=int(self.content_height),
multiline=multiline,
)
# bind(self, "rect", self._update_layout)
[docs]
def fit_content(self):
"""Set the width and height of the text area to contain the whole text."""
self.rect = LBWH(
self.left,
self.bottom,
self.layout.content_width,
self.layout.content_height,
)
@property
def text(self):
"""Text of the text area."""
return self.doc.text
@text.setter
def text(self, value):
self.doc.text = value
self.trigger_render()
def _update_layout(self):
# Update Pyglet layout size
layout = self.layout
# Convert from local float coords to ints to avoid jitter
# since pyglet imposes int-only coordinates as of pyglet 2.0
content_width, content_height = map(int, self.content_size)
if content_width != layout.width or content_height != layout.height:
layout.begin_update()
layout.width = content_width
layout.height = content_height
layout.end_update()
[docs]
@override
def do_render(self, surface: Surface):
"""Render the text area."""
self._update_layout()
self.prepare_render(surface)
self.layout.draw()
[docs]
@override
def on_event(self, event: UIEvent) -> Optional[bool]:
"""Handle scrolling of the widget."""
if isinstance(event, UIMouseScrollEvent):
if self.rect.point_in_rect(event.pos):
self.layout.view_y = round(self.layout.view_y + event.scroll_y * self.scroll_speed)
self.trigger_full_render()
if super().on_event(event):
return EVENT_HANDLED
return EVENT_UNHANDLED