Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/writing-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ from ... import fonts

To add a shared font, put the font file and its licence in `src/paperpi/fonts/`, and add a name for its path in `src/paperpi/fonts/__init__.py`. A font only one plugin uses can also stay in the plugin's own folder, with its licence.

### Pictures the plugin draws itself

Draw pictures such as bars, dots or icons at the exact size they are shown, never larger: resizing blurs their edges, and blurred edges show as dots on black-and-white screens. epdlib gives that size, inside the block's border and padding:

```python
layout = PLUGIN.layout(context.layout, context.settings)
prepared = layout.prepare(context.width, context.height, context.mode)
width, height = prepared.content_size("bar")
```

A picture of this size is shown pixel for pixel, whatever the block's `fit`. See `system_info` and `dec_binary_clock`.

### Moving blocks and colours

`draw` may return a `Drawn` instead of a dictionary, to set two more things:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@ select = ["E", "F", "W", "I", "B", "UP"]
# epdlib is not on PyPI yet, so it comes from GitHub at one fixed commit (see CLAUDE.md).
# Move `rev` forward when PaperPi needs a newer epdlib.
[tool.uv.sources]
epdlib = { git = "https://github.com/txoof/epdlib", rev = "b4dd600b35959c26fd235fe1af6be94e49fcc0db" }
epdlib = { git = "https://github.com/txoof/epdlib", rev = "4633fcad64cf5b99a6ea7a513fa6bcf497c28a79" }
9 changes: 4 additions & 5 deletions src/paperpi/plugins/dec_binary_clock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,12 @@ def fetch(context: Context):
def draw(now: datetime, context: Context) -> Drawn:
from . import dots

# Draw the dots at the exact size of their block, so epdlib doesn't scale them. The
# whole box is the drawing area only because the block has no padding and no border
# (see DOTS in layouts.py); a test checks that the screen shows the picture unscaled.
# Draw the dots at the exact size of their block, so epdlib doesn't scale them; a test
# checks that the screen shows the picture unscaled.
layout = PLUGIN.layout(context.layout, context.settings)
prepared = layout.prepare(context.width, context.height, context.mode)
box = prepared.boxes["dots"]
values = {"dots": dots.picture(now.hour, now.minute, box.width, box.height)}
width, height = prepared.content_size("dots")
values = {"dots": dots.picture(now.hour, now.minute, width, height)}
if "time" in prepared.boxes:
values["time"] = f"{now:%H:%M}"
# The time text moves at every update; the same minute gives the same place.
Expand Down
4 changes: 2 additions & 2 deletions src/paperpi/plugins/dec_binary_clock/layouts.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
FONT = fonts.ANTON
PADDING = 0.01

#: The picture of the dots. It must keep padding 0 and no border: ``draw`` makes the picture
#: exactly the size of the whole block, so epdlib doesn't scale it.
#: The picture of the dots. ``draw`` makes the picture exactly the size of the block's
#: drawing area, so epdlib doesn't scale it.
DOTS = {"name": "dots", "type": "image", "size": 8, "fit": "contain", "padding": 0}

