diff --git a/ci/deploy_to_device.py b/ci/deploy_to_device.py index 9268afd..f0795a0 100644 --- a/ci/deploy_to_device.py +++ b/ci/deploy_to_device.py @@ -26,6 +26,7 @@ def deploy(arch=""): deploy_py_files(Path("src/tempe/colors"), ":/lib/tempe/colors", arch=arch) deploy_py_files(Path("src/tempe/fonts"), ":/lib/tempe/fonts", arch=arch) deploy_py_files(Path("src/tempe/colormaps"), ":/lib/tempe/colormaps", arch=arch) + deploy_py_files(Path("src/tempe_components"), ":/lib/tempe_components", arch=arch) deploy_py_files(Path("src/tempe_displays"), ":/lib/tempe_displays", arch=arch) deploy_py_files( Path("src/tempe_displays/st7789"), ":/lib/tempe_displays/st7789", arch=arch diff --git a/ci/test.py b/ci/test.py index ca60780..ee8dc97 100644 --- a/ci/test.py +++ b/ci/test.py @@ -15,11 +15,11 @@ def test(): """Run unit tests in micropython""" print("Running Tests") failures = [] - test_dir = Path("tests/tempe") + test_dir = Path("tests") os.environ["MICROPYPATH"] = "src:" + os.environ.get( "MICROPYPATH", ":examples:.frozen:~/.micropython/lib:/usr/lib/micropython" ) - for path in sorted(test_dir.glob("test_*.py")): + for path in sorted(test_dir.glob("*/test_*.py")): print(path.name, "... ", end="", flush=True) result = run_test(path) if result: diff --git a/examples/component_example.py b/examples/component_example.py new file mode 100644 index 0000000..9b37a59 --- /dev/null +++ b/examples/component_example.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2024-present Unital Software +# +# SPDX-License-Identifier: MIT + +"""Example showing basic display of text.""" + +import time +import asyncio +import gc + +from tempe.colors import grey_6, grey_a +from tempe.surface import Surface, BACKGROUND, DRAWING +from tempe.text import Text +from tempe.shapes import Rectangles +from tempe.font import TempeFont +from tempe.fonts import ubuntu16 +from tempe_components.component import Box, Component, component_style +from tempe_components.label import Label, label_style +from tempe_components.style import Style, StateColor + +# maximize available memory before allocating buffer +gc.collect() + +# A buffer one half the size of a 320x240 screen +# NOTE: If you get MemoryErrors, make this smaller +working_buffer = bytearray(2 * 320 * 121) + + +# create the surface +surface = Surface() + +# fill the background with white pixels +bounds = (0, 0, 320, 240) + +background = Component( + surface=surface, + style=component_style, + box=Box(x=0, y=0, content_width=320, content_height=240), +) + +label = Label( + surface=surface, + style=Style( + parent=label_style, + font=TempeFont(ubuntu16), + shadow=StateColor(enabled=grey_a), + border=StateColor(enabled=grey_6), + ), + box=Box(x=100, y=100, content_width=100, content_height=50, pad_top=4, pad_left=4, pad_bottom=4, pad_right=4), + text="Hello Tempe\nComponents", +) + +def main(display=None): + """Render the surface and return the display object.""" + if display is None: + try: + from tempe_config import init_display + + display = asyncio.run(init_display()) + except ImportError: + print( + "Could not find tempe_config.init_display.\n\n" + "To run examples, you must create a top-level tempe_config module containing\n" + "an async init_display function that returns a display.\n\n" + "See https://unital.github.io/tempe more information.\n\n" + "Defaulting to file-based display.\n" + ) + from tempe.display import FileDisplay + + display = FileDisplay("component_example.rgb565", (320, 240)) + with display: + display.clear() + surface.refresh(display, working_buffer) + + start = time.ticks_us() + surface.refresh(display, working_buffer) + print(time.ticks_diff(time.ticks_us(), start)) + return display + + +if __name__ == '__main__': + display = main() diff --git a/src/tempe/colors/convert.py b/src/tempe/colors/convert.py index 5a8ecf6..80add80 100644 --- a/src/tempe/colors/convert.py +++ b/src/tempe/colors/convert.py @@ -14,6 +14,8 @@ def normalize_color(color): return rgb_to_rgb565(*color) else: return rgb24_to_rgb565(*color) + elif color is None: + return None else: raise ValueError(f"Unknown color {color!r}") diff --git a/src/tempe/colors/convert.pyi b/src/tempe/colors/convert.pyi index 35d9c73..e3894af 100644 --- a/src/tempe/colors/convert.pyi +++ b/src/tempe/colors/convert.pyi @@ -4,12 +4,16 @@ """Color conversion routines.""" +from typing import overload from collections.abc import Iterable from .types import color, rgb, rgb565 - +@overload def normalize_color(color: color) -> rgb565: ... +@overload +def normalize_color(color: None) -> None: ... + def rgb_sequence_to_rgb565(colors: Iterable[rgb]) -> list[rgb565]: ... def rgb444_to_rgb565(r: int, g: int, b: int, big_endian: bool = True) -> rgb565: ... diff --git a/src/tempe/shapes.py b/src/tempe/shapes.py index eb7dff8..d18a44f 100644 --- a/src/tempe/shapes.py +++ b/src/tempe/shapes.py @@ -311,12 +311,14 @@ class RoundedRectangles(Rectangles): Geometry should produce x, y, w, h arrays. """ - def __init__(self, geometry, colors, *, radius=4, fill=True, surface=None, clip=None): + def __init__(self, geometry, colors, *, radius=4, fill=True, fill_center=True, surface=None, clip=None): super().__init__(geometry, colors, fill=fill, surface=surface, clip=clip) self.radius = radius + self.fill_center = fill_center def draw(self, buffer, x=0, y=0): fill = self.fill + fill_center = self.fill_center for rect, color in self: px = rect[0] - x py = rect[1] - y @@ -333,9 +335,15 @@ def draw(self, buffer, x=0, y=0): h -= 1 r = min(self.radius, w // 2, h // 2) if fill: - buffer.rect(px + r, py, w - 2*r, h + 1, color, fill) - buffer.rect(px, py + r, r, h - 2*r, color, fill) - buffer.rect(px + w - r, py + r, r + 1, h - 2*r, color, fill) + if fill_center: + buffer.rect(px + r, py, w - 2*r, h + 1, color, fill) + buffer.rect(px, py + r, r, h - 2*r, color, fill) + buffer.rect(px + w - r, py + r, r + 1, h - 2*r, color, fill) + else: + buffer.rect(px + r, py, w - 2*r, r, color, fill) + buffer.rect(px + r, py + h - r + 1 , w - 2*r, r, color, fill) + buffer.rect(px, py + r, r, h - 2*r, color, fill) + buffer.rect(px + w - r + 1, py + r, r, h - 2*r, color, fill) else: buffer.hline(px + r, py, w - 2*r, color) buffer.hline(px + r, py + h, w - 2*r, color) @@ -353,9 +361,11 @@ def draw(self, buffer, x=0, y=0): buffer.ellipse(px + r, py + h - r, r, r, color, fill, 4) buffer.ellipse(px + r, py + r, r, r, color, fill, 2) - def update(self, geometry=None, colors=None, fill=None, radius=None): + def update(self, geometry=None, colors=None, fill=None, radius=None, fill_center=None): if radius is not None: self.radius = radius + if fill_center is not None: + self.fill_center = fill_center super().update(geometry=geometry, colors=colors, fill=fill) diff --git a/src/tempe/shapes.pyi b/src/tempe/shapes.pyi index 4be5ae5..61dd358 100644 --- a/src/tempe/shapes.pyi +++ b/src/tempe/shapes.pyi @@ -377,6 +377,7 @@ class RoundedRectangles(Rectangles): *, radius: int = 4, fill: bool = True, + fill_center: bool = True, surface: "tempe.surface.Surface | None" = None, clip: rectangle | None = None, ): ... @@ -387,6 +388,7 @@ class RoundedRectangles(Rectangles): geometry = Iterable[rectangle] | None, colors = Iterable[rgb565] | None, fill = bool | None, + fill_center: bool = True, radius = int | None ) -> None: """Update the state of the Shape, marking a redraw as needed. @@ -399,6 +401,8 @@ class RoundedRectangles(Rectangles): The sequence of colors for each geometry. fill : bool | None Whether to fill the shape or to draw the outline. + fill : bool | None + Whether to fill the central rectangle (only applies when fill=True). radius : int | None The corner radii for the rectangles. """ diff --git a/src/tempe_components/__init__.py b/src/tempe_components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tempe_components/component.py b/src/tempe_components/component.py new file mode 100644 index 0000000..0ace20f --- /dev/null +++ b/src/tempe_components/component.py @@ -0,0 +1,168 @@ +# SPDX-FileCopyrightText: 2024-present Unital Software +# +# SPDX-License-Identifier: MIT + +import asyncio + +from tempe.colors import grey_e, grey_a +from tempe.shapes import RoundedRectangles +from tempe.surface import Surface, BACKGROUND, OVERLAY + +from .observable import Observable, Field, undefined +from .style import Style, StateColor, ENABLED + + +component_style = Style( + parent=None, + background=StateColor(enabled=grey_e) +) + + +class Box(Observable): + x: int = Field(cls=int) + y: int = Field(cls=int) + content_width: int = Field(cls=int) + content_height: int = Field(cls=int) + pad_top: int = Field(1, cls=int) + pad_bottom: int = Field(1, cls=int) + pad_left: int = Field(1, cls=int) + pad_right: int = Field(1, cls=int) + margin_top: int = Field(0, cls=int) + margin_bottom: int = Field(0, cls=int) + margin_left: int = Field(0, cls=int) + margin_right: int = Field(0, cls=int) + + @property + def bounds(self): + return ( + self.x + self.margin_left, + self.y + self.margin_top, + self.content_width + self.pad_left + self.pad_right, + self.content_height + self.pad_top + self.pad_bottom, + ) + + @property + def shadow_bounds(self): + return ( + self.x + self.margin_left - 1, + self.y + self.margin_top + 1, + self.content_width + self.pad_left + self.pad_right + 2, + self.content_height + self.pad_top + self.pad_bottom, + ) + + @property + def content_bounds(self): + return ( + self.x + self.margin_left + self.pad_left, + self.y + self.margin_top + self.pad_top, + self.content_width, + self.content_height, + ) + + +class Component(Observable): + + surface: "Surface" + + box: Box + + style = Field(component_style, cls=Style) + + state = Field(ENABLED, cls=str) + + _background_shape = None + _shadow_shape = None + _border_shape = None + + def __init__(self, surface, **kwargs): + self.surface = surface + super().__init__(**kwargs) + + def _update_background_shape(self): + if self.style.background is None: + background = None + else: + background = self.style.background[self.state] + if background is None: + if self._background_shape is not None: + if self._shadow_shape is not None: + self.surface.remove_shape(BACKGROUND, self._shadow_shape) + self.surface.remove_shape(BACKGROUND, self._background_shape) + self._background_shape = None + elif self._background_shape is None: + if self.style.shadow is not None: + self._shadow_shape = RoundedRectangles( + [self.box.shadow_bounds], + [self.style.shadow[self.state]], + radius=self.style.radius + 1, + fill=True, + fill_center=False, + ) + self.surface.add_shape(BACKGROUND, self._shadow_shape) + self._background_shape = RoundedRectangles( + [self.box.bounds], + [background], + radius=self.style.radius, + fill=True, + ) + self.surface.add_shape(BACKGROUND, self._background_shape) + else: + if self.style.shadow is None: + if self._shadow_shape is not None: + self.surface.remove_shape(BACKGROUND, self._shadow_shape) + self._shadow_shape = None + elif self._shadow_shape is None: + self._shadow_shape = RoundedRectangles( + [self.box.shadow_bounds], + [self.style.shadow[self.state]], + radius=self.style.radius + 1, + fill=True, + fill_center=False, + ) + # Ensure proper layering + self.surface.remove_shape(BACKGROUND, self._background_shape) + self.surface.add_shape(BACKGROUND, self._shadow_shape) + self.surface.add_shape(BACKGROUND, self._background_shape) + else: + self._shadow_shape.update( + geometry=[self.box.shadow_bounds], + colors=[self.style.shadow[self.state]], + radius=self.style.radius, + ) + + self._background_shape.update( + geometry=[self.box.bounds], + colors=[background], + radius=self.style.radius, + ) + + def _update_border_shape(self): + if self.style.border is None: + border = None + else: + border = self.style.border[self.state] + if border is None: + if self._border_shape is not None: + self.surface.remove_shape(OVERLAY, self._border_shape) + self._border_shape = None + elif self._border_shape is None: + self._border_shape = RoundedRectangles( + [self.box.bounds], + [border], + radius=self.style.radius, + fill=False, + ) + self.surface.add_shape(OVERLAY, self._border_shape) + else: + self._border_shape.update( + geometry=[self.box.bounds], + colors=[border], + radius=self.style.radius, + ) + + def update( + self, update=None, **kwargs + ): + super().update(update, **kwargs) + self._update_background_shape() + self._update_border_shape() diff --git a/src/tempe_components/component.pyi b/src/tempe_components/component.pyi new file mode 100644 index 0000000..f9239ce --- /dev/null +++ b/src/tempe_components/component.pyi @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: 2024-present Unital Software +# +# SPDX-License-Identifier: MIT + +from typing import Final, dataclass_transform + +from tempe.shapes import RoundedRectangles, rectangle +from tempe.surface import Surface + +from .observable import Observable, Undefined, undefined +from .style import Style, State, ENABLED + + +component_style: Final[Style] + + +class Box(Observable): + """Geometry for UI components. + + This is a simplified version of the standard HTML box model: each box has + content which has a width and a height, internal padding, and external + margin. + """ + + #: Horizontal position + x: int + + #: Vertical position + y: int + + #: Width of box content + content_width: int + + #: Height of box content + content_height: int + + #: Amount of top padding inside border but outside content + pad_top: int = 0 + + #: Amount of bottom padding inside border but outside content + pad_bottom: int = 0 + + #: Amount of left padding inside border but outside content + pad_left: int = 0 + + #: Amount of right padding inside border but outside content + pad_right: int = 0 + + #: Amount of top margin outside border + margin_top: int = 0 + + #: Amount of bottom margin outside border + margin_bottom: int = 0 + + #: Amount of left margin outside border + margin_left: int = 0 + + #: Amount of right margin outside border + margin_right: int = 0 + + @property + def bounds(self) -> rectangle: + """Bounding rectangle of border and background.""" + + @property + def shadow_bounds(self) -> rectangle: + """Bounding rectangle of drop shadow.""" + + @property + def content_bounds(self) -> rectangle: + """Bounding rectangle of content.""" + + +class Component(Observable): + """Base class for UI elements.""" + + #: The surface to which the component's shapes will be added. + surface: Surface + + #: The box geometry of the component. + box: Box + + #: The style for the component. There is a default component style for each class. + style: Style = component_style + + #: The current activation state of the component, used for color selection. + state: State = ENABLED + + _background_shape: RoundedRectangles | None = None + + _border_shape: RoundedRectangles | None = None + + def _update_background_shape(self) -> None: ... + + def _update_border_shape(self) -> None: ... + + def update(self, update: dict[str, object] | None = None, /, **kwargs) -> None: ... + diff --git a/src/tempe_components/label.py b/src/tempe_components/label.py new file mode 100644 index 0000000..2ab52aa --- /dev/null +++ b/src/tempe_components/label.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: 2024-present Unital Software +# +# SPDX-License-Identifier: MIT + +from tempe.colors import grey_4, grey_6, grey_a, grey_d +from tempe.surface import DRAWING +from tempe.text import Text, CENTER + +from .component import Component, component_style +from .observable import Field, undefined, is_undefined +from .style import StateColor, Style + + +label_style = Style( + parent=component_style, + background=StateColor(enabled=grey_d), + text=StateColor(enabled=grey_4), + radius=8, +) + +class Label(Component): + + style = Field(label_style, cls=Style) + text = Field("", cls=str) + + _anchor = (0, 0) + _text_shape = None + + def _update_text(self): + if self.style.text is not None: + text_color = self.style.text[self.state] + else: + text_color = None + if text_color is None: + if self._text_shape is not None: + self.surface.remove_shape(DRAWING, self._text_shape) + self._text_shape = None + elif self._text_shape is None: + self._text_shape = Text( + [self._anchor], + [text_color], + [self.text], + [(CENTER, CENTER)], + font=self.style.font, + ) + self.surface.add_shape(DRAWING, self._text_shape) + else: + self._text_shape.update( + geometry=[self._anchor], + colors=[text_color], + texts=[self.text], + font=self.style.font, + ) + + + def update( + self, + update=None, + **kwargs + ): + super().update(update) + self._anchor = ( + self.box.bounds[0] + self.box.bounds[2] // 2, + self.box.bounds[1] + self.box.bounds[3] // 2, + ) + self._update_text() + diff --git a/src/tempe_components/label.pyi b/src/tempe_components/label.pyi new file mode 100644 index 0000000..0485449 --- /dev/null +++ b/src/tempe_components/label.pyi @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2024-present Unital Software +# +# SPDX-License-Identifier: MIT + +from typing import Final + +from tempe.colors import rgb565 +from tempe.font import AbstractFont + +from .component import Component, component_style +from .observable import Field, undefined +from .style import StateColor, Style + + +label_style: Final[Style] + + +class Label(Component): + """A Component which displays text.""" + + text = Field("", cls=str) + + _anchor = (0, 0) + _text_shape = None + + def _update_text(self) -> None: ... + + def update(self, update: dict[str, object] | None = None, /, **kwargs) -> None: ... diff --git a/src/tempe_components/observable.py b/src/tempe_components/observable.py new file mode 100644 index 0000000..faa9892 --- /dev/null +++ b/src/tempe_components/observable.py @@ -0,0 +1,159 @@ +# SPDX-FileCopyrightText: 2024-present Unital Software +# +# SPDX-License-Identifier: MIT + +import asyncio + +class Undefined: + pass + +undefined = Undefined() + +def is_undefined(obj): + return isinstance(obj, Undefined) + + +class Updatable: + + _obs_fields = {} + + def __init__(self, **kwargs): + # Hack to work around lack of PEP 487 support. + if not '_obs_fields' in self.__class__.__dict__: + self.__class__.__init_subclass__() + self.update(kwargs) + + @classmethod + def __init_subclass__(cls): + fields = {} + for base in cls.__bases__: + fields.update(getattr(base, '_obs_fields', {})) + cls._obs_fields = fields + + def update(self, update=None, **kwargs): + if update is None: + update = {} + update.update(kwargs) + for name, value in update.items(): + field = self.__class__._obs_fields.get(name, None) + if field is not None: + if is_undefined(value): + delattr(self, field.stored_name) + else: + setattr(self, field.stored_name, value) + else: + if is_undefined(value): + delattr(self, name) + else: + setattr(self, name, value) + + self.validate(update) + + def validate(self, update): + pass + + +class Observable(Updatable): + + _obs_fields = {} + + def __init__(self, **kwargs): + self.updated = asyncio.Event() + self._update_tasks = {} + super().__init__(**kwargs) + + def close(self): + for task in self._update_tasks.values(): + task.cancel() + + def _attr_updated(self, observable): + self.updated.set() + self.updated.clear() + + def update(self, update=None, **kwargs): + if update is None: + update = {} + update.update(kwargs) + for name, value in update.items(): + if name in self._update_tasks: + self._update_tasks[name].cancel() + del self._update_tasks[name] + + super().update(update) + + for name in update: + value = getattr(self, name) + if isinstance(value, Observable): + self._update_tasks[name] = observe(value, self._attr_updated) + + self.updated.set() + self.updated.clear() + + +async def aobserve(observable, callback): + while True: + try: + await observable.updated.wait() + except asyncio.CancelledError: + break + callback(observable) + + +def observe(observable, callback): + return asyncio.create_task(aobserve(observable, callback)) + + +class Field: + + prefix = '_' + + def __init__(self, default=undefined, default_factory=None, adapter=None, cls=None): + self._default = default + self._default_factory = default_factory + self.adapter = adapter + self.cls = cls + + def __set_name__(self, cls, name): + # Hack to work around lack of PEP 487 support. + if not '_obs_fields' in cls.__dict__: + cls.__init_subclass__() + cls._obs_fields[name] = self + self.name = name + self.stored_name = self.prefix + name + + def __get__(self, obj, cls=None): + if not hasattr(obj, self.stored_name): + default = self.default_factory(obj) + if not is_undefined(default): + setattr(obj, self.stored_name, default) + try: + return getattr(obj, self.stored_name) + except AttributeError: + raise AttributeError(self.name) + + def __set__(self, obj, value): + value = self.validator(obj, value) + obj.update({self.name: value}) + + def default_factory(self, obj): + if is_undefined(self._default) and self._default_factory is not None: + return self._default_factory() + else: + return self.default() + + def default(self): + return self._default + + def validator(self, obj, value): + if self.adapter is not None: + try: + value = self.adapter(value) + except Exception: + raise ValueError(f"Error adapting value {value}.") + if self.cls is not None and not isinstance(value, self.cls): + raise ValueError(f"Invalid value {value}, should be of type {self.cls}") + return value + + +def field(default=undefined, default_factory=None, adapter=None, cls=None): + return Field(default=default, default_factory=default_factory, adapter=adapter, cls=cls) diff --git a/src/tempe_components/observable.pyi b/src/tempe_components/observable.pyi new file mode 100644 index 0000000..13ab846 --- /dev/null +++ b/src/tempe_components/observable.pyi @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: 2024-present Unital Software +# +# SPDX-License-Identifier: MIT + +import asyncio +from typing import Callable, Any, ClassVar, ParamSpec, dataclass_transform, overload + +class Undefined: + pass + +undefined = Undefined() + +def is_undefined(obj: Any) -> bool: ... + +params = ParamSpec("params") + + +@dataclass_transform(eq_default=False, field_specifiers=(Field, field,)) +class Updatable: + """A dataclass-like class that allows atomic updates of attributes.""" + + _obs_fields: ClassVar[dict[str, Field]] = {} + + def update( + self, + update: dict[str, object] | None = None, + /, + **kwargs: object, + ) -> None: ... + + +class Observable(Updatable): + """A dataclass-like class that fires an asyncio.Event when updated.""" + + _obs_fields: ClassVar[dict[str, Field]] = {} + + @property + def updated(self) -> asyncio.Event: ... + + def close(self) -> None: ... + + +async def aobserve(observable: Observable, callback: Callable[[Observable], None]) -> None: ... + +def observe(observable: Observable, callback: Callable[[Observable], None]) -> asyncio.Task: ... + + +class Field[Accepts, Stores, Target: Updatable]: + """A dataclass-like field that can validate and adapt values on setting.""" + + name: str + + stored_name: str + + adapter: Callable[[Accepts], Stores] | None + + cls: type[Stores] | None + + @overload + def __init__( + self, + default: Stores, + *, + adapter: Callable[[Accepts], Stores] | None = None, + cls: type[Stores] | None = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + default_factory: Callable[[], Stores] | None = None, + adapter: Callable[[Accepts], Stores] | None = None, + cls: type[Stores] | None = None, + ) -> None: ... + + def __set_name__(self, cls: type[Target], name: str) -> None: ... + + @overload + def __get__(self, obj: Target, cls: type[Target] | None = None) -> Stores: ... + + @overload + def __get__(self, obj: None, cls: type[Target]) -> Stores: ... + + def __set__(self, obj: Target, value: Accepts) -> None: ... + + def default_factory(self, obj: Target) -> Stores | Undefined: ... + + def default(self) -> Stores | Undefined: ... + + def validator(self, obj: Target, value: Accepts) -> Stores: ... + + +@overload +def field[Accepts, Stores]( + default: Stores, + adapter: Callable[[Accepts], Stores] | None = None, + cls: type[Stores] | None = None, + ) -> Stores: ... + +@overload +def field[Accepts, Stores]( + default_factory: Callable[[], Stores] | None = None, + adapter: Callable[[Accepts], Stores] | None = None, + cls: type[Stores] | None = None, + ) -> Stores: ... + diff --git a/src/tempe_components/style.py b/src/tempe_components/style.py new file mode 100644 index 0000000..679e520 --- /dev/null +++ b/src/tempe_components/style.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: 2024-present Unital Software +# +# SPDX-License-Identifier: MIT + +from tempe.colors import normalize_color, grey_4, grey_c, grey_e, grey_a +from tempe.font import AbstractFont + +from .observable import Observable, Field, field, undefined, is_undefined + + +ACTIVE = const("active") +ENABLED = const("enabled") +DISABLED = const("disabled") +STATES = const((ENABLED, DISABLED, ACTIVE)) + + +class StateColorField(Field): + + def __init__( + self, + default=undefined, + default_factory=None, + adapter=normalize_color, + ): + if default_factory is None: + super().__init__( + default=default, + adapter=adapter, + cls=int, + ) + else: + super().__init__( + default_factory=default_factory, + adapter=adapter, + cls=int, + ) + + def default_factory(self, obj): + if self.name is not 'enabled': + return obj.enabled + return super().default_factory(obj) + + +class StateColor(Observable): + + enabled = StateColorField() + disabled = StateColorField() + active = StateColorField() + + def __init__(self, enabled, **kwargs): + kwargs[ENABLED] = enabled + super().__init__(**kwargs) + + def __getitem__(self, state): + return getattr(self, state) + + def __setitem__(self, state, value): + self.update({state: value}) + + +class StyleField(Field): + + def __get__(self, obj, cls=None): + if not hasattr(obj, self.stored_name): + try: + return getattr(obj.parent, self.name) + except AttributeError: + default = self.default_factory(obj) + if not is_undefined(default): + setattr(obj, self.stored_name, default) + try: + return getattr(obj, self.stored_name) + except AttributeError: + raise AttributeError(self.name) + + +class Style(Observable): + + parent = Field(None) + + background = StyleField(None, cls=StateColor) + border = StyleField(None, cls=StateColor) + shadow = StyleField(None, cls=StateColor) + radius = StyleField(0, adapter=abs, cls=int) + text = StyleField(None, cls=StateColor) + font = StyleField(None, cls=AbstractFont) + +# fix need for recursive definition +Style.parent.cls = Style diff --git a/src/tempe_components/style.pyi b/src/tempe_components/style.pyi new file mode 100644 index 0000000..c2b3a79 --- /dev/null +++ b/src/tempe_components/style.pyi @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: 2024-present Unital Software +# +# SPDX-License-Identifier: MIT + +from typing import Final, Literal, Callable, overload + +from tempe.colors import normalize_color, grey_4, grey_c, grey_e, grey_a, color, rgb565 +from tempe.font import AbstractFont + +from .observable import Observable, Field, field + + +State = Literal["active", "enabled", "disabled"] +ACTIVE: Final[State] = "active" +ENABLED: Final[State] = "enabled" +DISABLED: Final[State] = "disabled" +STATES: Final[tuple[State, ...]] = (ENABLED, DISABLED, ACTIVE) + + +class StateColorField(Field[color, rgb565, StateColor]): + """A Field subclass for storing color information.""" + + @overload + def __init__( + self, + default: rgb565, + *, + adapter: Callable[[color], rgb565] = normalize_color, + ) -> None: ... + + @overload + def __init__( + self, + *, + default_factory: Callable[[], rgb565] | None = None, + adapter: Callable[[color], rgb565] = normalize_color, + ) -> None: ... + + def default_factory(self, obj: StateColor) -> rgb565: ... + + +class StateColor(Observable): + """Color information for different states of a UI component. + + This object holds information about how a particular aspect of a component + changes color as the component's state changes. + """ + + enabled = StateColorField() + disabled = StateColorField() + active = StateColorField() + + def __init__(self, **kwargs: color): ... + + def __getitem__(self, state: State) -> rgb565: ... + + def __setitem__(self, state: State, value: color) -> None: ... + + +class Style(Observable): + """Styling information for Ui components. + + This object holds styling information, such as color selections for + different parts of the component, font information, and box shape + parameters. + + Styles can have a parent style, and inherit non-specified values from + that and will react to changes in their parent, signalling classes which + use them that they may have had values updated. + """ + + parent: Style | None = None + + background: StateColor | None = None + border: StateColor | None = None + shadow: StateColor | None = None + radius: int = 0 + text: StateColor | None = None + font: AbstractFont | None = None diff --git a/tests/tempe_components/__init__.py b/tests/tempe_components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tempe_components/test_observable.py b/tests/tempe_components/test_observable.py new file mode 100644 index 0000000..af4f966 --- /dev/null +++ b/tests/tempe_components/test_observable.py @@ -0,0 +1,502 @@ +# SPDX-FileCopyrightText: 2024-present Unital Software +# +# SPDX-License-Identifier: MIT + +import asyncio +import unittest + +from tempe_components.observable import Field, Observable, Updatable, aobserve, observe, field, undefined + + +class TestUpdatable(unittest.TestCase): + + def test_base(self): + data = Updatable() + data.update(test=1) + + self.assertEqual(data.test, 1) + + def test_base_update_args(self): + data = Updatable() + data.update({'test_1': 1}, test_2=2) + + self.assertEqual(data.test_1, 1) + self.assertEqual(data.test_2, 2) + + def test_base_init(self): + data = Updatable(test=1) + + self.assertEqual(data.test, 1) + + def test_simple_subclass(self): + + class UpdatableSimpleSub(Updatable): + test = 0 + + data = UpdatableSimpleSub() + data.update(test=1) + + self.assertEqual(data.test, 1) + + def test_simple_subclass_init(self): + + class UpdatableSimpleSub(Updatable): + test = 0 + + data = UpdatableSimpleSub(test=1) + + self.assertEqual(data.test, 1) + + def test_simple_subclass_init_args(self): + + class UpdatableFieldSub(Updatable): + test = 0 + + with self.assertRaises(TypeError): + UpdatableFieldSub(1) + + def test_field_subclass(self): + + class UpdatableFieldSub(Updatable): + test = Field() + + self.assertIn("_obs_fields", UpdatableFieldSub.__dict__) + self.assertEqual( + UpdatableFieldSub._obs_fields, + {"test": UpdatableFieldSub.test} + ) + + + data = UpdatableFieldSub() + data.update(test=1) + + self.assertEqual(data.test, 1) + + data.test = 2 + + self.assertEqual(data.test, 2) + + def test_field_subclass_init(self): + + class UpdatableFieldSub(Updatable): + test = Field() + + data = UpdatableFieldSub(test=1) + + self.assertEqual(data.test, 1) + + def test_field_subclass_init_args(self): + + class UpdatableFieldSub(Updatable): + test = Field() + + with self.assertRaises(TypeError): + UpdatableFieldSub(1) + + def test_validate(self): + + class ValidatingUpdatable(Updatable): + a = Field(0) + b = Field(0) + + def validate(self, update): + if self.b < self.a: + self.update(a=self.b, b=self.a) + + data = ValidatingUpdatable() + data.update(a=10, b=1) + + self.assertEqual(data.a, 1) + self.assertEqual(data.b, 10) + + +class TestObservable(unittest.TestCase): + + async def update_waiter(self, data, callback): + await data.updated.wait() + callback(data) + + async def update_later(self, data, update): + await asyncio.sleep(0.01) + data.update(update) + + def test_base(self): + data = Observable() + + try: + data.update(test=1) + finally: + data.close() + + self.assertEqual(data.test, 1) + + def test_base_event(self): + data = Observable() + + result = [] + + def test_changed(data): + result.append(data.test) + + async def test(): + await asyncio.gather( + asyncio.wait_for(self.update_waiter(data, test_changed), 10), + self.update_later(data, {'test': 1}) + ) + + try: + asyncio.run(test()) + finally: + data.close() + + self.assertEqual(result, [1]) + + def test_observable(self): + data = Observable() + + data.update(test=1) + + self.assertEqual(data.test, 1) + + def test_observable_event(self): + data = Observable(sub_data=Observable()) + + result = [] + + def data_changed(data): + result.append(data.sub_data.test) + + async def test(): + await asyncio.gather( + asyncio.wait_for(self.update_waiter(data, data_changed), 10), + self.update_later(data.sub_data, {'test': 1}) + ) + + try: + asyncio.run(test()) + + self.assertEqual(result, [1]) + self.assertFalse(data.sub_data.updated.is_set()) + self.assertFalse(data.updated.is_set()) + finally: + data.close() + + def test_remove_observable(self): + sub_data = Observable(test=0) + data = Observable(sub_data=sub_data) + + result = [] + + def data_changed(data): + result.append(data.sub_data) + + async def test(): + await asyncio.gather( + asyncio.wait_for(self.update_waiter(data, data_changed), 10), + self.update_later(data, {'sub_data': None}) + ) + try: + + asyncio.run(test()) + + self.assertEqual(result, [None]) + + result.clear() + + sub_data.test = 1 + + self.assertEqual(result, []) + finally: + sub_data.close() + data.close() + + +class TestObservers(unittest.TestCase): + + async def update_waiter(self, data, callback): + await data.updated.wait() + callback(data) + + async def update_later(self, data, update, delay=0.01): + await asyncio.sleep(delay) + data.update(update) + + async def cancel_task_later(self, task, delay=1.0): + await asyncio.sleep(delay) + task.cancel() + + def test_observe(self): + data = Observable() + + result = [] + + def test_changed(data): + print("----->", data.test) + result.append(data.test) + + obs = observe(data, test_changed) + + async def test(): + await asyncio.gather( + asyncio.wait_for(obs, 10), + self.update_later(data, {'test': 1}, 0.01), + self.update_later(data, {'test': 2}, 0.05), + self.cancel_task_later(obs, 0.1) + ) + + try: + asyncio.run(test()) + finally: + data.close() + + self.assertEqual(result, [1, 2]) + + def test_aobserve(self): + data = Observable() + + result = [] + + def test_changed(data): + print("----->", data.test) + result.append(data.test) + + obs = asyncio.create_task(aobserve(data, test_changed)) + + async def test(): + await asyncio.gather( + asyncio.wait_for(obs, 10), + self.update_later(data, {'test': 1}, 0.01), + self.update_later(data, {'test': 2}, 0.05), + self.cancel_task_later(obs, 0.1) + ) + + try: + asyncio.run(test()) + finally: + data.close() + + self.assertEqual(result, [1, 2]) + + +class TestField(unittest.TestCase): + + def test_basic(self): + + class SimpleExample(Updatable): + test = Field() + + data = SimpleExample() + + with self.assertRaises(AttributeError): + data.test + + data.test = 1 + + self.assertEqual(data.test, 1) + self.assertEqual(data.__dict__["_test"], 1) + + data.test = undefined + + with self.assertRaises(AttributeError): + data.test + + def test_default(self): + + class SimpleExample(Updatable): + test = Field(0) + + data = SimpleExample() + + self.assertEqual(data.test, 0) + self.assertEqual(data.__dict__["_test"], 0) + + data.test = 1 + + self.assertEqual(data.test, 1) + self.assertEqual(data.__dict__["_test"], 1) + + data.test = undefined + + self.assertEqual(data.test, 0) + self.assertEqual(data.__dict__["_test"], 0) + + def test_default_factory(self): + + class SimpleExample(Updatable): + test = Field(default_factory=int) + + data = SimpleExample() + + self.assertEqual(data.test, 0) + self.assertEqual(data.__dict__["_test"], 0) + + data.test = 1 + + self.assertEqual(data.test, 1) + self.assertEqual(data.__dict__["_test"], 1) + + data.test = undefined + + self.assertEqual(data.test, 0) + self.assertEqual(data.__dict__["_test"], 0) + + def test_adapter(self): + + class SimpleExample(Updatable): + test = Field(adapter=int) + + data = SimpleExample() + + data.test = "1" + + self.assertEqual(data.test, 1) + self.assertEqual(data.__dict__["_test"], 1) + + with self.assertRaises(ValueError): + data.test = "a" + + def test_cls(self): + + class SimpleExample(Updatable): + test = Field(cls=int) + + data = SimpleExample() + + with self.assertRaises(ValueError): + data.test = "1" + + data.test = 1 + + self.assertEqual(data.test, 1) + self.assertEqual(data.__dict__["_test"], 1) + + def test_cls_and_adapter(self): + + class SimpleExample(Updatable): + test = Field(adapter=int, cls=int) + + data = SimpleExample() + + with self.assertRaises(ValueError): + data.test = None + + data.test = "1" + + self.assertEqual(data.test, 1) + self.assertEqual(data.__dict__["_test"], 1) + + +class TestFieldFunction(unittest.TestCase): + + def test_basic(self): + + class SimpleExample(Updatable): + test = field() + + data = SimpleExample() + + with self.assertRaises(AttributeError): + data.test + + data.test = 1 + + self.assertEqual(data.test, 1) + self.assertEqual(data.__dict__["_test"], 1) + + data.test = undefined + + with self.assertRaises(AttributeError): + data.test + + def test_default(self): + + class SimpleExample(Updatable): + test = field(0) + + data = SimpleExample() + + self.assertEqual(data.test, 0) + self.assertEqual(data.__dict__["_test"], 0) + + data.test = 1 + + self.assertEqual(data.test, 1) + self.assertEqual(data.__dict__["_test"], 1) + + data.test = undefined + + self.assertEqual(data.test, 0) + self.assertEqual(data.__dict__["_test"], 0) + + def test_default_factory(self): + + class SimpleExample(Updatable): + test = field(default_factory=int) + + data = SimpleExample() + + self.assertEqual(data.test, 0) + self.assertEqual(data.__dict__["_test"], 0) + + data.test = 1 + + self.assertEqual(data.test, 1) + self.assertEqual(data.__dict__["_test"], 1) + + data.test = undefined + + self.assertEqual(data.test, 0) + self.assertEqual(data.__dict__["_test"], 0) + + def test_adapter(self): + + class SimpleExample(Updatable): + test = field(adapter=int) + + data = SimpleExample() + + data.test = "1" + + self.assertEqual(data.test, 1) + self.assertEqual(data.__dict__["_test"], 1) + + with self.assertRaises(ValueError): + data.test = "a" + + def test_cls(self): + + class SimpleExample(Updatable): + test = field(cls=int) + + data = SimpleExample() + + with self.assertRaises(ValueError): + data.test = "1" + + data.test = 1 + + self.assertEqual(data.test, 1) + self.assertEqual(data.__dict__["_test"], 1) + + def test_cls_and_adapter(self): + + class SimpleExample(Updatable): + test = field(adapter=int, cls=int) + + data = SimpleExample() + + with self.assertRaises(ValueError): + data.test = None + + data.test = "1" + + self.assertEqual(data.test, 1) + self.assertEqual(data.__dict__["_test"], 1) + + +if __name__ == "__main__": + result = unittest.main() + if not result.wasSuccessful(): + import sys + + sys.exit(1) diff --git a/tests/tempe_components/test_style.py b/tests/tempe_components/test_style.py new file mode 100644 index 0000000..f4a374d --- /dev/null +++ b/tests/tempe_components/test_style.py @@ -0,0 +1,222 @@ +# SPDX-FileCopyrightText: 2024-present Unital Software +# +# SPDX-License-Identifier: MIT + +import asyncio +import unittest + +from tempe.colors import black, grey, white +from tempe_components.style import StateColor, StateColorField, Style, ENABLED, DISABLED, ACTIVE + + +class TestStateColor(unittest.TestCase): + + def test_basic(self): + + color = StateColor(enabled=black) + + self.assertEqual(color.enabled, black) + self.assertEqual(color.disabled, black) + self.assertEqual(color.active, black) + + self.assertEqual(color[ENABLED], black) + self.assertEqual(color[DISABLED], black) + self.assertEqual(color[ACTIVE], black) + + color.enabled = white + + self.assertEqual(color.enabled, white) + self.assertEqual(color.disabled, white) + self.assertEqual(color.active, white) + + self.assertEqual(color[ENABLED], white) + self.assertEqual(color[DISABLED], white) + self.assertEqual(color[ACTIVE], white) + + color[ENABLED] = grey + + self.assertEqual(color.enabled, grey) + self.assertEqual(color.disabled, grey) + self.assertEqual(color.active, grey) + + self.assertEqual(color[ENABLED], grey) + self.assertEqual(color[DISABLED], grey) + self.assertEqual(color[ACTIVE], grey) + + def test_basic_adapted(self): + + color = StateColor(enabled="black") + + self.assertEqual(color.enabled, black) + self.assertEqual(color.disabled, black) + self.assertEqual(color.active, black) + + self.assertEqual(color[ENABLED], black) + self.assertEqual(color[DISABLED], black) + self.assertEqual(color[ACTIVE], black) + + color.enabled = "white" + + self.assertEqual(color.enabled, white) + self.assertEqual(color.disabled, white) + self.assertEqual(color.active, white) + + self.assertEqual(color[ENABLED], white) + self.assertEqual(color[DISABLED], white) + self.assertEqual(color[ACTIVE], white) + + color[ENABLED] = "grey" + + self.assertEqual(color.enabled, grey) + self.assertEqual(color.disabled, grey) + self.assertEqual(color.active, grey) + + self.assertEqual(color[ENABLED], grey) + self.assertEqual(color[DISABLED], grey) + self.assertEqual(color[ACTIVE], grey) + + def test_full(self): + + color = StateColor(enabled=black, disabled="white") + + self.assertEqual(color.enabled, black) + self.assertEqual(color.disabled, white) + self.assertEqual(color.active, black) + + self.assertEqual(color[ENABLED], black) + self.assertEqual(color[DISABLED], white) + self.assertEqual(color[ACTIVE], black) + + color.enabled = grey + color.disabled = "black" + color[ACTIVE] = white + + self.assertEqual(color.enabled, grey) + self.assertEqual(color.disabled, black) + self.assertEqual(color.active, white) + + self.assertEqual(color[ENABLED], grey) + self.assertEqual(color[DISABLED], black) + self.assertEqual(color[ACTIVE], white) + + def test_empty(self): + + color = StateColor() + + with self.assertRaises(AttributeError): + color.enabled + + with self.assertRaises(AttributeError): + color.disabled + + with self.assertRaises(AttributeError): + color.active + + color.enabled = black + + self.assertEqual(color.enabled, black) + self.assertEqual(color.disabled, black) + self.assertEqual(color.active, black) + + self.assertEqual(color[ENABLED], black) + self.assertEqual(color[DISABLED], black) + self.assertEqual(color[ACTIVE], black) + + + def test_bad(self): + + color = StateColor() + + with self.assertRaises(ValueError): + color.enabled = (1,) + + +class TestStyle(unittest.TestCase): + + async def update_waiter(self, data, callback): + await data.updated.wait() + callback(data) + + async def update_later(self, data, update, delay=0.01): + await asyncio.sleep(delay) + data.update(update) + + async def cancel_task_later(self, task, delay=1.0): + await asyncio.sleep(delay) + task.cancel() + + def test_basic(self): + style = Style( + background=StateColor(), + border=StateColor(), + radius=0, + text=StateColor(), + font=None, + ) + + try: + self.assertEqual(style.radius, 0) + finally: + style.close() + + def test_inherited(self): + bg_color = StateColor() + parent = Style( + background=bg_color, + border=StateColor(), + radius=0, + text=StateColor(), + font=None, + ) + style = Style( + parent=parent, + radius=2, + ) + try: + self.assertEqual(style.radius, 2) + self.assertEqual(style.background, bg_color) + + new_color = StateColor() + style.background = new_color + + self.assertEqual(style.background, new_color) + self.assertEqual(parent.background, bg_color) + finally: + parent.close() + style.close() + + def test_updates(self): + bg_color = StateColor() + parent = Style( + background=bg_color, + border=StateColor(), + radius=0, + text=StateColor(), + font=None, + ) + style = Style( + parent=parent, + radius=2, + ) + + result = [] + + def test_changed(data): + result.append(data.test) + + obs = asyncio.create_task(aobserve(data, test_changed)) + + async def test(): + await asyncio.gather( + asyncio.wait_for(obs, 10), + self.update_later(data, {'test': 1}, 0.01), + self.update_later(data, {'test': 2}, 0.05), + self.cancel_task_later(obs, 0.1) + ) + + try: + asyncio.run(test()) + finally: + data.close() + + self.assertEqual(result, [1, 2])