Source code for arcade.gui.nine_patch

from array import array

import arcade
from arcade.gl import Buffer, BufferDescription, Geometry, Program
from arcade.math import Vec2
from arcade.texture_atlas.base import TextureAtlasBase
from arcade.types import Rect


[docs] class NinePatchTexture: """Keeps borders & corners at constant widths while stretching the middle. It can be used with new or existing :py:class:`~arcade.gui.UIWidget` subclasses wherever an ordinary :py:class:`arcade.Texture` is supported. This is useful for GUI elements which must grow or shrink while keeping their border decorations constant, such as dialog boxes or text boxes. The diagram below explains the stretching behavior of this class: * Numbered regions with arrows (``<--->``) stretch along the direction(s) of any arrows present * Bars (``|---|``) mark the distances specified by the border parameters (``left``, etc) .. code-block:: :caption: Stretch Axes & Border Parameters left right |------| |------| top +------+-----------------+------+ --- | (1) | (2) | (3) | | | | <-------------> | | | +------+-----------------+------+ --- | (4) | (5) ^ | (6) | | ^ | | | ^ | | | | | | | | | | | <------+------> | | | | | | | | | | | | | | | | | | v | v | v | +------+-----------------+------+ --- | (7) | (8) | (9) | | | | <-------------> | | | +------+-----------------+------+ --- bottom As the texture is stretched, the numbered slices of the texture behave as follows: * Areas ``(1)``, ``(3)``, ``(7)`` and ``(9)`` never stretch. * Area ``(5)`` stretches both horizontally and vertically. * Areas ``(2)`` and ``(8)`` only stretch horizontally. * Areas ``(4)`` and ``(6)`` only stretch vertically. Args: left: The width of the left border of the 9-patch (in pixels) right: The width of the right border of the 9-patch (in pixels) bottom: The height of the bottom border of the 9-patch (in pixels) top: The height of the top border of the 9-patch (in pixels) texture: The raw texture to use for the 9-patch atlas: Specify an atlas other than Arcade's default texture atlas """ def __init__( self, left: int, right: int, bottom: int, top: int, texture: arcade.Texture, *, atlas: TextureAtlasBase | None = None, ): self._initialized = False self._texture = texture self._custom_atlas = atlas self._geometry_cache: tuple[int, int, int, int, int, Rect] | None = None # pixel texture co-ordinate start and end of central box. self._left = left self._right = right self._bottom = bottom self._top = top self._check_sizes() # Created in _init_deferred self._buffer: Buffer self._program: Program self._geometry: Geometry self._ctx: arcade.ArcadeContext self._atlas: TextureAtlasBase try: self._init_deferred() except Exception: pass
[docs] def initialize(self) -> None: """ Manually initialize the NinePatchTexture if it was lazy loaded. This has no effect if the NinePatchTexture was already initialized. """ if not self._initialized: self._init_deferred()
@property def ctx(self) -> arcade.ArcadeContext: """The OpenGL context.""" if not self._initialized: raise RuntimeError("The NinePatchTexture has not been initialized") return self._ctx @property def texture(self) -> arcade.Texture: """Get or set the texture.""" return self._texture @texture.setter def texture(self, texture: arcade.Texture): self._texture = texture self._add_to_atlas(texture) @property def program(self) -> Program: """Get or set the shader program. Returns the default shader if no other shader is assigned. """ if not self._initialized: raise RuntimeError("The NinePatchTexture has not been initialized") return self._program @program.setter def program(self, program: Program): if not self._initialized: raise RuntimeError("The NinePatchTexture has not been initialized") self._program = program def _add_to_atlas(self, texture: arcade.Texture): """Internal method for setting the texture. It ensures the texture is added to the global atlas. """ if not self._atlas.has_texture(texture): self._atlas.add(texture) @property def left(self) -> int: """Get or set the left border of the 9-patch.""" return self._left @left.setter def left(self, left: int): self._left = left @property def right(self) -> int: """Get or set the right border of the 9-patch.""" return self._right @right.setter def right(self, right: int): self._right = right @property def bottom(self) -> int: """Get or set the bottom border of the 9-patch.""" return self._bottom @bottom.setter def bottom(self, bottom: int): self._bottom = bottom @property def top(self) -> int: """Get or set the top border of the 9-patch.""" return self._top @top.setter def top(self, top: int): self._top = top @property def size(self) -> tuple[int, int]: """The size of texture as a width, height tuple in pixels.""" return self.texture.size @property def width(self) -> int: """The width of the texture in pixels.""" return self.texture.width @property def height(self) -> int: """The height of the texture in pixels.""" return self.texture.height
[docs] def draw_rect( self, *, rect: arcade.types.Rect, pixelated: bool = True, blend: bool = True, **kwargs, ): """Draw the 9-patch texture with a specific size. Warning: This method assumes the passed dimensions are proper! Unexpected behavior may occur if you specify a size smaller than the total size of the border areas. Args: rect: Rectangle to draw the 9-patch texture in pixelated: Whether to draw with nearest neighbor interpolation """ if not self._initialized: self._init_deferred() self._create_geometry(rect) if blend: self._ctx.enable_only(self._ctx.BLEND) else: self._ctx.disable(self._ctx.BLEND) if pixelated: self._atlas.texture.filter = self._ctx.NEAREST, self._ctx.NEAREST else: self._atlas.texture.filter = self._ctx.LINEAR, self._ctx.LINEAR self._atlas.use_uv_texture(0) self._atlas.texture.use(1) self._geometry.render(self._program) if blend: self._ctx.disable(self._ctx.BLEND)
def _check_sizes(self): """Raise a ValueError if any dimension is invalid.""" # Sanity check values if self._left < 0: raise ValueError("Left border must be a positive integer") if self._right < 0: raise ValueError("Right border must be a positive integer") if self._bottom < 0: raise ValueError("Bottom border must be a positive integer") if self._top < 0: raise ValueError("Top border must be a positive integer") # Sanity check texture size if self._left + self._right > self._texture.width: raise ValueError("Left and right border must be smaller than texture width") if self._bottom + self._top > self._texture.height: raise ValueError("Bottom and top border must be smaller than texture height") def _init_deferred(self): """Deferred initialization when lazy loaded""" self._ctx = arcade.get_window().ctx # TODO: Cache in context? self._program = self._ctx.load_program( vertex_shader=":system:shaders/gui/nine_patch_vs.glsl", fragment_shader=":system:shaders/gui/nine_patch_fs.glsl", ) # Configure texture channels self._program.set_uniform_safe("uv_texture", 0) self._program["sprite_texture"] = 1 # 4 byte floats * 4 floats * 4 vertices * 9 patches self._buffer = self._ctx.buffer(reserve=576) # fmt: off self._ibo = self._ctx.buffer( data=array("i", [ # Triangulate the patches # First rot 0, 1, 2, 3, 1, 2, 4, 5, 6, 7, 5, 6, 8, 9, 10, 11, 9, 10, # Middle row 12, 13, 14, 15, 13, 14, 16, 17, 18, 19, 17, 18, 20, 21, 22, 23, 21, 22, # Bottom row 24, 25, 26, 27, 25, 26, 28, 29, 30, 31, 29, 30, 32, 33, 34, 35, 33, 34, ] ), ) # fmt: on self._geometry = self._ctx.geometry( content=[BufferDescription(self._buffer, "2f 2f", ["in_position", "in_uv"])], index_buffer=self._ibo, mode=self._ctx.TRIANGLES, index_element_size=4, ) # References for the texture self._atlas = self._custom_atlas or self._ctx.default_atlas self._add_to_atlas(self.texture) self._initialized = True def _create_geometry(self, rect: Rect): """Create vertices for the 9-patch texture.""" # NOTE: This was ported from glsl geometry shader to python # Simulate old uniforms cache_key = ( self._atlas.version, self._left, self._right, self._bottom, self._top, rect, ) if cache_key == self._geometry_cache: return self._geometry_cache = cache_key position = rect.bottom_left start = Vec2(self._left, self._bottom) end = Vec2(self.width - self._right, self.height - self._top) size = rect.size t_size = Vec2(*self._texture.size) atlas_size = Vec2(*self._atlas.size) # Patch points starting from upper left row by row p1 = position + Vec2(0.0, size.y) p2 = position + Vec2(start.x, size.y) p3 = position + Vec2(size.x - (t_size.x - end.x), size.y) p4 = position + Vec2(size.x, size.y) y = size.y - (t_size.y - end.y) p5 = position + Vec2(0.0, y) p6 = position + Vec2(start.x, y) p7 = position + Vec2(size.x - (t_size.x - end.x), y) p8 = position + Vec2(size.x, y) p9 = position + Vec2(0.0, start.y) p10 = position + Vec2(start.x, start.y) p11 = position + Vec2(size.x - (t_size.x - end.x), start.y) p12 = position + Vec2(size.x, start.y) p13 = position + Vec2(0.0, 0.0) p14 = position + Vec2(start.x, 0.0) p15 = position + Vec2(size.x - (t_size.x - end.x), 0.0) p16 = position + Vec2(size.x, 0.0) # <AtlasRegion # x=1 y=1 # width=100 height=100 # uvs=( # 0.001953125, 0.001953125, # 0.197265625, 0.001953125, # 0.001953125, 0.197265625, # 0.197265625, 0.197265625, # ) # Get texture coordinates # vec2 uv0, uv1, uv2, uv3 region = self._atlas.get_texture_region_info(self._texture.atlas_name) tex_coords = region.texture_coordinates uv0 = Vec2(tex_coords[0], tex_coords[1]) uv1 = Vec2(tex_coords[2], tex_coords[3]) uv2 = Vec2(tex_coords[4], tex_coords[5]) uv3 = Vec2(tex_coords[6], tex_coords[7]) # Local corner offsets in pixels left = start.x right = t_size.x - end.x top = t_size.y - end.y bottom = start.y # UV offsets to the inner rectangle in the patch # This is the global texture coordinate offset in the entire atlas c1 = Vec2(left, top) / atlas_size # Upper left corner c2 = Vec2(right, top) / atlas_size # Upper right corner c3 = Vec2(left, bottom) / atlas_size # Lower left corner c4 = Vec2(right, bottom) / atlas_size # Lower right corner # Texture coordinates for all the points in the patch t1 = uv0 t2 = uv0 + Vec2(c1.x, 0.0) t3 = uv1 - Vec2(c2.x, 0.0) t4 = uv1 t5 = uv0 + Vec2(0.0, c1.y) t6 = uv0 + c1 t7 = uv1 + Vec2(-c2.x, c2.y) t8 = uv1 + Vec2(0.0, c2.y) t9 = uv2 - Vec2(0.0, c3.y) t10 = uv2 + Vec2(c3.x, -c3.y) t11 = uv3 - c4 t12 = uv3 - Vec2(0.0, c4.y) t13 = uv2 t14 = uv2 + Vec2(c3.x, 0.0) t15 = uv3 - Vec2(c4.x, 0.0) t16 = uv3 # fmt: off primitives = [ # First row - two fixed corners + stretchy middle # Upper left corner. Fixed size. p1, t1, p5, t5, p2, t2, p6, t6, # Upper middle part stretches on x axis p2, t2, p6, t6, p3, t3, p7, t7, # Upper right corner. Fixed size p3, t3, p7, t7, p4, t4, p8, t8, # Middle row: Two stretchy sides + stretchy middle # left border sketching on y axis p5, t5, p9, t9, p6, t6, p10, t10, # Center stretchy area p6, t6, p10, t10, p7, t7, p11, t11, # Right border. Stenches on y axis p7, t7, p11, t11, p8, t8, p12, t12, # Bottom row: two fixed corners + stretchy middle # Lower left corner. Fixed size. p9, t9, p13, t13, p10, t10, p14, t14, # Lower middle part stretches on x axis p10, t10, p14, t14, p11, t11, p15, t15, # Lower right corner. Fixed size p11, t11, p15, t15, p12, t12, p16, t16, ] # fmt: on data = array("f", [coord for point in primitives for coord in point]) self._buffer.write(data.tobytes())