# Copyright (c) 2024 Thomas Mathieson.
# Distributed under the terms of the MIT license.
import copy
import math
import numpy as np
import numpy.typing as npt
from typing import Callable, Optional, Union, Tuple, List, Dict
from enum import IntFlag, IntEnum
import logging
import sys
if sys.version_info >= (3, 10):
from typing import TypeAlias
else:
from typing_extensions import TypeAlias
from .ssv_canvas import SSVCanvas
from .ssv_render_buffer import SSVRenderBuffer
from .ssv_vertex_buffer import SSVVertexBuffer
from .ssv_colour import Colour
from . import ssv_colour
from .ssv_future import Reference
from .ssv_fonts import SSVFont, SSVCharacterDefinition, ssv_font_noto_sans_sb
from .ssv_callback_dispatcher import SSVCallbackDispatcher
from .ssv_logging import log
[docs]
class SSVGUIShaderMode(IntFlag):
"""
Represents the shader features needed to render a GUI element when using the built-in UI shader.
"""
SOLID = 0,
TRANSPARENT = 1,
TEXT = 2,
TEXTURE = 4,
SHADOWED = 8,
ROUNDING = 16,
OUTLINE = 32
[docs]
@staticmethod
def get_vertex_attributes(shader_mode: int) -> Tuple[str, ...]:
"""
Gets the tuple of vertex attribute names required to support this shader mode.
:param shader_mode:
:return:
"""
attributes = ["in_vert", "in_color"]
if SSVGUIShaderMode.TEXT & shader_mode > 0:
attributes.append("in_char")
if (SSVGUIShaderMode.ROUNDING | SSVGUIShaderMode.TEXTURE) & shader_mode > 0:
attributes.append("in_texcoord")
if SSVGUIShaderMode.ROUNDING & shader_mode > 0:
attributes.append("in_size")
return tuple(attributes)
[docs]
class TextAlign(IntEnum):
LEFT = 0,
CENTRE = 1,
CENTER = 1,
RIGHT = 2
[docs]
class Rect:
"""
Represents a 2D rectangle in pixel space.
"""
x: int
y: int
width: int
height: int
def __init__(self, x: int = 0, y: int = 0, width: int = 20, height: int = 20):
self.x = x
self.y = y
self.width = width
self.height = height
def __copy__(self):
return Rect(self.x, self.y, self.width, self.height)
def __str__(self):
return f"Rect[x: {self.x}, y: {self.y}, width: {self.width}, height: {self.height}]"
class CachedVertexArray:
"""
Stores an SSVVertexBuffer and a reusable numpy vertex array.
:meta private:
"""
vertex_buff: SSVVertexBuffer
v_array: npt.NDArray
used_space: int
SSVGUIDrawDelegate: TypeAlias = Callable[["SSVGUI", Rect], None]
"""
A delegate for a GUIElement draw function. It should follow the signature: ``draw(gui: SSVGUI, rect: Rect) -> None``
"""
SSVGUIPreLayoutDelegate: TypeAlias = Callable[["SSVGUI"], Tuple[int, int]]
"""
A delegate for a GUIElement pre layout function. It should follow the signature:
``draw(gui: SSVGUI) -> tuple[width: int, height: int]``
"""
[docs]
class SSVGUIElement:
"""
A class representing a single GUI element for use by the layout engine.
"""
draw_func: SSVGUIDrawDelegate
pre_layout_func: Optional[SSVGUIPreLayoutDelegate]
expand: bool
layout: bool
overlay_last: bool
control_width: int
control_height: int
def __init__(self, draw_func: SSVGUIDrawDelegate, control_width: int, control_height: int,
expand: bool, layout: bool, overlay_last: bool, pre_layout_func: Optional[SSVGUIPreLayoutDelegate]):
self.draw_func = draw_func
self.control_width = control_width
self.control_height = control_height
self.expand = expand
self.layout = layout
self.overlay_last = overlay_last
self.pre_layout_func = pre_layout_func
[docs]
class SSVGUILayoutContainer:
"""
A GUILayoutContainer stores a list gui elements (represented by their draw functions). It automatically lays out
all of its elements either vertically or horizontally when its ``draw()`` method is called. An
``SSVGUILayoutContainer`` can itself be put inside another layout container.
"""
def __init__(self, gui: "SSVGUI", vertical: bool = True,
enabled: Union[bool, Reference[bool]] = True,
squeeze: bool = False, pad: bool = False):
self.vertical = vertical
self._squeeze = squeeze
self._pad = pad
self._enabled = enabled
self._gui = gui
self._gui_elements: List[SSVGUIElement] = []
[docs]
def draw(self, gui: "SSVGUI", max_bounds: Rect):
"""
Lays out and draws all the elements within this container in the order they were added.
:param gui: the parent ``SSVGUI`` instance.
:param max_bounds: the rect representing the space to fit elements within.
"""
if isinstance(self._enabled, Reference):
if not self._enabled.result:
return
else:
if not self._enabled:
return
# log(f"Drawing layout group... bounds={max_bounds} vertical={self.vertical} lw:{gui.layout_control_width} lh:{gui.layout_control_height} elements={len(self._gui_elements)}", severity=logging.INFO)
# Apply padding if needed
px0, py0, px1, py1 = gui.padding
bounds_padded = copy.copy(max_bounds)
if self._pad:
Rect(max_bounds.x + px0, max_bounds.y + py0, max_bounds.width - px1, max_bounds.height - py1)
bound_dim = (bounds_padded.height if self.vertical else bounds_padded.width)
# Call all the pre-layout callbacks in this group
for el in self._gui_elements:
if el.pre_layout_func is not None:
el.control_width, el.control_height = el.pre_layout_func(gui)
# Work out if all the elements are going to fit inside this container with the layout width/height
layout_elements = [e for e in self._gui_elements if e.layout and not e.overlay_last]
free_space = bound_dim - sum([e.control_height if self.vertical else e.control_width for e in layout_elements])
if free_space >= 0:
# There's space to spare => expand any elements that requested it to fill the space
expanded_elements = [el for el in layout_elements if el.expand]
pre_exp_free_space = bound_dim - sum([el.control_height if self.vertical else el.control_width
for el in expanded_elements])
exp_dim = 0 if len(expanded_elements) == 0 else pre_exp_free_space / len(expanded_elements)
else:
# There's not enough space => share it evenly
exp_dim = bound_dim / len(layout_elements)
squeezing = self._squeeze and free_space < 0
x, y = float(bounds_padded.x), float(bounds_padded.y)
last_bounds = copy.copy(max_bounds)
for element in self._gui_elements:
# noinspection PyProtectedMember
gui._control_ind += 1
if not element.layout:
element.draw_func(gui, bounds_padded)
continue
if element.overlay_last:
element.draw_func(gui, last_bounds)
continue
# log(f" element expand={element.expand} cw={element.control_width} exp_dim={exp_dim} x={x} y={y}", severity=logging.INFO)
if self.vertical:
flex_dim = exp_dim if element.expand or squeezing else element.control_height
last_bounds = Rect(round(x), round(y), min(bounds_padded.width, element.control_width), round(flex_dim))
element.draw_func(gui, last_bounds)
y += flex_dim
else:
flex_dim = exp_dim if element.expand or squeezing else element.control_width
last_bounds = Rect(round(x), round(y), round(flex_dim), min(bounds_padded.height, element.control_height))
element.draw_func(gui, last_bounds)
x += flex_dim
@property
def min_width(self) -> int:
"""Measures the minimum width of a given layout group by recursively measuring all of its children."""
return sum([e.control_width if e.pre_layout_func is None else e.pre_layout_func(self._gui)[0]
for e in self._gui_elements if e.layout and not e.overlay_last])
@property
def min_height(self) -> int:
"""Measures the minimum height of a given layout group by recursively measuring all of its children."""
return sum([e.control_height if e.pre_layout_func is None else e.pre_layout_func(self._gui)[1]
for e in self._gui_elements if e.layout and not e.overlay_last])
[docs]
def add_element(self, draw_callback: SSVGUIDrawDelegate, control_width: int, control_height: int,
expand: bool = False, layout: bool = True, overlay_last: bool = False,
pre_layout_callback: Optional[SSVGUIPreLayoutDelegate] = None):
"""
Adds a GUI element to this Layout Container.
:param draw_callback: the draw function of the GUI element
:param control_width: the requested width of the control. The layout engine can give a larger width than this
if the ``expand`` option is enabled; if the layout group has ``squeeze`` enabled, the
actual width may be smaller than requested.
:param control_height: the requested height of the control. The layout engine can give a larger height than this
if the ``expand`` option is enabled; if the layout group has ``squeeze`` enabled, the
actual height may be smaller than requested.
:param expand: whether the element should attempt to fill all remaining space in the container. If multiple
elements have ``expand`` set, then the remaining space is shared. The element's minimum size is
still determined by the defined layout size.
:param layout: whether this element should participate in automatic layout. If disabled, the element doesn't
count towards layout calculations and is given the full ``Rect`` of the Layout Container. The
element will still be drawn in the order specified.
:param overlay_last: whether this element should be overlaid on top of the last element drawn.
:param pre_layout_callback: the callback is invoked just before the element is laid out, it's useful for Layout
Group elements which might not know their minimum size until just before layout.
"""
self._gui_elements.append(SSVGUIElement(draw_callback, control_width, control_height, expand, layout,
overlay_last, pre_layout_callback))
[docs]
class SSVGUI:
"""
An immediate mode GUI library for pySSV.
"""
def __init__(self, canvas: SSVCanvas, render_buffer: SSVRenderBuffer):
"""
Creates a new GUI and binds its event listeners to the given canvas.
:param canvas: the canvas to get events from.
:param render_buffer: the buffer to render into.
"""
self.canvas = canvas
self.render_buffer = render_buffer
self._on_gui_callback: SSVCallbackDispatcher[Callable[[SSVGUI], None]] = SSVCallbackDispatcher()
self._on_post_gui_callback: SSVCallbackDispatcher[Callable[[SSVGUI], None]] = SSVCallbackDispatcher()
self._resolution = render_buffer.size
self._vb_cache: Dict[int, CachedVertexArray] = {}
self._layout_groups: List[SSVGUILayoutContainer] = []
self._capturing_control_ind = -1
self._control_ind = 0
self._layout_control_height = 26
self._layout_control_width = 400
self._padding = (2, 2, 2, 2)
self._rounding_radius = 3.0
# TODO: Custom fonts support
self._font = ssv_font_noto_sans_sb
# TODO: Support multiple font textures at once
font_tex = self.canvas.get_texture("uFontTex")
if font_tex is not None:
font_tex.release()
font_tex = self.canvas.texture(self._font.bitmap, "uFontTex")
font_tex.linear_filtering = True # type: ignore
font_tex.linear_mipmap_filtering = True # type: ignore
self.canvas.preprocessor.global_defines["_USING_GUI"] = "1"
self._set_logging_stream = False
self._last_mouse_down = False
self.canvas.on_start(lambda: self._update_gui())
self.canvas.on_mouse_event(lambda x, y, z: self._update_gui(True))
self.canvas.on_keyboard_event(lambda x, y: self._update_gui())
def _update_gui(self, should_set_logging_stream: bool = False):
"""
This method is called whenever the GUI is invalidated. This is function is bound to the canvas' on_start,
on_mouse, etc... events.
:param should_set_logging_stream: a hack to force the logging stream to be directed to the canvas log.
"""
if should_set_logging_stream and not self._set_logging_stream:
# noinspection PyProtectedMember
self.canvas._set_logging_stream()
self._set_logging_stream = True
for v in self._vb_cache.values():
# Reset all the cached arrays
v.used_space = 0
self._layout_groups.clear()
# All gui elements live in one big vertical layout element
self._control_ind = 0
self.begin_vertical()
self._on_gui_callback(self)
self._layout_groups[0].draw(self, Rect(0, 0, self._resolution[0], self._resolution[1]))
# Update all the vertex buffers using the cached arrays
filter_cache = False
for k, v in self._vb_cache.items():
if v.used_space == 0:
# Destroy this vertex buffer, we can't render empty vertex buffers
v.vertex_buff.release()
filter_cache = True
else:
v.vertex_buff.update_vertex_buffer(v.v_array[:v.used_space], SSVGUIShaderMode.get_vertex_attributes(k))
# If the cache array has got much larger than the amount of used space, then trim it
if v.v_array.shape[0] > v.used_space * 2:
# log(f"Trimming v_array for type={SSVGUIShaderMode(k).name} "
# f"usage={v.used_space}/{v.v_array.shape[0]}...", severity=logging.INFO)
v.v_array = v.v_array[:v.used_space]
# Filter out any destroyed vertex buffers
if filter_cache:
self._vb_cache = {k: v for k, v in self._vb_cache.items() if v.vertex_buff.is_valid}
self._last_mouse_down = self.canvas.mouse_down[0]
self._on_post_gui_callback(self)
def _get_vertex_buffer(self, render_type: int, requested_space: int) -> npt.NDArray:
"""
Gets a vertex buffer array for the given render type to write new vertices into.
Creates a new vertex buffer and array for the render_type if one doesn't already exist in the cache.
:param render_type: an ``SSVGUIShaderMode`` with the type of shader needed.
:param requested_space: how many array items of space
:return: a slice of a vertex array to write into; all the requested space should be filled.
"""
if requested_space == 0:
return np.empty(0, dtype=np.float32)
if render_type in self._vb_cache:
cached = self._vb_cache[render_type]
# Expand the cached array if needed
if cached.v_array.shape[0] < cached.used_space + requested_space:
# log(f"Expanding v_array for type={SSVGUIShaderMode(render_type).name} "
# f"usage={cached.used_space}/{cached.v_array.shape[0]} "
# f"(needed={cached.used_space + requested_space})...",
# severity=logging.INFO)
# Get twice as much space as we need, there's a good chance more space will be requested soon...
cached.v_array = np.pad(cached.v_array, (0, requested_space*2), 'empty')
ret = cached.v_array[cached.used_space:cached.used_space + requested_space]
cached.used_space += requested_space
return ret
cached = CachedVertexArray()
cached.vertex_buff = self.render_buffer.vertex_buffer()
cached.v_array = np.empty(requested_space, dtype=np.float32)
cached.used_space = requested_space
self._vb_cache[render_type] = cached
options = []
if SSVGUIShaderMode.TRANSPARENT & render_type > 0:
options.append("--support_alpha")
if SSVGUIShaderMode.TEXT & render_type > 0:
options.append("--support_text")
if SSVGUIShaderMode.TEXTURE & render_type > 0:
options.append("--support_texture")
if SSVGUIShaderMode.SHADOWED & render_type > 0:
options.append("--support_shadow")
if SSVGUIShaderMode.ROUNDING & render_type > 0:
options.append("--support_rounding")
if SSVGUIShaderMode.OUTLINE & render_type > 0:
options.append("--support_outline")
cached.vertex_buff.shader(f"#pragma SSV ui {' '.join(options)}")
return cached.v_array
@property
def layout_control_height(self) -> int:
"""Gets or sets the default GUI element height."""
return self._layout_control_height
@layout_control_height.setter
def layout_control_height(self, value: int):
self._layout_control_height = value
@property
def layout_control_width(self) -> int:
"""Gets or sets the default GUI element width."""
return self._layout_control_width
@layout_control_width.setter
def layout_control_width(self, value: int):
self._layout_control_width = value
@property
def padding(self) -> Tuple[int, int, int, int]:
"""Gets or sets the amount of padding between GUI elements in pixels."""
return self._padding
@padding.setter
def padding(self, value: Tuple[int, int, int, int]):
self._padding = value
@property
def rounding_radius(self) -> float:
"""Gets or sets the default corner radius for GUI elements, in pixels."""
return self._rounding_radius
@rounding_radius.setter
def rounding_radius(self, value: float):
self._rounding_radius = value
@property
def _can_capture_mouse(self) -> bool:
"""Returns ``True`` if the current element can capture the mouse."""
return self._capturing_control_ind < 0 or self._capturing_control_ind == self._control_ind
@property
def _is_capturing(self) -> bool:
"""Returns ``True`` if the current element is capturing the mouse."""
return self._capturing_control_ind == self._control_ind
def _capture_mouse(self, release=False):
"""
Allows a GUI element to capture mouse until the cursor is released.
This also updates the relevant shader uniform which other shaders can use to know if the current mouse event
was consumed.
:param release: whether the mouse should be released from the capture instead.
"""
if self._can_capture_mouse:
if release:
self._capturing_control_ind = -1
self.canvas.update_uniform("uSSVGUI_isCapturingMouse", 0)
self.canvas.main_camera.inhibit = False
else:
self._capturing_control_ind = self._control_ind
self.canvas.update_uniform("uSSVGUI_isCapturingMouse", 1)
self.canvas.main_camera.inhibit = True
[docs]
def on_gui(self, callback: Callable[["SSVGUI"], None], remove: bool = False):
"""
Registers/unregisters a callback to this GUI's on_gui event which is called any time the GUI is invalidated
and needs to be redrawn.
All GUI drawing operations should occur within the callback registered here. Calling GUI drawing functions
outside of this callback results in undefined behaviour.
:param callback: the callback function to register to the on_gui event.
:param remove: whether the function passed in should be removed from the callback list.
"""
self._on_gui_callback.register_callback(callback, remove)
[docs]
def on_post_gui(self, callback: Callable[["SSVGUI"], None], remove: bool = False):
"""
Registers/unregisters a callback to this GUI's on_post_gui event which is called just after the GUI drawn.
GUI drawing operations are not permitted within this callback; but any ``Reference`` values returned by GUI
elements *will* have been updated by the time this callback is invoked.
:param callback: the callback function to register to the on_post_gui event.
:param remove: whether the function passed in should be removed from the callback list.
"""
self._on_post_gui_callback.register_callback(callback, remove)
[docs]
def begin_horizontal(self, width: Optional[int] = None, height: Optional[int] = None,
pad: bool = False, squeeze: bool = True):
"""
Starts a new horizontal layout group. All GUI elements created after this call will flow horizontally, left
to right until ``end_horizontal()`` is called.
:param width: optionally override the width of this layout group. Defaults to the current
``layout_control_width``.
:param height: optionally override the height of this layout group. Defaults to the current
``layout_control_height``.
:param pad: whether padding should be created between this layout group and the last GUI element.
:param squeeze: whether this layout group should attempt to squeeze the elements contained within if they would
have otherwise overflowed.
"""
layout = SSVGUILayoutContainer(self, False, True, squeeze, pad)
def pre_layout(gui: "SSVGUI") -> Tuple[int, int]:
return layout.min_width, self._layout_control_height if height is None else height
if len(self._layout_groups) > 0:
self._layout_groups[-1].add_element(layout.draw,
self._layout_control_width if width is None else width,
self._layout_control_height if height is None else height,
False, pre_layout_callback=pre_layout)
self._layout_groups.append(layout)
[docs]
def end_horizontal(self):
"""
Ends a horizontal layout group.
"""
if len(self._layout_groups) <= 1:
raise ValueError(
"Can't end base layout group! Did you call end_vertical()/end_horizontal()/end_...() too many times?")
layout = self._layout_groups.pop()
if layout.vertical:
raise ValueError("Current layout group is not a horizontal group!")
[docs]
def begin_vertical(self, width: Optional[int] = None, height: Optional[int] = None,
pad: bool = False, squeeze: bool = False):
"""
Starts a new vertical layout group. All GUI elements created after this call will flow vertically, top
to bottom until ``end_vertical()`` is called.
:param width: optionally override the width of this layout group. Defaults to the current
``layout_control_width``.
:param height: optionally override the height of this layout group. Defaults to the current
``layout_control_height``.
:param pad: whether padding should be created between this layout group and the last GUI element.
:param squeeze: whether this layout group should attempt to squeeze the elements contained within if they would
have otherwise overflowed.
"""
layout = SSVGUILayoutContainer(self, True, True, squeeze, pad)
def pre_layout(gui: "SSVGUI") -> Tuple[int, int]:
return self._layout_control_width if width is None else width, layout.min_height
if len(self._layout_groups) > 0:
self._layout_groups[-1].add_element(layout.draw,
self._layout_control_width if width is None else width,
self._layout_control_height if height is None else height,
False, pre_layout_callback=pre_layout)
self._layout_groups.append(layout)
[docs]
def end_vertical(self) -> SSVGUILayoutContainer:
"""
Ends a vertical layout group.
"""
if len(self._layout_groups) <= 1:
raise ValueError(
"Can't end base layout group! Did you call end_vertical()/end_horizontal()/end_...() too many times?")
layout = self._layout_groups.pop()
if not layout.vertical:
raise ValueError("Current layout group is not a vertical group!")
return layout
[docs]
def begin_toggle(self, enabled: Union[bool, Reference[bool]], width: Optional[int] = None,
height: Optional[int] = None,
pad: bool = False, squeeze: bool = False):
"""
Starts a new toggle layout group. GUI elements contained within this group can be shown or hidden using the
``enabled`` field. All GUI elements created after this call will flow vertically, top to bottom until
``end_toggle()`` is called.
:param enabled: a boolean or a reference to a boolean for whether the contents of this group should be shown.
:param width: optionally override the width of this layout group. Defaults to the current
``layout_control_width``.
:param height: optionally override the height of this layout group. Defaults to the current
``layout_control_height``.
:param pad: whether padding should be created between this layout group and the last GUI element.
:param squeeze: whether this layout group should attempt to squeeze the elements contained within if they would
have otherwise overflowed.
"""
# Dereferencing the 'enabled' value here means that it won't have been updated to the latest value from this
# GUI update yet, but doing so prevents layout issues due to a race condition.
_enabled = False
if isinstance(enabled, Reference):
_enabled = enabled.result
else:
_enabled = enabled
layout = SSVGUILayoutContainer(self, True, _enabled, squeeze, pad)
def pre_layout(gui: "SSVGUI") -> Tuple[int, int]:
if _enabled:
min_height = layout.min_height
else:
min_height = 0
return self._layout_control_width if width is None else width, min_height
if len(self._layout_groups) > 0:
self._layout_groups[-1].add_element(layout.draw,
self._layout_control_width if width is None else width,
self._layout_control_height if height is None else height,
False, pre_layout_callback=pre_layout)
self._layout_groups.append(layout)
[docs]
def end_toggle(self):
"""
Ends a toggle layout group.
"""
if len(self._layout_groups) <= 1:
raise ValueError(
"Can't end base layout group! Did you call end_vertical()/end_horizontal()/end_...() too many times?")
layout = self._layout_groups.pop()
if not layout.vertical:
raise ValueError("Current layout group is not a toggle group!")
def _get_rect_corners(self, bounds: Rect, local_rect: Optional[Rect]) -> Tuple[float, float, float, float]:
"""
Gets the coordinates of the bounding corners of a rect.
:param bounds: the bounds provided by the layout engine.
:param local_rect: optionally, a user provided rect which will be clipped with the layout engine's bounds.
:return: x0, x1, y0, y1
"""
x0 = bounds.x + self._padding[0]
x1 = bounds.x + bounds.width - self._padding[2]
y0 = bounds.y + self._padding[1]
y1 = bounds.y + bounds.height - self._padding[3]
if local_rect is not None:
x0 += local_rect.x
x1 = min(x1, x0 + local_rect.width - self._padding[2])
y0 += local_rect.y
y1 = min(y1, y0 + local_rect.height - self._padding[3])
return x0, x1, y0, y1
[docs]
def space(self, width: Optional[int] = None, height: Optional[int] = None):
"""
Creates a blank space element.
:param width: optionally, the width of the element.
:param height: optionally, the height of the element.
"""
self._layout_groups[-1].add_element(lambda x, y: None,
self._layout_control_width if width is None else width,
self._layout_control_height // 2 if height is None else height,
expand=False)
[docs]
def rect(self, colour: Colour, rect: Optional[Rect] = None, overlay_last: bool = False):
"""
Creates a rectangle GUI element.
:param colour: the colour of the rectangle.
:param rect: optionally, the absolute coordinates of the rectangle to draw. These will be clipped to fit
within the current layout group.
:param overlay_last: whether the layout engine should overlay this element onto the last drawn element.
"""
def draw(gui: SSVGUI, bounds: Rect):
render_mode = SSVGUIShaderMode.SOLID
if colour.a != 1:
render_mode = SSVGUIShaderMode.TRANSPARENT
verts = gui._get_vertex_buffer(render_mode, 6 * 6)
col = colour.astuple
# Generate vertices for a quad. The vertex attributes to fill are (vec2 pos, vec4 colour)
x0, x1, y0, y1 = gui._get_rect_corners(bounds, rect)
verts[:] = (x0, y0, *col,
x1, y0, *col,
x0, y1, *col,
x0, y1, *col,
x1, y0, *col,
x1, y1, *col)
# self._update_vertex_buffer(render_mode, verts)
self._layout_groups[-1].add_element(draw, self._layout_control_width, self._layout_control_height,
expand=False, layout=rect is None, overlay_last=overlay_last)
[docs]
def rounded_rect(self, colour: Colour, outline: bool = False, radius: Optional[float] = None,
rect: Optional[Rect] = None, overlay_last: bool = False):
"""
Creates a rounded rectangle GUI element.
:param colour: the colour of the rectangle.
:param outline: whether the rectangle should be outlined.
:param radius: the rounding radius in pixels. This can be set to an arbitrarily high number to create
circles/pills. Set to ``None`` to use the GUI's ``rounding_radius``.
:param rect: optionally, the absolute coordinates of the rectangle to draw. These will be clipped to fit
within the current layout group.
:param overlay_last: whether the layout engine should overlay this element onto the last drawn element.
"""
def draw(gui: SSVGUI, bounds: Rect):
render_mode = SSVGUIShaderMode.TRANSPARENT | SSVGUIShaderMode.ROUNDING
if outline:
render_mode |= SSVGUIShaderMode.OUTLINE
if radius is None:
_radius = gui._rounding_radius
else:
_radius = radius
verts = gui._get_vertex_buffer(render_mode, (2+4+2+2+1)*6)
col = colour.astuple
# Generate vertices for a quad. The vertex attributes to fill are (vec2 pos, vec4 colour,
# vec2 texcoord, vec2 size, float radius)
x0, x1, y0, y1 = gui._get_rect_corners(bounds, rect)
verts[:] = (x0, y0, *col, 0, 0, bounds.width, bounds.height, _radius,
x1, y0, *col, 1, 0, bounds.width, bounds.height, _radius,
x0, y1, *col, 0, 1, bounds.width, bounds.height, _radius,
x0, y1, *col, 0, 1, bounds.width, bounds.height, _radius,
x1, y0, *col, 1, 0, bounds.width, bounds.height, _radius,
x1, y1, *col, 1, 1, bounds.width, bounds.height, _radius)
# verts = np.concatenate((verts, n_verts), dtype=np.float32)
# self._update_vertex_buffer(render_mode, verts)
self._layout_groups[-1].add_element(draw, self._layout_control_width, self._layout_control_height,
expand=False, layout=rect is None, overlay_last=overlay_last)
def _draw_chars(self, char_defs: List[SSVCharacterDefinition], pos: Tuple[float, float],
font_tex_size: Tuple[int, int],
colour: Colour, scale: float, weight: float, shear_x: float, enforce_hinting: bool,
render_mode: SSVGUIShaderMode):
"""
Draws a string of characters to the GUI. This function expects that the text has already been transformed and
clipped as needed by the caller.
:param char_defs: a list of character definitions to draw from the font file.
:param pos: the position in screen-space to start drawing from. The first character's bottom-left corner is
placed at this position; subsequent characters are placed according to the font file.
:param font_tex_size: the size of the font bitmap in pixels. (width, height)
:param colour: the text colour.
:param scale: a float to scale the characters by. A value of 1 would draw the characters at the font file's
native size.
:param weight: the font weight modifier (0-1).
:param shear_x: the amount of horizontal shear to apply to characters in pixels.
:param enforce_hinting: whether positions should be rounded to pixels to help with hinting.
:param render_mode: the shader render mode flags for this text.
"""
verts = self._get_vertex_buffer(render_mode, (2 + 4 + 2 + 1) * 6 * len(char_defs))
col = colour.astuple
vert_ind = 0
font_width, font_height = font_tex_size[0], font_tex_size[1]
draw_x, draw_y = pos[0], pos[1]
if enforce_hinting:
draw_x, draw_y = round(draw_x), round(draw_y)
for char_def in char_defs:
# Compute the pixel space coordinates of the character quad
x0 = draw_x + char_def.x_offset * scale
x1 = draw_x + char_def.x_offset * scale + char_def.width * scale
y0 = draw_y + char_def.y_offset * scale
y1 = draw_y + char_def.y_offset * scale + char_def.height * scale
# Compute texture-space coordinates of the character
bm_x0 = char_def.x / font_width
bm_x1 = (char_def.x + char_def.width) / font_width
bm_y0 = char_def.y / font_height
bm_y1 = (char_def.y + char_def.height) / font_height
# Generate vertices for a quad. The vertex attributes to fill are (vec2 pos, vec4 colour, vec2 char,
# float weight)
verts[vert_ind:vert_ind + (2 + 4 + 2 + 1) * 6] = (
x0, y0, *col, bm_x0, bm_y0, 1. - weight,
x1, y0, *col, bm_x1, bm_y0, 1. - weight,
x0 + shear_x, y1, *col, bm_x0, bm_y1, 1. - weight,
x0 + shear_x, y1, *col, bm_x0, bm_y1, 1. - weight,
x1, y0, *col, bm_x1, bm_y0, 1. - weight,
x1 + shear_x, y1, *col, bm_x1, bm_y1, 1. - weight
)
vert_ind += (2 + 4 + 2 + 1) * 6
draw_x += char_def.x_advance * scale
if enforce_hinting:
draw_x = round(draw_x)
[docs]
def label(self, text: str, colour: Colour = ssv_colour.ui_text, font_size: Optional[float] = None,
x_offset: int = 0, weight: float = 0.5, italic: bool = False, shadow: bool = False,
align: TextAlign = TextAlign.LEFT, enforce_hinting: bool = True,
rect: Optional[Rect] = None, overlay_last: bool = False):
"""
Creates a label GUI element.
:param text: the text to display.
:param colour: the colour of the rectangle.
:param font_size: the font size in pt.
:param x_offset: how far to indent the text in pixels.
:param weight: the font weight [0-1], where 0.5 is the native font weight. The font renderer uses SDF fonts
which allows variable font weight rendering for free within certain limits (since this is only
an effect, at the extremes type quality is degraded).
:param italic: whether the text should be rendered in faux italic. This effect simply applies a shear
transformation to the rendered characters, so it will work on any font, but won't look as good
as a proper italic font.
:param shadow: whether the text should be rendered with a shadow. This incurs a very small extra rendering
cost, and tends to have visual artifacts when the font weight is high.
:param align: the horizontal alignment of the text.
:param enforce_hinting: this option applies rounding to the font size and position to force it to line up with
the pixel grid to improve sharpness. This is only effective if the font texture was
rendered with hinting enabled in the first place. This can result in aliasing when
animating font size/text position.
:param rect: optionally, the absolute coordinates of the rectangle to draw. These will be clipped to fit
within the current layout group.
:param overlay_last: whether the layout engine should overlay this element onto the last drawn element.
"""
def draw(gui: SSVGUI, bounds: Rect):
render_mode = SSVGUIShaderMode.TRANSPARENT | SSVGUIShaderMode.TEXT
if shadow:
render_mode |= SSVGUIShaderMode.SHADOWED
# Font sizing & positioning
_font_size = (font_size if font_size is not None else self._font.size)
if enforce_hinting:
_font_size = round(_font_size)
if font_size is not None:
scale = _font_size / self._font.size
else:
scale = 1
_weight = weight
shear_x = -0.2 * _font_size if italic else 0
bx, by, bwidth, bheight = (bounds.x + self._padding[0] + x_offset, bounds.y + self._padding[1],
bounds.width - self._padding[2], bounds.height + self._padding[3])
if rect is not None:
bx += rect.x
by += rect.y
bwidth = min(bx + rect.width - self._padding[2], bx + bounds.width) - bx
bheight = min(by + rect.height - self._padding[3], by + bounds.height) - by
draw_x = bx
# Centre on the y-axis, there's some janky tuning in here to make it behave
diff_y = (self._font.base_height + (self._font.base_height - self._font.size * 1.3)) * scale
draw_y = by + bheight / 2 - (min(diff_y, bheight) + diff_y) / 2
max_x = bx + bwidth
max_y = by + bheight
font_width, font_height = self._font.width, self._font.height
# if font_height*scale > bheight * 1.5:
# # Let's allow a little bit of overflow for now, until we have proper clipping
# return
# Align on the x-axis
if align == TextAlign.CENTER:
fulltext_width = sum(
[self._font.chars.get(char, self._font.chars[' ']).x_advance for char in text]) * scale
draw_x = round((draw_x + max_x - fulltext_width) / 2)
elif align == TextAlign.RIGHT:
fulltext_width = sum(
[self._font.chars.get(char, self._font.chars[' ']).x_advance for char in text]) * scale
draw_x = round(max_x - fulltext_width)
char_defs = [self._font.chars.get(char, self._font.chars[' ']) for char in text]
# Trim the chars to fit the bounds
trim_x = float(draw_x)
for i, c in enumerate(char_defs):
trim_x += c.x_advance * scale
if trim_x > max_x:
# This char won't fit...
char_defs = char_defs[:i]
break
# Now create the actual geometry for the text
gui._draw_chars(char_defs, (draw_x, draw_y), (font_width, font_height), colour, scale,
_weight, shear_x, enforce_hinting, render_mode)
self._layout_groups[-1].add_element(draw, self._layout_control_width, self._layout_control_height,
expand=False, layout=rect is None, overlay_last=overlay_last)
[docs]
def label_3d(self, text: str, pos: Tuple[float, float, float],
colour: Colour = ssv_colour.ui_text, font_size: Optional[float] = None,
weight: float = 0.5, italic: bool = False, shadow: bool = False,
align: TextAlign = TextAlign.CENTER, enforce_hinting: bool = True):
"""
Creates a label GUI element which is transformed in 3d space using the canvas's camera.
:param text: the text to display.
:param pos: the 3d position of the label.
:param colour: the colour of the rectangle.
:param font_size: the font size in pt.
:param weight: the font weight [0-1], where 0.5 is the native font weight. The font renderer uses SDF fonts
which allows variable font weight rendering for free within certain limits (since this is only
an effect, at the extremes type quality is degraded).
:param italic: whether the text should be rendered in faux italic. This effect simply applies a shear
transformation to the rendered characters, so it will work on any font, but won't look as good
as a proper italic font.
:param shadow: whether the text should be rendered with a shadow. This incurs a very small extra rendering
cost, and tends to have visual artifacts when the font weight is high.
:param align: the horizontal alignment of the text.
:param enforce_hinting: this option applies rounding to the font size and position to force it to line up with
the pixel grid to improve sharpness. This is only effective if the font texture was
rendered with hinting enabled in the first place. This can result in aliasing when
animating font size/text position.
"""
def draw(gui: SSVGUI, bounds: Rect):
render_mode = SSVGUIShaderMode.TRANSPARENT | SSVGUIShaderMode.TEXT
if shadow:
render_mode |= SSVGUIShaderMode.SHADOWED
# TODO: The camera view/projection matrix should be cached to avoid calculating it so often...
# pos_clip = gui.canvas.main_camera.projection_matrix @ gui.canvas.main_camera.view_matrix @ (*pos, 1.)
pos_clip = (*pos, 1.) @ gui.canvas.main_camera.view_matrix @ gui.canvas.main_camera.projection_matrix
pos_clip[0] /= pos_clip[2]
pos_clip[1] /= pos_clip[2]
# Clipping planes
if 0 > pos_clip[2] > 1:
return
screen_x = float((pos_clip[0]*0.5+0.5) * gui._resolution[0])
screen_y = float(((-pos_clip[1])*0.5+0.5) * gui._resolution[1])
# Font sizing & positioning
_font_size = (font_size if font_size is not None else self._font.size)
if enforce_hinting:
_font_size = round(_font_size)
if font_size is not None:
scale = _font_size / self._font.size
else:
scale = 1
_weight = weight
shear_x = -0.2 * _font_size if italic else 0
draw_x = screen_x
# Centre on the y-axis, there's some janky tuning in here to make it behave
diff_y = self._font.base_height * scale
draw_y = screen_y - diff_y
font_width, font_height = self._font.width, self._font.height
# Align on the x-axis
if align == TextAlign.CENTER:
fulltext_width = sum(
[self._font.chars.get(char, self._font.chars[' ']).x_advance for char in text]) * scale
draw_x -= fulltext_width / 2
elif align == TextAlign.RIGHT:
fulltext_width = sum(
[self._font.chars.get(char, self._font.chars[' ']).x_advance for char in text]) * scale
draw_x -= fulltext_width
char_defs = [self._font.chars.get(char, self._font.chars[' ']) for char in text]
# Trim the chars to fit the bounds
trim_x = draw_x
i_0 = 0
for i, c in enumerate(char_defs):
if trim_x > gui._resolution[0]:
# This char (and consequently all subsequent chars) is entirely off the right edge of the screen
char_defs = char_defs[:i]
break
trim_x += c.x_advance * scale
if trim_x < 0:
# This char is entirely off the left edge of the screen
i_0 = i
char_defs = char_defs[i_0:]
if len(char_defs) == 0:
return
# Now create the actual geometry for the text
gui._draw_chars(char_defs, (draw_x, draw_y), (font_width, font_height), colour, scale,
_weight, shear_x, enforce_hinting, render_mode)
self._layout_groups[-1].add_element(draw, self._layout_control_width, self._layout_control_height,
expand=False, layout=False, overlay_last=False)
[docs]
def slider(self, text: str, value: float, min_value: float = 0., max_value: float = 1., step_size: float = 0,
power: float = 1., colour: Optional[Colour] = None, track_thickness: float = 4,
rect: Optional[Rect] = None) -> Reference[float]:
"""
Creates a slider GUI element.
Since the actual drawing of GUI elements is deferred till after layout has been updated (which occurs just
after the on_gui event finishes), the updated value of this slider is not known when this method returns.
Wait until the ``on_post_gui`` event (or the start of the next ``on_gui``) event to get the new value of this
slider. Until then the value returned by the slider will be the value passed in to it.
:param text: the label of the slider.
:param value: the current value of the slider.
:param min_value: the minimum value of the slider.
:param max_value: the maximum value of the slider.
:param step_size: the step size to round the slider value to.
:param power: an exponent to raise the value of the slider to, useful for creating non-linear sliders.
:param colour: the colour of the rectangle.
:param track_thickness: the thickness of the slider track in pixels.
:param rect: optionally, the absolute coordinates of the rectangle to draw. These will be clipped to fit
within the current layout group.
:return: a reference to a float which will be set to the new value of the slider.
"""
res = value if isinstance(value, Reference) else Reference(value)
def draw(gui: SSVGUI, bounds: Rect):
render_mode = SSVGUIShaderMode.TRANSPARENT | SSVGUIShaderMode.ROUNDING | SSVGUIShaderMode.OUTLINE
verts = gui._get_vertex_buffer(render_mode, (2+4+2+2+1) * 6 * 2)
# Generate vertices for a quad. The vertex attributes to fill are (vec2 pos, vec4 colour,
# vec2 texcoord, vec2 size, float radius)
x0, x1, y0, y1 = gui._get_rect_corners(bounds, rect)
handle_thickness = max((y1 - y0) - 2, 2)
half_h_thick = handle_thickness / 2
hover = (x0 <= gui.canvas.mouse_pos[0] <= x1) and (y0 <= gui._resolution[1] - gui.canvas.mouse_pos[1] <= y1)
if gui._is_capturing:
click = gui.canvas.mouse_down[0]
gui._capture_mouse(not click)
elif gui._can_capture_mouse:
click = hover and gui.canvas.mouse_down[0]
if click:
gui._capture_mouse()
else:
hover = False
click = False
if isinstance(value, Reference):
pos = value.result
else:
pos = value
if click:
pos = (gui.canvas.mouse_pos[0] - x0 - half_h_thick) / (x1 - x0 - handle_thickness)
pos = min(max(pos, 0), 1)
pos = (pos * (max_value - min_value) + min_value)
if power != 1:
sign = pos
pos = math.copysign((abs(pos) ** power) / (max_value**power) * max_value, sign)
if step_size > 0:
pos = round(pos/step_size)*step_size
res.result = pos
if power != 1:
sign = pos
pos = math.copysign((abs(pos) * (max_value ** power) / max_value) ** (1/power), sign)
handle_x = (pos - min_value) / (max_value - min_value)
handle_x = handle_x * (x1 - x0 - handle_thickness) + x0 + half_h_thick
y_mid = (y0+y1)/2
tx0 = x0 + half_h_thick
tx1 = x1 - half_h_thick
ty0 = y_mid - track_thickness / 2
ty1 = y_mid + track_thickness / 2
hx0 = handle_x - half_h_thick
hx1 = handle_x + half_h_thick
hy0 = y_mid - half_h_thick
hy1 = y_mid + half_h_thick
if colour is None:
col_track = ssv_colour.ui_element_bg_click.astuple
if click:
col = ssv_colour.ui_element_bg_click.astuple
elif hover:
col = ssv_colour.ui_element_bg_hover.astuple
else:
col = ssv_colour.ui_element_bg.astuple
else:
colour_tinted = colour
col_track = (colour * 0.8).astuple
if click:
colour_tinted *= 0.8
elif hover:
colour_tinted *= 1.4
col = colour_tinted.astuple
# Track
verts[:] = (tx0, ty0, *col_track, 0, 0, bounds.width, track_thickness, 1.,
tx1, ty0, *col_track, 1, 0, bounds.width, track_thickness, 1.,
tx0, ty1, *col_track, 0, 1, bounds.width, track_thickness, 1.,
tx0, ty1, *col_track, 0, 1, bounds.width, track_thickness, 1.,
tx1, ty0, *col_track, 1, 0, bounds.width, track_thickness, 1.,
tx1, ty1, *col_track, 1, 1, bounds.width, track_thickness, 1.,
# Handle
hx0, hy0, *col, 0, 0, handle_thickness, handle_thickness, 10.,
hx1, hy0, *col, 1, 0, handle_thickness, handle_thickness, 10.,
hx0, hy1, *col, 0, 1, handle_thickness, handle_thickness, 10.,
hx0, hy1, *col, 0, 1, handle_thickness, handle_thickness, 10.,
hx1, hy0, *col, 1, 0, handle_thickness, handle_thickness, 10.,
hx1, hy1, *col, 1, 1, handle_thickness, handle_thickness, 10.)
# verts = np.concatenate((verts, n_verts), dtype=np.float32)
# self._update_vertex_buffer(render_mode, verts)
self.begin_horizontal(squeeze=True)
self._layout_groups[-1].add_element(draw, self._layout_control_height, self._layout_control_height,
expand=False, layout=rect is None)
self.label(text, ssv_colour.ui_text, x_offset=4, rect=rect, font_size=14, align=TextAlign.LEFT)
self.end_horizontal()
return res
[docs]
def checkbox(self, text: str, value: Union[bool, Reference[bool]], colour: Optional[Colour] = None,
radius: Optional[float] = None,
rect: Optional[Rect] = None) -> Reference[bool]:
"""
Creates a checkbox GUI element.
Since the actual drawing of GUI elements is deferred till after layout has been updated (which occurs just
after the on_gui event finishes), the updated value of this checkbox is not known when this method returns.
Wait until the ``on_post_gui`` event (or the start of the next ``on_gui``) event to get the new value of this
checkbox. Until then the value returned by the checkbox will be the value passed in to it.
:param text: the label of the checkbox.
:param value: whether the checkbox is currently checked.
:param colour: the colour of the checkbox.
:param radius: optionally, the corner radius of the checkbox.
:param rect: optionally, the absolute coordinates of the rectangle to draw. These will be clipped to fit
within the current layout group.
:return: a reference to a float which will be set to the new value of the checkbox.
"""
res = value if isinstance(value, Reference) else Reference(value)
# noinspection DuplicatedCode
def draw(gui: SSVGUI, bounds: Rect):
render_mode = SSVGUIShaderMode.TRANSPARENT | SSVGUIShaderMode.ROUNDING | SSVGUIShaderMode.OUTLINE
x0, x1, y0, y1 = gui._get_rect_corners(bounds, rect)
hover = (x0 <= gui.canvas.mouse_pos[0] <= x1) and (y0 <= gui._resolution[1] - gui.canvas.mouse_pos[1] <= y1)
if gui._is_capturing:
click = hover and gui.canvas.mouse_down[0]
gui._capture_mouse(not gui.canvas.mouse_down[0])
elif gui._can_capture_mouse:
click = hover and gui.canvas.mouse_down[0]
if click:
gui._capture_mouse()
else:
hover = False
click = False
if radius is None:
_radius = gui._rounding_radius
else:
_radius = radius
if isinstance(value, Reference):
checked = value.result
else:
checked = value
if click and click != self._last_mouse_down:
checked = not checked
res.result = checked
if colour is None:
if click or checked:
col = ssv_colour.ui_element_bg_click.astuple
elif hover:
col = ssv_colour.ui_element_bg_hover.astuple
else:
col = ssv_colour.ui_element_bg.astuple
else:
colour_tinted = colour
if click or checked:
colour_tinted *= 0.8
elif hover:
colour_tinted *= 1.4
col = colour_tinted.astuple
verts = gui._get_vertex_buffer(render_mode, (2+4+2+2+1)*(6*3 if checked else 6))
# Generate vertices for a quad. The vertex attributes to fill are (vec2 pos, vec4 colour,
# vec2 texcoord, vec2 size, float radius)
verts[:(2+4+2+2+1)*6] = (x0, y0, *col, 0, 0, bounds.width, bounds.height, _radius,
x1, y0, *col, 1, 0, bounds.width, bounds.height, _radius,
x0, y1, *col, 0, 1, bounds.width, bounds.height, _radius,
x0, y1, *col, 0, 1, bounds.width, bounds.height, _radius,
x1, y0, *col, 1, 0, bounds.width, bounds.height, _radius,
x1, y1, *col, 1, 1, bounds.width, bounds.height, _radius)
if checked:
check_col = ssv_colour.ui_element_bg_hover.astuple
verts[(2+4+2+2+1)*6:] = (
# \
x0, (y0 * .9 + y1 * .1), *check_col, 0, .1, bounds.width, bounds.height, _radius,
(x0 * .9 + x1 * .1), y0, *check_col, .1, 0, bounds.width, bounds.height, _radius,
(x0 * .1 + x1 * .9), y1, *check_col, .9, 1, bounds.width, bounds.height, _radius,
(x0 * .1 + x1 * .9), y1, *check_col, .9, 1, bounds.width, bounds.height, _radius,
(x0 * .9 + x1 * .1), y0, *check_col, .1, 0, bounds.width, bounds.height, _radius,
x1, (y0 * .1 + y1 * .9), *check_col, 1, .9, bounds.width, bounds.height, _radius,
# /
(x0 * .1 + x1 * .9), y0, *check_col, .9, 0, bounds.width, bounds.height, _radius,
x1, (y0 * .9 + y1 * .1), *check_col, 1, .1, bounds.width, bounds.height, _radius,
x0, (y0 * .1 + y1 * .9), *check_col, 0, .9, bounds.width, bounds.height, _radius,
x1, (y0 * .9 + y1 * .1), *check_col, 1, .1, bounds.width, bounds.height, _radius,
x0, (y0 * .1 + y1 * .9), *check_col, 0, .9, bounds.width, bounds.height, _radius,
(x0 * .9 + x1 * .1), y1, *check_col, .1, 1, bounds.width, bounds.height, _radius)
# verts = np.concatenate((verts, n_verts), dtype=np.float32)
# self._update_vertex_buffer(render_mode, verts)
self.begin_horizontal(squeeze=False)
self._layout_groups[-1].add_element(draw, self._layout_control_height, self._layout_control_height,
expand=False, layout=rect is None)
self.label(text, ssv_colour.ui_text, x_offset=4, rect=rect, font_size=14, align=TextAlign.LEFT)
self.end_horizontal()
return res
[docs]
def create_gui(canvas: SSVCanvas) -> SSVGUI:
"""
Creates a new full screen GUI and render buffer and binds it to the canvas (the render buffer's order defaults to
100).
:param canvas: the canvas to bind to.
:return: a new ``SSVGUI`` object
"""
rb = canvas.render_buffer(canvas.size, order=100)
# Empty the full screen vertex buffer so that it has no effect
rb.shader("""#pragma SSV full_screen_colour --colour "vec4(0.)" """)
vb = canvas.main_render_buffer.vertex_buffer()
# The default vertex array in a vertex buffer is the full screen vertex array, so no need to change it
# vb.update_vertex_buffer()
# Now render our GUI on top of the main render buffer
vb.shader(f"""
#pragma SSV pixel mainImage --z_value 0.01
vec4 mainImage(in vec2 fragCoord)
{{
// Compute UVs which place the UI at it's original resolution in the top left of the screen.
// this is needed to prevent UI stretching if the main render buffer is resized.
vec2 ui_size = vec2({rb.size[0]}., {rb.size[1]}.);
vec2 uv = clamp((fragCoord - vec2(0., uResolution.y-ui_size.y))/ui_size, 0., 1.);
vec4 col = texture({rb.render_buffer_name}, uv);
col.rgb /= col.a;
return col;
}}
""")
gui = SSVGUI(canvas, rb)
return gui