Source code for pyparadigm.surface_composition

"""Easy Image Composition

The purpose of this module is to make it easy to compose the 
frames that are displayed in a paradigm. For an introduction, please refer to
the :ref:`tutorial<creating_surfaces>`
"""
from functools import wraps, lru_cache
from itertools import accumulate, chain

import contextlib
with contextlib.redirect_stdout(None):
    import pygame
    import pygame.ftfont

from ._primitives import PPError

_lmap = wraps(map)(lambda *args, **kwargs:list(map(*args, **kwargs)))

def _wrap_surface(elem):
    return Surface()(elem) if type(elem) == pygame.Surface else elem
    
def _round_to_int(val): 
    return int(round(val))

def _call_function(elem):
    return elem() if callable(elem) else elem


def _inner_func_anot(func):
    """must be applied to all inner functions that return contexts.
    
    Wraps all instances of pygame.Surface in the input in Surface"""
    @wraps(func)
    def new_func(*args):
        return func(*_lmap(_wrap_surface, args))
    return new_func


def _wrap_children(children):
    try:
        return [_wrap_surface(c) for c in children]
    except TypeError:
        return _wrap_surface(children)


def _check_call_op(child): 
    if child is not None:
        raise RuntimeError("Call operator was called twice")


[docs]class LLItem: """Defines the relative size of an element in a LinLayout All Elements that are passed to a linear layout are automatically wrapped into an LLItem with relative_size=1. Therefore by default all elements within a layout will be of the same size. To change the proportions a LLItem can be used explicitely with another relative size. It is also possible to use an LLItem as placeholde in a layout, to generate an empty space like this: :Example: LinLayout("h")( LLItem(1), LLItem(1)(Circle(0xFFFF00))) """ def __init__(self, relative_size): self.child = Surface() self.relative_size = relative_size def __call__(self, child): if child: self.child = _wrap_surface(child) return self def __repr__(self): return "LLItem({})({})".format(self.relative_size, repr(self.child))
[docs]class LinLayout: """A linear layout to order items horizontally or vertically. Every element in the layout is automatically wrapped within a LLItem with relative_size=1, i.e. all elements get assigned an equal amount of space, to change that elements can be wrappend in LLItems manually to get desired proportions :param orientation: orientation of the layout, either 'v' for vertica, or 'h' for horizontal. :type orientation: str """ def __init__(self, orientation): assert orientation in ["v", "h"] self.orientation = orientation self.children = None def __call__(self, *children): if len(children) == 0: raise PPError("You tried to add no children to layout") _check_call_op(self.children) self.children = _lmap(lambda child: child if type(child) == LLItem else LLItem(1)(child), _wrap_children(children)) return self def _draw(self, surface, target_rect): child_rects = self._compute_child_rects(target_rect) for child, rect in zip(self.children, child_rects): child.child._draw(surface, rect) def _compute_child_rects(self, target_rect): def flip_if_not_horizontal(t): return t if self.orientation == "h" else (t[1], t[0]) target_rect_size = target_rect.size sum_child_weights = sum(child.relative_size for child in self.children) if sum_child_weights == 0: raise PPError("LinLayout Children all have weight 0: " + repr(self.children)) divider, full = flip_if_not_horizontal(target_rect_size) dyn_size_per_unit = divider / sum_child_weights strides = [child.relative_size * dyn_size_per_unit for child in self.children] dyn_offsets = [0] + list(accumulate(strides))[:-1] left_offsets, top_offsets = flip_if_not_horizontal((dyn_offsets, [0] * len(self.children))) widths, heights = flip_if_not_horizontal((strides, [full] * len(self.children))) return [pygame.Rect(target_rect.left + left_offset, target_rect.top + top_offset, w, h) for left_offset, top_offset, w, h in zip(left_offsets, top_offsets, widths, heights)]
[docs]class FRect: """A wrapper Item for children of the FreeFloatLayout, see description of FreeFloatLayout""" def __init__(self, x, y, w, h): for coord in (x, y, w, h): assert FRect.coord_valid(coord) self.x = x self.y = y self.w = w self.h = h self.child = None @staticmethod def coord_valid(x): return type(x) is int or (type(x) == float and 0 <= x <= 1) @staticmethod def adjust_coord(x, abs_partner): if type(x) == int: if x >= 0: return x else: return abs_partner + x elif type(x) == float: return x * abs_partner # this code should never be reached assert False def to_abs_rect(self, target_rect): tmp = pygame.Rect( FRect.adjust_coord(self.x, target_rect.w), FRect.adjust_coord(self.y, target_rect.h), FRect.adjust_coord(self.w, target_rect.w), FRect.adjust_coord(self.h, target_rect.h)) return tmp.move(target_rect.topleft) def __call__(self, child): if child: self.child = _wrap_surface(child) return self
[docs]class FreeFloatLayout: """A "Layout" that allows for free positioning of its elements. All children must be Wrapped in an FRect, which takes a rects arguments (x, y, w, h), and determines the childs rect. All values can either be floats, and must then be between 0 and 1 and are relative to the rect-size of the layout, positive integers, in which case the values are interpreded as pixel offsets from the layout rect origin, or negative integers, in which case the absolute value is the available width or height minus the value""" def __init__(self) -> None: self.children = None def __call__(self, *children): if len(children) == 0: raise PPError("You tried to add no children to layout") _check_call_op(self.children) for child in children: if type(child) != FRect: raise PPError("All children of a FreeFloatLayout must be wrapped in an FRect") self.children = children return self def _draw(self, surface, target_rect): for child in self.children: rect = child.to_abs_rect(target_rect) if child.child is None: raise ValueError("There is an FRect without child") child.child._draw(surface, rect)
[docs]class Margin: """Defines the relative position of an item within a Surface. For details see Surface. """ __slots__ = ["left", "right", "top", "bottom"] def __init__(self, left=1, right=1, top=1, bottom=1): self.left=left self.right=right self.top=top self.bottom=bottom
def _offset_by_margins(space, one, two): return space * one / (one + two)
[docs]class Surface: """Wraps a pygame surface. The Surface is the connection between the absolute world of pygame.Surfaces and the relative world of the composition functions. A pygame.Surfaces can be bigger than the space that is available to the Surface, or smaller. The Surface does the actual blitting, and determines the concrete position, and if necessary (or desired) scales the input surface. Warning: When images are scaled with smoothing, colors will change decently, which makes it inappropriate to use in combination with colorkeys. :param margin: used to determine the exact location of the pygame.Surfaces within the available space. The margin value represents the proportion of the free space, along an axis, i.e. Margin(1, 1, 1, 1) is centered, Margin(0, 1, 1, 2) is as far left as possible and one/third on the way down. :type margin: Margin object :param scale: If 0 < scale <= 1 the longer side of the surface is scaled to to the given fraction of the available space, the aspect ratio is will be preserved. If scale is 0 the will be no scaling if the image is smaller than the available space. It will still be scaled down if it is too big. :type scale: float :param smooth: if True the result of the scaling will be smoothed :type smooth: float """ def __init__(self, margin=Margin(1, 1, 1, 1), scale=0, smooth=True, keep_aspect_ratio=True): assert 0 <= scale <= 1 self.child = None self.margin = margin self.scale = scale self.smooth = smooth self.keep_aspect_ratio = keep_aspect_ratio def __call__(self, child): _check_call_op(self.child) self.child = child return self @staticmethod def _scale_to_target(source, target_size, smooth=False): return pygame.transform.scale(source, target_size) if not smooth\ else pygame.transform.smoothscale(source, target_size) @staticmethod def _determine_target_size(child, target_rect, scale, keep_aspect_ratio): if scale > 0: scaled_target_rect = tuple(dist * scale for dist in target_rect) if keep_aspect_ratio: return child.get_rect().fit(scaled_target_rect).size else: return scaled_target_rect[2:4] elif all(s_dim <= t_dim for s_dim, t_dim in zip(child.get_size(), target_rect.size)): return child.get_size() else: return target_rect.size def compute_render_rect(self, target_rect): target_size = Surface._determine_target_size( self.child, target_rect, self.scale, self.keep_aspect_ratio) remaining_h_space = target_rect.w - target_size[0] remaining_v_space = target_rect.h - target_size[1] return pygame.Rect( (target_rect.left + _offset_by_margins(remaining_h_space, self.margin.left, self.margin.right), target_rect.top + _offset_by_margins(remaining_v_space, self.margin.top, self.margin.bottom)), target_size) def _draw(self, surface, target_rect): if self.child is None: return render_rect = self.compute_render_rect(target_rect) if render_rect.size == self.child.get_size(): content = self.child else: content = Surface._scale_to_target( self.child, render_rect.size, self.smooth) surface.blit(content, render_rect)
[docs]class Padding: """Pads a child element Each argument refers to a percentage of the axis it belongs to. A padding of (0.25, 0.25, 0.25, 0.25) would generate blocked area a quater of the available height in size above and below the child, and a quarter of the available width left and right of the child. If left and right or top and bottom sum up to one that would mean no space for the child is remaining """ def _draw(self, surface, target_rect): assert self.child is not None child_rect = pygame.Rect( target_rect.left + target_rect.w * self.left, target_rect.top + target_rect.h * self.top, target_rect.w * (1 - self.left - self.right), target_rect.h * (1 - self.top - self.bottom) ) self.child._draw(surface, child_rect) def __init__(self, left, right, top, bottom): assert all(0 <= side < 1 for side in [left, right, top, bottom]) assert left + right < 1 assert top + bottom < 1 self.left = left self.right = right self.top = top self.bottom = bottom self.child = None def __call__(self, child): _check_call_op(self.child) self.child = _wrap_surface(child) return self
[docs] @staticmethod def from_scale(scale_w, scale_h=None): """Creates a padding by the remaining space after scaling the content. E.g. Padding.from_scale(0.5) would produce Padding(0.25, 0.25, 0.25, 0.25) and Padding.from_scale(0.5, 1) would produce Padding(0.25, 0.25, 0, 0) because the content would not be scaled (since scale_h=1) and therefore there would be no vertical padding. If scale_h is not specified scale_h=scale_w is used as default :param scale_w: horizontal scaling factors :type scale_w: float :param scale_h: vertical scaling factor :type scale_h: float """ if not scale_h: scale_h = scale_w w_padding = [(1 - scale_w) * 0.5] * 2 h_padding = [(1 - scale_h) * 0.5] * 2 return Padding(*w_padding, *h_padding)
[docs]class RectangleShaper: """Creates a padding, defined by a target Shape. Width and height are the relative proportions of the target rectangle. E.g RectangleShaper(1, 1) would create a square. and RectangleShaper(2, 1) would create a rectangle which is twice as wide as it is high. The rectangle always has the maximal possible size within the parent area. """ def __init__(self, width=1, height=1): self.child = None self.width = width self.height = height def __call__(self, child): _check_call_op(self.child) self.child = _wrap_surface(child) return self def _draw(self, surface, target_rect): parent_w_factor = target_rect.w / target_rect.h my_w_factor = self.width / self.height if parent_w_factor > my_w_factor: my_h = target_rect.h my_w = my_h * my_w_factor my_h_offset = 0 my_w_offset = _round_to_int((target_rect.w - my_w) * 0.5) else: my_w = target_rect.w my_h = my_w / self.width * self.height my_w_offset = 0 my_h_offset = _round_to_int((target_rect.h - my_h) * 0.5) self.child._draw(surface, pygame.Rect( target_rect.left + my_w_offset, target_rect.top + my_h_offset, my_w, my_h ))
[docs]class Circle: """Draws a Circle in the assigned space. The circle will always be centered, and the radius will be half of the shorter side of the assigned space. :param color: The color of the circle :type color: pygame.Color or int :param width: width of the circle (in pixels). If 0 the circle will be filled :type width: int """ def __init__(self, color, width=0): self.color = color self.width = width def _draw(self, surface, target_rect): pygame.draw.circle(surface, self.color, target_rect.center, int(round(min(target_rect.w, target_rect.h) * 0.5)), self.width)
[docs]class Fill: """Fills the assigned area. Afterwards, the children are rendered :param color: the color with which the area is filled :type color: pygame.Color or int """ def __init__(self, color): self.color = color self.child = None def __call__(self, child): _check_call_op(self.child) self.child = _wrap_surface(child) return self def _draw(self, surface, target_rect): surface.fill(self.color, target_rect) if self.child: self.child._draw(surface, target_rect)
[docs]class Overlay: """Draws all its children on top of each other in the same rect""" def __init__(self, *children): self.children = _wrap_children(children) def _draw(self, surface, target_rect): for child in self.children: child._draw(surface, target_rect)
[docs]def Cross(width=3, color=0): """Draws a cross centered in the target area :param width: width of the lines of the cross in pixels :type width: int :param color: color of the lines of the cross :type color: pygame.Color """ return Overlay(Line("h", width, color), Line("v", width, color))
[docs]class Border: """Draws a border around the contained area. Can have a single child. :param width: width of the border in pixels :type width: int :param color: color of the border :type color: pygame.Color """ def __init__(self, width=3, color=0): v_line = Line("v", width, color) h_line = Line("h", width, color) self.child_was_added = False self.overlay = Overlay( LinLayout("h")( LLItem(0)(v_line), LLItem(1), LLItem(0)(v_line) ), LinLayout("v")( LLItem(0)(h_line), LLItem(1), LLItem(0)(h_line) ) ) def __call__(self, child): _check_call_op(None if not self.child_was_added else 1) self.overlay.children.append(_wrap_surface(child)) return self def _draw(self, surface, target_rect): self.overlay._draw(surface, target_rect)
[docs]class Line: """Draws a line. :param width: width of the line in pixels :type widht: int :param orientation: "v" or "h". Indicates whether the line should be horizontal or vertical. :type orientation: str """ def __init__(self, orientation, width=3, color=0): assert orientation in ["h", "v"] assert width > 0 self.orientation = orientation self.width = width self.color = color def _draw(self, surface, target_rect): if self.orientation == "h": pygame.draw.line(surface, self.color, ( target_rect.left, _round_to_int(target_rect.top + target_rect.h * 0.5)), ( target_rect.left + target_rect.w - 1, _round_to_int(target_rect.top + target_rect.h * 0.5)), self.width) else: pygame.draw.line(surface, self.color, ( _round_to_int(target_rect.left + target_rect.width * 0.5), target_rect.top), ( _round_to_int(target_rect.left + target_rect.width * 0.5), target_rect.top + target_rect.h - 1), self.width)
def _fill_col(target_len): return lambda col: col + [None] * (target_len - len(col)) def _interleave_with_lines(line, contents): ll = [LLItem(0)(line)] return chain(*zip(ll * len(contents), contents), ll) def _to_h_layout(cols, line_width, color): def inner_wrap(children): contents = [it(child) for it, child in zip( map(LLItem, cols), map(_wrap_surface, children))] return LinLayout("h")(*(contents if line_width == 0 else _interleave_with_lines(Line("v", line_width, color), contents))) return inner_wrap
[docs]def GridLayout(row_proportions=None, col_proportions=None, line_width=0, color=0): """Layout that arranges its children on a grid. Proportions are given as lists of integers, where the nth element represents the proportion of the nth row or column. Children are added in lists, every list represents one row, if row or column proportions are provided, the number of rows or columns in the children must match the provided proportions. To define an empty cell use None as child. If no column proportions are provided, rows can have different lengths. In this case the width of the layout will be the length of the longest row, and the other rows will be filled with Nones""" def inner_grid_layout(*children): nonlocal row_proportions, col_proportions assert all(type(child) == list for child in children) if row_proportions is None: row_proportions = [1] * len(children) else: assert len(row_proportions) == len(children) col_width = max(map(len, children)) if col_proportions: assert len(col_proportions) == col_width else: col_proportions = [1] * col_width filled_cols = _lmap(_fill_col(col_width), children) llitems = map(LLItem, row_proportions) mapped_rows = map(_to_h_layout(col_proportions, line_width, color), filled_cols) contents = [it(child) for it, child in zip(llitems, mapped_rows)] return LinLayout("v")(*_interleave_with_lines( Line("h", line_width, color), contents) if line_width else contents) return inner_grid_layout
[docs]def compose(target, root=None): """Top level function to create a surface. :param target: the pygame.Surface to blit on. Or a (width, height) tuple in which case a new surface will be created :type target: - """ if type(root) == Surface: raise ValueError("A Surface may not be used as root, please add " +"it as a single child i.e. compose(...)(Surface(...))") @_inner_func_anot def inner_compose(*children): if root: root_context = root(*children) else: assert len(children) == 1 root_context = children[0] if type(target) == pygame.Surface: surface = target size = target.get_size() else: size = target surface = pygame.Surface(size) root_context._draw(surface, pygame.Rect(0, 0, *size)) return surface return inner_compose
[docs]@lru_cache(128) def Font(name=None, source="sys", italic=False, bold=False, size=20): """Unifies loading of fonts. :param name: name of system-font or filepath, if None is passed the default system-font is loaded :type name: str :param source: "sys" for system font, or "file" to load a file :type source: str """ assert source in ["sys", "file"] if not name: return pygame.font.SysFont(pygame.font.get_default_font(), size, bold=bold, italic=italic) if source == "sys": return pygame.font.SysFont(name, size, bold=bold, italic=italic) else: f = pygame.font.Font(name, size) f.set_italic(italic) f.set_bold(bold) return f
def _text(text, font, color=pygame.Color(0, 0, 0), antialias=False): text = font.render(text, antialias, color) return text.convert_alpha()
[docs]def Text(text, font, color=pygame.Color(0, 0, 0), antialias=False, align="center"): """Renders a text. Supports multiline text, the background will be transparent. :param align: text-alignment must be "center", "left", or "righ" :type align: str :return: the input text :rtype: pygame.Surface """ assert align in ["center", "left", "right"] margin_l, margin_r = 1, 1 if align == "left": margin_l = 0 elif align == "right": margin_r = 0 margin = Margin(margin_l, margin_r) color_key = pygame.Color(0, 0, 1) if pygame.Color(0, 0, 1) != color else 0x000002 text_surfaces = [_text(line.strip(), font=font, color=color, antialias=antialias) for line in text.split("\n")] w = max(surf.get_rect().w for surf in text_surfaces) h = sum(surf.get_rect().h for surf in text_surfaces) surf = compose((w, h), Fill(color_key))(LinLayout("v")( *_lmap(lambda s: Surface(margin)(s), text_surfaces))) surf.set_colorkey(color_key) return surf.convert_alpha()