diff --git a/README.md b/README.md index 92c5df2..6042f55 100644 --- a/README.md +++ b/README.md @@ -47,17 +47,34 @@ Then install indiepixel uv add indiepixel ``` -Create a basic example: for example, copy [clock.py](https://github.com/tmcw/indiepixel/blob/main/examples/cli/clock.py) to the current directory. +Create a widget file, like `clock.py`: -Then run: +```python +import datetime +from indiepixel import Box, Root, Row, Text + +def main(): + now = datetime.datetime.now() + return Root( + child=Box( + Row(children=[ + Text(content=now.strftime("%I:%M %p"), color="#fff"), + ]), + padding=2, + background="#000", + ), + ) +``` + +Then run it: ``` uv run indiepixel clock.py ``` -Then, bam! You've got that clock rendering. If you want to get fancier, -you can create a directory of widgets and move `clock.py` into it, point -indiepixel at that, and it'll show all of them rendered in its web interface. +Open http://localhost:5000 in your browser and you'll see the clock rendering. +You can also point indiepixel at a directory of widget files and it'll show +all of them in its web interface. ## Deploying @@ -108,7 +125,7 @@ uv run pre-commit install ### Running examples -To run `examples/kitchen_sink.py`: +To run examples: ``` uv run indiepixel examples/cli/gradient.py @@ -152,8 +169,9 @@ _* = currently not tidbyt-compatible_ - [x] Text - [x] Box* - [x] Rect* - - [x] Column* + - [x] Column - [x] Row + - [x] Root - [x] Stack - [x] Circle - [x] PieChart @@ -162,5 +180,5 @@ _* = currently not tidbyt-compatible_ - [x] WrappedText - [ ] Resizing - [ ] Animation - - [ ] Plot - - [ ] 'expand' option + - [x] Plot + - [x] Row/Column `expanded`, `main_align`, `cross_align` diff --git a/examples/cli/animated_image.py b/examples/cli/animated_image.py index 348ad94..efda9b9 100644 --- a/examples/cli/animated_image.py +++ b/examples/cli/animated_image.py @@ -1,8 +1,9 @@ """Render an animation.""" -from indiepixel import Animation, Image from pathlib import Path +from indiepixel import Image + def main(): """ diff --git a/examples/cli/animation_nested.py b/examples/cli/animation_nested.py index 3cdcb1f..7a799cd 100644 --- a/examples/cli/animation_nested.py +++ b/examples/cli/animation_nested.py @@ -10,7 +10,6 @@ def main(): This matches the example in https://github.com/tidbyt/pixlet/blob/main/docs/widgets.md#stack Successfully matches! """ - return Animation( debug_label="Outer", children=[ diff --git a/examples/cli/image.py b/examples/cli/image.py index df94930..ed117d9 100644 --- a/examples/cli/image.py +++ b/examples/cli/image.py @@ -1,8 +1,9 @@ """Render an animation.""" -from indiepixel import Animation, Image from pathlib import Path +from indiepixel import Image + def main(): """ diff --git a/examples/kitchen_sink.py b/examples/cli/kitchen_sink.py similarity index 80% rename from examples/kitchen_sink.py rename to examples/cli/kitchen_sink.py index 82867cc..3f0eb8a 100644 --- a/examples/kitchen_sink.py +++ b/examples/cli/kitchen_sink.py @@ -1,24 +1,15 @@ -"""Interactive kitchen sink example.""" +"""Render a bunch of stuff.""" -# ruff: noqa: D103 -from io import BytesIO from pathlib import Path -from flask import Flask, send_file -from PIL import Image as ImagePIL -from PIL import ImageDraw - # import pytz from indiepixel import Box, Column, Image, Rect, Row, Text -def render(): - frames = [] - im = ImagePIL.new("RGB", (64, 32)) - draw = ImageDraw.Draw(im) - draw.fontmode = "1" +def main(): + """Render a bunch of stuff.""" # tz = pytz.timezone("America/New_York") - layout = Box( + return Box( Column( [ Row( @@ -28,7 +19,7 @@ def render(): Rect(width=4, height=4, color="orange"), Rect(width=4, height=4, color="blue"), ], - expand=True, + expanded=True, ), Row( [ @@ -150,42 +141,3 @@ def render(): # ]), # padding=2, background=(100, 0, 0, 255) # ) - layout.paint(draw, im, (0, 0, 64, 32), 0) - frames.append(im) - return frames - - -app = Flask(__name__) - - -@app.route("/") -def root() -> str: - return ( - "

" - "

" - ) - - -@app.route("/image.webp") -def image(): - img_io = BytesIO() - rendered = render() - # It's very important to specify lossless=True here, - # otherwise we get blurry output - rendered[0].save( - img_io, - "WEBP", - lossless=True, - alpha_quality=100, - save_all=True, - append_images=rendered[1:], - duration=100, - ) - img_io.seek(0) - return send_file(img_io, mimetype="image/webp") - - -if __name__ == "__main__": - app.run(debug=True, port=5000) diff --git a/examples/cli/kitchen_sink_cli.py b/examples/cli/kitchen_sink_cli.py deleted file mode 100644 index 0318824..0000000 --- a/examples/cli/kitchen_sink_cli.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Render a bunch of stuff.""" - -from pathlib import Path - -# import pytz -from indiepixel import Box, Column, Image, Rect, Row, Text - - -def main(): - """Render a bunch of stuff.""" - # tz = pytz.timezone("America/New_York") - return Box( - Column( - [ - Row( - [ - Image(src=str(Path("./examples/pixel-16x16.png").resolve())), - Rect(width=4, height=4, color="purple"), - Rect(width=4, height=4, color="orange"), - Rect(width=4, height=4, color="blue"), - ], - expand=True, - ), - Row( - [ - Rect(width=2, height=2, color="red"), - Rect(width=3, height=3, color="gray"), - Rect(width=4, height=4, color="#00f"), - ] - ), - Row( - [ - Text(content="6x10", font="6x10"), - Text(content="hello", font="tom-thumb"), - ] - ), - Row( - [ - Rect( - width=2, - height=2, - color="#f22", - ), - Rect(width=3, height=3, color="#234"), - Rect(width=4, height=4, color="#f00"), - Text(content="hi"), - Rect(width=2, height=2, color="#0f0"), - Text(content="hi"), - Rect(width=2, height=2, color="#f00"), - ] - ), - # Box( - # Row([ - # Rect( - # width=4, - # height=4, - # background=(255, 55, 0, 255) - # ), - # Rect( - # width=8, - # height=8, - # background=(0, 55, 0, 255) - # ), - # Rect( - # width=10, - # height=10, - # background=(0, 55, 255, 255) - # ) - # ]), - # background=(10, 10 ,10, 255) - # ) - ] - ) - ) - # layout = Box( - # Row([ - # Box( - # Text(content="xyz", color = (255, 255, 255, 255)), - # padding=2, - # background=(10, 10, 10, 255) - # ), - # Column([ - # # Box( - # # Row([ - # # Box( - # # Text(content="yea", color = (255, 0, 0, 255)), - # # padding=1 - # # )# , - # # # Box( - # # # Text(content="xxx", color = (10, 100, 0, 255)#), - # # # padding=0, background=(200, 255, #255, # 255# ) - # # # ) - # # ], - # # # expand = True - # # ), - # # background=(200, 200, 200, 200) - # # ), - # # Box( - # # Row([ - # # Text(now.strftime("%I:%M"), color = (255, ## 255, 0, 255)), - # # ]), - # # background=(0, 0, 255, 255) - # # ), - # Row([ - # Rect( - # width=4, - # height=4, - # background=(255, 255, 0, 255) - # ), - # Rect( - # width=4, - # height=4, - # background=(0, 0, 0, 255) - # ) - # ]), - # Rect( - # width=4, - # height=4, - # background=(0, 255, 0, 255) - # ) - # ]), - # ]), - # expand=True, - # padding=2, background=(100, 0, 0, 255) - # ) - # layout = Box( - # Stack([ - # Row([ - # Box( - # Text(content="yea", color = (255, 0, 0, 255)), - # padding=2 - # ), - # Box( - # Text(content="xxx", color = (10, 100, 0, 255)), - # padding=2, background=(200, 255, 255, 255) - # ) - # ]), - # Row([ - # Text(now.strftime("%I:%M"), color = (255, 255, 0, 255)), - # ]) - # ]), - # padding=2, background=(100, 0, 0, 255) - # ) diff --git a/examples/cli/plot.py b/examples/cli/plot.py new file mode 100644 index 0000000..2dc4a8c --- /dev/null +++ b/examples/cli/plot.py @@ -0,0 +1,26 @@ +"""Render a plot.""" + +import math + +from indiepixel import Box, Plot, Root + + +def main(): + """Render a sine wave plot.""" + data = [(x / 4, math.sin(x / 4)) for x in range(80)] + return Root( + child=Box( + Plot( + data=data, + width=60, + height=28, + color="#0ff", + color_inverted="#f80", + fill=True, + fill_color="#033", + fill_color_inverted="#330", + ), + padding=2, + background="#000", + ), + ) diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/src/indiepixel/__init__.py b/src/indiepixel/__init__.py index 0e1ff56..50a8b27 100644 --- a/src/indiepixel/__init__.py +++ b/src/indiepixel/__init__.py @@ -6,12 +6,17 @@ from typing import Literal from PIL import Image as ImagePIL -from PIL import ImageColor, ImageDraw, ImageFont +from PIL import ImageColor, ImageDraw, ImageEnhance, ImageFont type Size = tuple[int, int] type Bounds = tuple[int, int, int, int] type Color = tuple[int, int, int] | tuple[int, int, int, int] type InputColor = str | tuple[int, int, int] | None +type MainAlign = Literal[ + "start", "end", "center", "space_between", "space_evenly", "space_around" +] +type CrossAlign = Literal["start", "end", "center"] +type ChartType = Literal["scatter", "line"] fonts: dict[str, ImageFont.ImageFont] = {} @@ -74,16 +79,17 @@ def __init__( child: Renderable, max_age: int = 100, delay: int = 100, - # TODO: this doesn't do anything yet! - show_full_application: bool = False, + show_full_animation: bool = False, size: Size = DEFAULT_SIZE, + brightness: float = 1.0, ) -> None: """Construct a root widget.""" self.child = child self.max_age = max_age self.delay = delay - self.show_full_application = show_full_application + self.show_full_animation = show_full_animation self._size = size + self.brightness = brightness def size(self, bounds: Bounds): """Return the dimensions of a widget.""" @@ -100,13 +106,7 @@ def paint( self.child.paint( draw, im, - ( - # TODO: this hardcodes dimensions and should not. - 0, - 0, - self._size[0], - self._size[1], - ), + (0, 0, self._size[0], self._size[1]), frame, ) @@ -596,12 +596,68 @@ def paint( child.paint(draw, im, (bounds), frame) +def _accumulate_positions(start: float, sizes: list[int], gap: float = 0) -> list[int]: + """Build a list of positions by accumulating sizes with optional gaps.""" + positions = [] + pos = start + for s in sizes: + positions.append(round(pos)) + pos += s + gap + return positions + + +def distribute_space(total: int, sizes: list[int], align: MainAlign) -> list[int]: + """Calculate starting positions for children along the main axis.""" + used = sum(sizes) + remaining = max(total - used, 0) + n = len(sizes) + + match align: + case "end": + return _accumulate_positions(remaining, sizes) + case "center": + return _accumulate_positions(remaining // 2, sizes) + case "space_between": + gap = remaining / (n - 1) if n > 1 else 0 + return _accumulate_positions(0, sizes, gap) + case "space_evenly": + gap = remaining / (n + 1) if n > 0 else 0 + return _accumulate_positions(gap, sizes, gap) + case "space_around": + gap = remaining / n if n > 0 else 0 + return _accumulate_positions(gap / 2, sizes, gap) + case _: + return _accumulate_positions(0, sizes) + + +def cross_offset(total: int, child_size: int, align: CrossAlign) -> int: + """Calculate the offset for a child along the cross axis.""" + match align: + case "end": + return total - child_size + case "center": + return (total - child_size) // 2 + case _: + return 0 + + +# https://github.com/tidbyt/pixlet/blob/main/docs/widgets.md#column class Column(Renderable): """A column of widgets, laid out vertically.""" - def __init__(self, children: list[Renderable]) -> None: + def __init__( + self, + children: list[Renderable], + *, + expanded: bool = False, + main_align: MainAlign = "start", + cross_align: CrossAlign = "start", + ) -> None: """Construct a column widget.""" self.children = children + self.expanded = expanded + self.main_align: MainAlign = main_align + self.cross_align: CrossAlign = cross_align def size(self, bounds: Bounds): """Sizes the items in the column.""" @@ -610,11 +666,9 @@ def size(self, bounds: Bounds): for child in self.children: (cw, ch) = child.size(bounds) width = max(cw, width) - # NOTE: this might be an off-by-one, - # it's pretty fuzzy right now but if - # you omit this, you'll get overlapping items - # if you don't change anything else. - height = height + ch + 1 + height = height + ch + if self.expanded: + return (width, bounds[3] - bounds[1]) return (width, height) def frame_count(self) -> int: @@ -625,27 +679,39 @@ def paint( self, draw: ImageDraw.ImageDraw, im: ImagePIL.Image, bounds: Bounds, frame: int ) -> None: """Paints the items in the column.""" - top = bounds[1] - for child in self.children: - child.paint(draw, im, (bounds[0], top, bounds[2], bounds[3]), frame) - # Debug - # draw.rectangle([ - # bounds[0], - # top, - # bounds[0] + 1, - # top + 1 - # ], fill=(255, 0, 0, 255)) - (cw, ch) = child.size(bounds) - top = top + ch + 1 + child_sizes = [child.size(bounds) for child in self.children] + child_heights = [s[1] for s in child_sizes] + max_width = max((s[0] for s in child_sizes), default=0) + + total_height = bounds[3] - bounds[1] if self.expanded else sum(child_heights) + positions = distribute_space(total_height, child_heights, self.main_align) + + for i, child in enumerate(self.children): + cw, ch = child_sizes[i] + x_off = cross_offset(max_width, cw, self.cross_align) + x = bounds[0] + x_off + y = bounds[1] + positions[i] + child.paint(draw, im, (x, y, bounds[2], bounds[3]), frame) +# https://github.com/tidbyt/pixlet/blob/main/docs/widgets.md#row class Row(Renderable): """Produces a row of widgets, laid out horizontally.""" - def __init__(self, children: list[Renderable], *, expand: bool = False) -> None: + def __init__( + self, + children: list[Renderable], + *, + expanded: bool = False, + main_align: MainAlign = "start", + cross_align: CrossAlign = "start", + expand: bool | None = None, + ) -> None: """Construct a row widget.""" self.children = children - self.expand = expand + self.expanded = expand if expand is not None else expanded + self.main_align: MainAlign = main_align + self.cross_align: CrossAlign = cross_align def size(self, bounds: Bounds) -> tuple[int, int]: """Sizes the items in the row.""" @@ -655,7 +721,7 @@ def size(self, bounds: Bounds) -> tuple[int, int]: (cw, ch) = child.size(bounds) height = max(ch, height) width = width + cw - if self.expand: + if self.expanded: return (bounds[2] - bounds[0], height) return (width, height) @@ -667,23 +733,135 @@ def paint( self, draw: ImageDraw.ImageDraw, im: ImagePIL.Image, bounds: Bounds, frame: int ) -> None: """Paints the items in the row.""" - if self.expand: - widths = [child.size(bounds) for child in self.children] # noqa: F841 - left = bounds[0] - for child in self.children: - child.paint(draw, im, (left, bounds[1], bounds[2], bounds[3]), frame) - (cw, ch) = child.size(bounds) - # TODO: these +1 increments are a code smell, - # and I want to know why they aren't correct' - left = left + cw + 1 + child_sizes = [child.size(bounds) for child in self.children] + child_widths = [s[0] for s in child_sizes] + max_height = max((s[1] for s in child_sizes), default=0) + + total_width = bounds[2] - bounds[0] if self.expanded else sum(child_widths) + positions = distribute_space(total_width, child_widths, self.main_align) + + for i, child in enumerate(self.children): + cw, ch = child_sizes[i] + y_off = cross_offset(max_height, ch, self.cross_align) + x = bounds[0] + positions[i] + y = bounds[1] + y_off + child.paint(draw, im, (x, y, bounds[2], bounds[3]), frame) + + +# https://github.com/tidbyt/pixlet/blob/main/docs/widgets.md#plot +class Plot(Renderable): + """Visualizes data series as line or scatter charts.""" + + def __init__( + self, + *, + data: list[tuple[float, float]], + width: int, + height: int, + color: InputColor = "#fff", + color_inverted: InputColor = None, + fill: bool = False, + fill_color: InputColor = None, + fill_color_inverted: InputColor = None, + chart_type: ChartType = "line", + x_lim: tuple[float, float] | None = None, + y_lim: tuple[float, float] | None = None, + ) -> None: + """Construct a plot widget.""" + self.data = sorted(data, key=lambda p: p[0]) + self.width = width + self.height = height + self.color = maybe_parse_color(color) or (255, 255, 255) + self.color_inverted = maybe_parse_color(color_inverted) or self.color + self.fill = fill + self.fill_color = maybe_parse_color(fill_color) or self.color + self.fill_color_inverted = ( + maybe_parse_color(fill_color_inverted) or self.color_inverted + ) + self.chart_type = chart_type + self.x_lim = x_lim + self.y_lim = y_lim + + def size(self, bounds: Bounds): + """Return the configured width and height.""" + return (self.width, self.height) + + def frame_count(self) -> int: + """How many frames this widget produces.""" + return 1 + + def _map_point(self, x: float, y: float, bounds: Bounds) -> tuple[float, float]: + """Map a data point to pixel coordinates.""" + x_vals = [p[0] for p in self.data] + y_vals = [p[1] for p in self.data] + x_min = self.x_lim[0] if self.x_lim else min(x_vals) + x_max = self.x_lim[1] if self.x_lim else max(x_vals) + y_min = self.y_lim[0] if self.y_lim else min(y_vals) + y_max = self.y_lim[1] if self.y_lim else max(y_vals) + + x_range = x_max - x_min + y_range = y_max - y_min + + px = bounds[0] + ((x - x_min) / x_range * (self.width - 1) if x_range else 0) + py = ( + bounds[1] + + self.height + - 1 + - ((y - y_min) / y_range * (self.height - 1) if y_range else 0) + ) + return (px, py) + + def paint( + self, draw: ImageDraw.ImageDraw, im: ImagePIL.Image, bounds: Bounds, frame: int + ) -> None: + """Paints the plot.""" + if not self.data: + return + + points = [self._map_point(x, y, bounds) for x, y in self.data] + + if self.fill: + y_vals = [p[1] for p in self.data] + y_min = self.y_lim[0] if self.y_lim else min(y_vals) + y_max = self.y_lim[1] if self.y_lim else max(y_vals) + y_range = y_max - y_min + zero_y = ( + bounds[1] + + self.height + - 1 + - ((0 - y_min) / y_range * (self.height - 1) if y_range else 0) + ) + zero_y = max(bounds[1], min(bounds[1] + self.height - 1, zero_y)) + + for i in range(len(points) - 1): + x0, y0 = points[i] + x1, y1 = points[i + 1] + for px in range(round(x0), round(x1) + 1): + if x1 == x0: + py = y0 + else: + t = (px - x0) / (x1 - x0) + py = y0 + t * (y1 - y0) + orig_y = self.data[i][1] + fc = self.fill_color if orig_y >= 0 else self.fill_color_inverted + top = min(py, zero_y) + bottom = max(py, zero_y) + draw.line( + [(px, round(top)), (px, round(bottom))], + fill=fc, + ) + + if self.chart_type == "line": + for i in range(len(points) - 1): + c = self.color if self.data[i][1] >= 0 else self.color_inverted + draw.line( + [points[i], points[i + 1]], + fill=c, + ) else: - left = bounds[0] - for child in self.children: - child.paint(draw, im, (left, bounds[1], bounds[2], bounds[3]), frame) - (cw, ch) = child.size(bounds) - # TODO: these +1 increments are a code smell, - # and I want to know why they aren't correct' - left = left + cw + 1 + for i, (px, py) in enumerate(points): + c = self.color if self.data[i][1] >= 0 else self.color_inverted + draw.point((round(px), round(py)), fill=c) def render(widget: Renderable) -> list[ImagePIL.Image]: @@ -692,12 +870,17 @@ def render(widget: Renderable) -> list[ImagePIL.Image]: frame_count = widget.frame_count() size = widget.size((0, 0, 0, 0)) if isinstance(widget, Root) else DEFAULT_SIZE + brightness = widget.brightness if isinstance(widget, Root) else 1.0 for frame in range(frame_count): im = ImagePIL.new("RGB", size) draw = ImageDraw.Draw(im) draw.fontmode = "1" widget.paint(draw, im, (0, 0, size[0], size[1]), frame) + + if brightness < 1.0: + im = ImageEnhance.Brightness(im).enhance(brightness) + frames.append(im) return frames diff --git a/src/indiepixel/cli.py b/src/indiepixel/cli.py index 182499c..3dbb23d 100644 --- a/src/indiepixel/cli.py +++ b/src/indiepixel/cli.py @@ -1,46 +1,55 @@ """CLI that runs an interactive server for indiepixel.""" +import base64 import os +import secrets import sys +import time from importlib.util import module_from_spec, spec_from_file_location from io import BytesIO from pathlib import Path from types import ModuleType import click -from flask import Flask, render_template, request, send_file +from flask import Flask, Response, render_template, request, send_file from indiepixel import DEFAULT_SIZE, Root, Size, render -def create_server(filename: str, duration: int): - """Produce a server that will display the widgets listed by mods.""" - app = Flask(__name__) - - @app.route("/") - def root() -> str: - mods = load_from_path(filename) - host = request.headers["Host"] - return render_template("index.html", widgets=mods, host=host) - - @app.route("/widget/") - def widget(subpath) -> str: - mods = load_from_path(filename) - widget = next((w for w in mods if w[0] == subpath), None) - host = request.headers["Host"] - if widget is None: - return render_template("not_found.html") - print(widget) - return render_template("widget.html", widget=widget, host=host) - - @app.route("/image/.webp") - def image(subpath): - mods = load_from_path(filename) - """Display an image.""" - mod = [x for x in mods if x[0] == subpath] - if len(mod) == 0: - return f"Image not found, available modules are {map(lambda m: m[0], mods)}" - frames = render(mod[0][1][0].main()) +class IndiepixelServer: + """Development server for previewing indiepixel widgets.""" + + def __init__( + self, + filename: str, + duration: int = 500, + password: str | None = None, + ) -> None: + """Construct a server for the given widget file or directory.""" + self.filename = filename + self.duration = duration + self.password = password + self.active_screen: str | None = None + self.rotation_interval = 15 + self.app = Flask(__name__) + self._register_routes() + + def _check_auth(self): + """Check HTTP Basic Auth if a password is configured.""" + if self.password is None: + return None + auth = request.authorization + if not auth or not secrets.compare_digest(auth.password or "", self.password): + return Response( + "Authentication required", + 401, + {"WWW-Authenticate": 'Basic realm="Indiepixel"'}, + ) + return None + + def _render_module(self, mod): + """Render a module's main() to WebP frames and return a file response.""" + frames = render(mod[1][0].main()) # It's very important to specify lossless=True here, # otherwise we get blurry output img_io = BytesIO() @@ -51,18 +60,86 @@ def image(subpath): alpha_quality=100, save_all=True, append_images=frames[1:], - duration=duration, + duration=self.duration, loop=0, ) img_io.seek(0) return send_file(img_io, mimetype="image/webp") - return app + def _register_routes(self) -> None: + """Register all Flask routes.""" + self.app.before_request(self._check_auth) + self._register_widget_routes() + self._register_screen_routes() + + def _register_widget_routes(self) -> None: + """Register widget display routes.""" + + @self.app.route("/") + def root() -> str: + mods = load_from_path(self.filename) + host = request.headers["Host"] + return render_template("index.html", widgets=mods, host=host) + + @self.app.route("/widget/") + def widget(subpath) -> str: + mods = load_from_path(self.filename) + w = next((w for w in mods if w[0] == subpath), None) + host = request.headers["Host"] + if w is None: + return render_template("not_found.html") + return render_template("widget.html", widget=w, host=host) + + @self.app.route("/image/.webp") + def image(subpath): + """Display an image.""" + mods = load_from_path(self.filename) + mod = [x for x in mods if x[0] == subpath] + if len(mod) == 0: + return "Image not found", 404 + return self._render_module(mod[0]) + + def _register_screen_routes(self) -> None: + """Register screen selection and rotation routes.""" + + @self.app.route("/active.webp") + def active_image(): + """Serve whichever screen is currently active.""" + mods = load_from_path(self.filename) + if not mods: + return "No widgets found", 404 + if self.active_screen: + mod = next((m for m in mods if m[0] == self.active_screen), None) + if mod is None: + mod = mods[0] + else: + index = int(time.time() / self.rotation_interval) % len(mods) + mod = mods[index] + return self._render_module(mod) + + @self.app.route("/screen", methods=["GET"]) + def get_screen(): + """Get the currently active screen.""" + return {"active_screen": self.active_screen} + + @self.app.route("/screen/", methods=["POST"]) + def set_screen(name): + """Set the active screen.""" + self.active_screen = name + return {"active_screen": name} + + def run(self, host: str = "0.0.0.0", port: int = 5000, **kwargs) -> None: # noqa: S104 + """Start the development server.""" + extra_files: list[str] = [] + if os.path.isdir(self.filename): + extra_files = [str(p) for p in Path(self.filename).glob("*.py")] + else: + extra_files = [self.filename] + self.app.run(host=host, port=port, extra_files=extra_files, **kwargs) def import_from_path(module_name, file_path) -> tuple[ModuleType, Size]: """Import a module given its name and file path.""" - print(f"importing {file_path}") spec = spec_from_file_location(module_name, file_path) if spec is None or spec.loader is None: raise Exception("Could not get spec") @@ -82,11 +159,33 @@ def load_from_path(filename): return [(filename, import_from_path("render", filename))] +def display_in_terminal(filename: str) -> None: + """Display rendered widgets in the terminal using iTerm2 inline image protocol.""" + mods = load_from_path(filename) + for name, (module, _size) in mods: + frames = render(module.main()) + img_io = BytesIO() + frames[0].save(img_io, "PNG") + img_data = base64.b64encode(img_io.getvalue()).decode() + print(f"\033]1337;File=inline=1;width=auto;preserveAspectRatio=1:{img_data}\a") + print(f" {name}") + + @click.command() @click.argument("filename") @click.option("--duration", default=500, help="Frame duration for animations") -def cli(filename: str, duration: int): +@click.option("--terminal", is_flag=True, help="Display in terminal instead of server") +@click.option( + "--password", + default=None, + envvar="INDIEPIXEL_PASSWORD", + help="Password for basic auth", +) +def cli(filename: str, duration: int, *, terminal: bool, password: str | None): """Run indiepixel in a CLI.""" - app = create_server(filename, duration) + if terminal: + display_in_terminal(filename) + return + server = IndiepixelServer(filename, duration, password=password) port = int(os.environ.get("PORT", 5000)) - app.run(debug=True, port=port, host="0.0.0.0") # noqa: S104 + server.run(debug=True, port=port) diff --git a/src/indiepixel/templates/base.html b/src/indiepixel/templates/base.html index 21cd163..9936192 100644 --- a/src/indiepixel/templates/base.html +++ b/src/indiepixel/templates/base.html @@ -15,5 +15,13 @@

{% block main %}{% endblock %} + diff --git a/src/indiepixel/templates/index.html b/src/indiepixel/templates/index.html index ca8205b..533e5ee 100644 --- a/src/indiepixel/templates/index.html +++ b/src/indiepixel/templates/index.html @@ -11,6 +11,7 @@

+ {% endfor %} diff --git a/src/indiepixel/templates/not_found.html b/src/indiepixel/templates/not_found.html new file mode 100644 index 0000000..4ad8c77 --- /dev/null +++ b/src/indiepixel/templates/not_found.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block main %} +
+

Widget not found.

+
+{% endblock %} diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_column_cross_align_center.png b/tests/unit/__snapshots__/test_pixlet_compat/test_column_cross_align_center.png new file mode 100644 index 0000000..78608a3 Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_column_cross_align_center.png differ diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_column_cross_align_end.png b/tests/unit/__snapshots__/test_pixlet_compat/test_column_cross_align_end.png new file mode 100644 index 0000000..8f29ee1 Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_column_cross_align_end.png differ diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_column_main_align_center.png b/tests/unit/__snapshots__/test_pixlet_compat/test_column_main_align_center.png new file mode 100644 index 0000000..bc854f2 Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_column_main_align_center.png differ diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_column_main_align_end.png b/tests/unit/__snapshots__/test_pixlet_compat/test_column_main_align_end.png new file mode 100644 index 0000000..feca0fe Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_column_main_align_end.png differ diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_plot_empty_data.png b/tests/unit/__snapshots__/test_pixlet_compat/test_plot_empty_data.png new file mode 100644 index 0000000..84cdc94 Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_plot_empty_data.png differ diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_plot_fill.png b/tests/unit/__snapshots__/test_pixlet_compat/test_plot_fill.png new file mode 100644 index 0000000..dc72d90 Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_plot_fill.png differ diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_plot_line.png b/tests/unit/__snapshots__/test_pixlet_compat/test_plot_line.png new file mode 100644 index 0000000..69713a9 Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_plot_line.png differ diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_plot_scatter.png b/tests/unit/__snapshots__/test_pixlet_compat/test_plot_scatter.png new file mode 100644 index 0000000..0dcd2f3 Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_plot_scatter.png differ diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_plot_with_text.png b/tests/unit/__snapshots__/test_pixlet_compat/test_plot_with_text.png new file mode 100644 index 0000000..473f0ad Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_plot_with_text.png differ diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_row_cross_align_center.png b/tests/unit/__snapshots__/test_pixlet_compat/test_row_cross_align_center.png new file mode 100644 index 0000000..020d6e5 Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_row_cross_align_center.png differ diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_row_cross_align_end.png b/tests/unit/__snapshots__/test_pixlet_compat/test_row_cross_align_end.png new file mode 100644 index 0000000..0a6f988 Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_row_cross_align_end.png differ diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_row_main_align_center.png b/tests/unit/__snapshots__/test_pixlet_compat/test_row_main_align_center.png new file mode 100644 index 0000000..6126c31 Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_row_main_align_center.png differ diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_row_main_align_end.png b/tests/unit/__snapshots__/test_pixlet_compat/test_row_main_align_end.png new file mode 100644 index 0000000..e67b448 Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_row_main_align_end.png differ diff --git a/tests/unit/__snapshots__/test_pixlet_compat/test_row_main_align_space_between.png b/tests/unit/__snapshots__/test_pixlet_compat/test_row_main_align_space_between.png new file mode 100644 index 0000000..a8f19bb Binary files /dev/null and b/tests/unit/__snapshots__/test_pixlet_compat/test_row_main_align_space_between.png differ diff --git a/tests/unit/test_pixlet_compat.py b/tests/unit/test_pixlet_compat.py new file mode 100644 index 0000000..02aab1f --- /dev/null +++ b/tests/unit/test_pixlet_compat.py @@ -0,0 +1,270 @@ +""" +Tests for pixlet compatibility. + +Verifies that widgets behave according to the pixlet specification +documented at https://github.com/tidbyt/pixlet/blob/main/docs/widgets.md +""" + +# ruff: noqa: D103 +from __future__ import annotations + +from io import BytesIO + +import pytest +from PIL import Image as ImagePIL +from PIL import ImageDraw +from syrupy.extensions.image import PNGImageSnapshotExtension + +from indiepixel import Column, Plot, Rect, Root, Row, Text + + +@pytest.fixture +def snapshot(snapshot): + return snapshot.use_extension(PNGImageSnapshotExtension) + + +@pytest.fixture +def image() -> ImagePIL.Image: + return ImagePIL.new("RGB", (64, 32)) + + +@pytest.fixture +def image_draw(image: ImagePIL.Image) -> ImageDraw.ImageDraw: + return ImageDraw.ImageDraw(image) + + +@pytest.fixture +def render_widget(image, image_draw): + """Render a widget to PNG image data.""" + + def render_widget_impl(widget) -> bytes: + image_draw.rectangle([0, 0, 63, 31], fill=(0, 0, 0)) + widget.paint(image_draw, image, (0, 0, 64, 32), 0) + buffer = BytesIO() + image.save(buffer, format="PNG") + png_bytes = buffer.getvalue() + buffer.close() + return png_bytes + + return render_widget_impl + + +BOUNDS = (0, 0, 64, 32) + + +# --- Root --- + + +def test_root_default_size() -> None: + """Root provides a 64x32 canvas.""" + root = Root(child=Rect(width=10, height=10, color="#fff")) + assert root.size(BOUNDS) == (64, 32) + + +def test_root_custom_size() -> None: + root = Root(child=Rect(width=10, height=10, color="#fff"), size=(128, 64)) + assert root.size(BOUNDS) == (128, 64) + + +def test_root_frame_count() -> None: + root = Root(child=Rect(width=10, height=10, color="#fff")) + assert root.frame_count() == 1 + + +# --- Row --- + + +def test_row_shrinks_to_fit() -> None: + """Row is as small as possible while holding all children.""" + row = Row( + children=[ + Rect(width=10, height=5, color="#f00"), + Rect(width=20, height=10, color="#0f0"), + ] + ) + assert row.size(BOUNDS) == (30, 10) + + +def test_row_expanded_fills_width() -> None: + """Expanded Row fills available horizontal space.""" + row = Row(children=[Rect(width=10, height=5, color="#f00")], expanded=True) + assert row.size(BOUNDS) == (64, 5) + + +def test_row_expand_compat() -> None: + """The old 'expand' parameter still works.""" + row = Row(children=[Rect(width=10, height=5, color="#f00")], expand=True) + assert row.size(BOUNDS) == (64, 5) + + +def test_row_main_align_end(snapshot, render_widget) -> None: + row = Row( + children=[ + Rect(width=5, height=5, color="#f00"), + Rect(width=5, height=5, color="#0f0"), + ], + expanded=True, + main_align="end", + ) + assert render_widget(row) == snapshot + + +def test_row_main_align_center(snapshot, render_widget) -> None: + row = Row( + children=[ + Rect(width=5, height=5, color="#f00"), + Rect(width=5, height=5, color="#0f0"), + ], + expanded=True, + main_align="center", + ) + assert render_widget(row) == snapshot + + +def test_row_main_align_space_between(snapshot, render_widget) -> None: + row = Row( + children=[ + Rect(width=5, height=5, color="#f00"), + Rect(width=5, height=5, color="#0f0"), + ], + expanded=True, + main_align="space_between", + ) + assert render_widget(row) == snapshot + + +def test_row_cross_align_center(snapshot, render_widget) -> None: + row = Row( + children=[ + Rect(width=5, height=10, color="#f00"), + Rect(width=5, height=5, color="#0f0"), + ], + cross_align="center", + ) + assert render_widget(row) == snapshot + + +def test_row_cross_align_end(snapshot, render_widget) -> None: + row = Row( + children=[ + Rect(width=5, height=10, color="#f00"), + Rect(width=5, height=5, color="#0f0"), + ], + cross_align="end", + ) + assert render_widget(row) == snapshot + + +# --- Column --- + + +def test_column_shrinks_to_fit() -> None: + """Column is as small as possible while holding all children.""" + col = Column( + children=[ + Rect(width=10, height=5, color="#f00"), + Rect(width=20, height=10, color="#0f0"), + ] + ) + assert col.size(BOUNDS) == (20, 15) + + +def test_column_expanded_fills_height() -> None: + """Expanded Column fills available vertical space.""" + col = Column(children=[Rect(width=10, height=5, color="#f00")], expanded=True) + assert col.size(BOUNDS) == (10, 32) + + +def test_column_main_align_end(snapshot, render_widget) -> None: + col = Column( + children=[ + Rect(width=5, height=5, color="#f00"), + Rect(width=5, height=5, color="#0f0"), + ], + expanded=True, + main_align="end", + ) + assert render_widget(col) == snapshot + + +def test_column_main_align_center(snapshot, render_widget) -> None: + col = Column( + children=[ + Rect(width=5, height=5, color="#f00"), + Rect(width=5, height=5, color="#0f0"), + ], + expanded=True, + main_align="center", + ) + assert render_widget(col) == snapshot + + +def test_column_cross_align_center(snapshot, render_widget) -> None: + col = Column( + children=[ + Rect(width=10, height=5, color="#f00"), + Rect(width=5, height=5, color="#0f0"), + ], + cross_align="center", + ) + assert render_widget(col) == snapshot + + +def test_column_cross_align_end(snapshot, render_widget) -> None: + col = Column( + children=[ + Rect(width=10, height=5, color="#f00"), + Rect(width=5, height=5, color="#0f0"), + ], + cross_align="end", + ) + assert render_widget(col) == snapshot + + +# --- Plot --- + + +def test_plot_size() -> None: + plot = Plot(data=[(0, 0), (1, 1)], width=30, height=20) + assert plot.size(BOUNDS) == (30, 20) + + +def test_plot_frame_count() -> None: + plot = Plot(data=[(0, 0), (1, 1)], width=30, height=20) + assert plot.frame_count() == 1 + + +def test_plot_empty_data(snapshot, render_widget) -> None: + plot = Plot(data=[], width=30, height=20) + assert render_widget(plot) == snapshot + + +def test_plot_line(snapshot, render_widget) -> None: + data = [(float(i), float(i)) for i in range(10)] + plot = Plot(data=data, width=30, height=20, color="#0ff") + assert render_widget(plot) == snapshot + + +def test_plot_scatter(snapshot, render_widget) -> None: + data = [(float(i), float(i % 5)) for i in range(10)] + plot = Plot(data=data, width=30, height=20, color="#ff0", chart_type="scatter") + assert render_widget(plot) == snapshot + + +def test_plot_fill(snapshot, render_widget) -> None: + data = [(float(i), float(i)) for i in range(10)] + plot = Plot( + data=data, width=30, height=20, color="#0f0", fill=True, fill_color="#030" + ) + assert render_widget(plot) == snapshot + + +def test_plot_with_text(snapshot, render_widget) -> None: + """A layout combining plot and text.""" + col = Column( + children=[ + Text(content="data", color="#fff"), + Plot(data=[(0, 0), (5, 10), (10, 3)], width=40, height=15, color="#f00"), + ] + ) + assert render_widget(col) == snapshot