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 %}
+