LAYOUTS = {
Expand Down
13 changes: 5 additions & 8 deletions src/paperpi/plugins/splash_screen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

from ... import __version__
from ...plugin import Context, Plugin, PluginSettings, ready
from .layouts import ADDRESS_BLOCKS, LAYOUTS, PADDING
from .layouts import ADDRESS_BLOCKS, LAYOUTS

NAME = "PaperPi"
URL = "https://github.com/txoof/PaperPi"
Expand Down Expand Up @@ -146,19 +146,16 @@ def qr_code(address: str, width: int, height: int) -> Image.Image:
def draw(about: About, context: Context) -> dict:
layout = PLUGIN.layout(context.layout, context.settings)
prepared = layout.prepare(context.width, context.height, context.mode)
short_side = min(context.width, context.height)
values = {"name": about.name, "version": about.version}
address = web_address(about, context.settings.port)
texts = {"github": about.url, "ip": address or NO_NETWORK}
box = prepared.boxes["qr"]
if address and box.width > 0 and box.height > 0:
values["qr"] = qr_code(address, box.width, box.height)
edge = round(PADDING * short_side)
width, height = prepared.content_size("qr")
if address and width > 0 and height > 0:
values["qr"] = qr_code(address, width, height)
for name in ADDRESS_BLOCKS:
value = texts.get(name, "")
box = prepared.boxes[name]
o = layout.blocks[name].options
width, height = box.width - 2 * edge, box.height - 2 * edge
width, height = prepared.content_size(name)
if value and width > 0 and height > 0:
size = prepared.font_sizes[name]
value = fit_address(
Expand Down
22 changes: 18 additions & 4 deletions src/paperpi/plugins/system_info/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,20 @@ def draw(info: Info, context: Context) -> Drawn:
if info.disk_used is not None and info.disk_total:
disk_share = info.disk_used / info.disk_total
load_now = info.load[0] if info.load else None
# Pictures are drawn at the exact size of their block, so the layout doesn't resize them.
layout = PLUGIN.layout(context.layout, settings)
prepared = layout.prepare(context.width, context.height, context.mode)

def bar(name: str, part: float | None, *, upright: bool):
width, height = prepared.content_size(name)
if width and height:
return pictures.bar(part, width, height, ink, paper, upright=upright)
return None

def icon(name: str, draw_icon):
side = min(prepared.content_size(name))
return draw_icon(side, ink, paper) if side else None

if context.layout == "small":
values = {"hostname": hostname, "ip": ip, "temp": degrees(info.temperature)}
elif context.layout == "portrait":
Expand All @@ -108,7 +122,7 @@ def draw(info: Info, context: Context) -> Drawn:
"temp": (share(info.temperature, HOT), degrees(info.temperature)),
}
for name, (part, text) in gauges.items():
values[f"{name}_bar"] = pictures.bar(part, ink, paper, upright=True)
values[f"{name}_bar"] = bar(f"{name}_bar", part, upright=True)
values[f"{name}_label"] = name
values[f"{name}_value"] = text
else:
Expand All @@ -117,10 +131,10 @@ def draw(info: Info, context: Context) -> Drawn:
"hostname": hostname,
"ip": ip,
"wifi": wifi,
"disk_icon": pictures.disk_icon(ink, paper),
"disk_icon": icon("disk_icon", pictures.disk_icon),
"disk": disk_text(info.disk_used, info.disk_total),
"disk_bar": pictures.bar(disk_share, ink, paper, upright=False),
"cpu_icon": pictures.chip_icon(ink, paper),
"disk_bar": bar("disk_bar", disk_share, upright=False),
"cpu_icon": icon("cpu_icon", pictures.chip_icon),
"cpu": f"{degrees(info.temperature)} · memory {percent(info.memory)}",
"load": f"load {load}",
"about": f"{uptime_text(info.uptime)} · PaperPi {info.version}",
Expand Down
62 changes: 36 additions & 26 deletions src/paperpi/plugins/system_info/pictures.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,66 @@
"""The pictures system_info draws itself: two icons and the bars.

They are drawn in the text colour (``ink``) on the background colour (``paper``). Icons
are drawn large and scaled down by the layout; bars are stretched to their block.
They are drawn in the text colour (``ink``) on the background colour (``paper``), at the
exact size of their block (``content_size``), so the layout never resizes them: resizing
blurs the edges, and blurred edges show as dots on black-and-white screens.
"""

from PIL import Image, ImageDraw

ICON = 240 # pixels; the layout scales it to fit
ICON = 240 # the grid the icons are designed on; they are drawn at any size from it


def _canvas(width: int, height: int, paper: str) -> tuple[Image.Image, ImageDraw.ImageDraw]:
image = Image.new("RGB", (width, height), paper)
return image, ImageDraw.Draw(image)


def disk_icon(color: str, paper: str) -> Image.Image:
def _icon(side: int, paper: str):
"""A square canvas, and a function that turns grid units (0-240) into its pixels."""
image, draw = _canvas(side, side, paper)

def px(*units: float) -> list[int]:
return [round(u * side / ICON) for u in units]

return image, draw, px


def disk_icon(side: int, color: str, paper: str) -> Image.Image:
"""A disk drive seen from the front: a rounded box with two lights."""
image, draw = _canvas(ICON, ICON, paper)
draw.rounded_rectangle((20, 70, 220, 190), radius=24, fill=color)
draw.rectangle((20, 70, 220, 120), fill=color)
draw.line((20, 125, 220, 125), fill=paper, width=8) # gap between top and front
image, draw, px = _icon(side, paper)
draw.rounded_rectangle(px(20, 70, 220, 190), radius=px(24)[0], fill=color)
draw.rectangle(px(20, 70, 220, 120), fill=color)
gap = max(1, px(8)[0]) # gap between top and front
draw.line(px(20, 125, 220, 125), fill=paper, width=gap)
for x in (150, 185):
draw.rectangle((x, 148, x + 20, 166), fill=paper)
draw.rectangle(px(x, 148, x + 20, 166), fill=paper)
return image


def chip_icon(color: str, paper: str) -> Image.Image:
def chip_icon(side: int, color: str, paper: str) -> Image.Image:
"""A processor chip: a square with pins on every side."""
image, draw = _canvas(ICON, ICON, paper)
draw.rectangle((50, 50, 190, 190), outline=color, width=18)
draw.rectangle((92, 92, 148, 148), fill=color)
image, draw, px = _icon(side, paper)
draw.rectangle(px(50, 50, 190, 190), outline=color, width=max(1, px(18)[0]))
draw.rectangle(px(92, 92, 148, 148), fill=color)
for p in (78, 120, 162):
draw.rectangle((p - 8, 14, p + 8, 50), fill=color)
draw.rectangle((p - 8, 190, p + 8, 226), fill=color)
draw.rectangle((14, p - 8, 50, p + 8), fill=color)
draw.rectangle((190, p - 8, 226, p + 8), fill=color)
draw.rectangle(px(p - 8, 14, p + 8, 50), fill=color)
draw.rectangle(px(p - 8, 190, p + 8, 226), fill=color)
draw.rectangle(px(14, p - 8, 50, p + 8), fill=color)
draw.rectangle(px(190, p - 8, 226, p + 8), fill=color)
return image


def bar(share: float | None, color: str, paper: str, *, upright: bool) -> Image.Image:
"""A bar filled to ``share`` (0-1): an outline, filled from the bottom (upright) or
from the left. ``None`` (number not known) gives an empty outline.

Drawn larger than it will be shown, so the layout only ever shrinks it: enlarging
would blur the edges, and blurred edges show as dots on black-and-white screens.
def bar(
share: float | None, width: int, height: int, color: str, paper: str, *, upright: bool
) -> Image.Image:
"""A ``width`` x ``height`` bar filled to ``share`` (0-1): an outline, filled from the
bottom (upright) or from the left. ``None`` (number not known) gives an empty outline.
"""
share = 0.0 if share is None else max(0.0, min(1.0, share))
long, short, line = 1600, 400, 16
width, height = (short, long) if upright else (long, short)
image, draw = _canvas(width, height, paper)
line = max(1, round(min(width, height) * 0.04))
draw.rectangle((0, 0, width - 1, height - 1), outline=color, width=line)
filled = round(long * share)
filled = round((height if upright else width) * share)
if filled and upright:
draw.rectangle((0, height - filled, width - 1, height - 1), fill=color)
elif filled:
Expand Down
4 changes: 2 additions & 2 deletions src/paperpi/plugins/xkcd_comic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ def draw(comic: Comic, context: Context) -> dict:
layout = PLUGIN.layout(context.layout, settings)
picture = _open(comic.image)
if not settings.enlarge:
box = layout.prepare(context.width, context.height, context.mode).boxes["comic"]
picture = keep_size(picture, box.width, box.height)
prepared = layout.prepare(context.width, context.height, context.mode)
picture = keep_size(picture, *prepared.content_size("comic"))
title = f"{comic.number}: {comic.title}" if comic.title else str(comic.number)
values = {"comic": picture, "title": title, "alt": comic.alt}
return {name: value for name, value in values.items() if name in layout.blocks}
Expand Down
6 changes: 3 additions & 3 deletions src/paperpi/plugins/xkcd_comic/layouts.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Layouts for xkcd_comic. The first one is the default.

Blocks: ``comic`` (the picture), ``title`` and ``alt`` (the comic's hover text). The comic
block has no padding: the plugin needs its exact size to leave small comics at their own
size (see ``enlarge`` in ``__init__.py``). The text samples are a long title and a long
Blocks: ``comic`` (the picture), ``title`` and ``alt`` (the comic's hover text). The plugin
reads the comic block's exact size to leave small comics at their own size (see
``enlarge`` in ``__init__.py``). The text samples are a long title and a long
hover text, so the font size stays the same for most comics; longer texts get smaller
(``shrink``). The title is in Lato Bold and the hover text in Lato Italic, as in v1.
"""
Expand Down
Binary file modified tests/images/system_info-full-5in65.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/images/system_info-full-7in5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/images/system_info-full-9in7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/images/system_info-full-inverse-5in65.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/images/system_info-full-inverse-7in5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/images/system_info-full-inverse-9in7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/images/system_info-portrait-5in65.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/images/system_info-portrait-7in5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/images/system_info-portrait-9in7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/images/system_info-portrait-inverse-5in65.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/images/system_info-portrait-inverse-7in5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/images/system_info-portrait-inverse-9in7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 31 additions & 1 deletion tests/test_system_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import pytest
from epdlib import ScreenMode
from PIL import Image

from paperpi.plugin import Context, draw_update
from paperpi.plugins.system_info import (
Expand Down Expand Up @@ -115,14 +116,43 @@ def test_memory_without_available(root):
],
)
def test_bars(share, upright, dark_at):
image = pictures.bar(share, "black", "white", upright=upright).convert("L")
width, height = (400, 1600) if upright else (1600, 400)
image = pictures.bar(share, width, height, "black", "white", upright=upright).convert("L")
middle_top = image.getpixel((image.width // 2, image.height // 4))
if dark_at:
assert image.getpixel(dark_at) == 0
if share is None:
assert middle_top == 255 # an empty outline


@pytest.mark.parametrize("layout", ["full", "portrait"])
def test_pictures_have_their_blocks_exact_size(layout):
"""Bars and icons are drawn at their block's size, so the layout never resizes them."""
ctx = context(layout)
prepared = PLUGIN.layout(layout, ctx.settings).prepare(ctx.width, ctx.height, ctx.mode)
pictures_drawn = {
name: value
for name, value in draw(PLUGIN.sample, ctx).values.items()
if isinstance(value, Image.Image)
}
assert pictures_drawn
for name, picture in pictures_drawn.items():
width, height = prepared.content_size(name)
if name.endswith("_icon"):
assert picture.size == (min(width, height),) * 2
else:
assert picture.size == (width, height)


@pytest.mark.parametrize("draw_icon", [pictures.disk_icon, pictures.chip_icon])
@pytest.mark.parametrize("side", [1, 7, 240])
def test_icons_at_any_size(draw_icon, side):
icon = draw_icon(side, "black", "white")
assert icon.size == (side, side)
if side > 1:
assert icon.convert("L").getextrema() == (0, 255)


def test_disk_used_and_free_add_up(tmp_path):
used, total = readers.disk(tmp_path)
assert 0 < used <= total
Expand Down
6 changes: 3 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading