diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 4247a1d..fa3d5fb 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -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: diff --git a/pyproject.toml b/pyproject.toml index d843a36..cd44bf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/paperpi/plugins/dec_binary_clock/__init__.py b/src/paperpi/plugins/dec_binary_clock/__init__.py index 6440f09..93ef510 100644 --- a/src/paperpi/plugins/dec_binary_clock/__init__.py +++ b/src/paperpi/plugins/dec_binary_clock/__init__.py @@ -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. diff --git a/src/paperpi/plugins/dec_binary_clock/layouts.py b/src/paperpi/plugins/dec_binary_clock/layouts.py index 35e7659..6cf8215 100644 --- a/src/paperpi/plugins/dec_binary_clock/layouts.py +++ b/src/paperpi/plugins/dec_binary_clock/layouts.py @@ -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 = { diff --git a/src/paperpi/plugins/splash_screen/__init__.py b/src/paperpi/plugins/splash_screen/__init__.py index 751682d..54fc584 100644 --- a/src/paperpi/plugins/splash_screen/__init__.py +++ b/src/paperpi/plugins/splash_screen/__init__.py @@ -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" @@ -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( diff --git a/src/paperpi/plugins/system_info/__init__.py b/src/paperpi/plugins/system_info/__init__.py index 1735fca..a33e816 100644 --- a/src/paperpi/plugins/system_info/__init__.py +++ b/src/paperpi/plugins/system_info/__init__.py @@ -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": @@ -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: @@ -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}", diff --git a/src/paperpi/plugins/system_info/pictures.py b/src/paperpi/plugins/system_info/pictures.py index 8b97afd..68ffc8d 100644 --- a/src/paperpi/plugins/system_info/pictures.py +++ b/src/paperpi/plugins/system_info/pictures.py @@ -1,12 +1,13 @@ """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]: @@ -14,43 +15,52 @@ def _canvas(width: int, height: int, paper: str) -> tuple[Image.Image, ImageDraw 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: diff --git a/src/paperpi/plugins/xkcd_comic/__init__.py b/src/paperpi/plugins/xkcd_comic/__init__.py index 7d381fd..14a443f 100644 --- a/src/paperpi/plugins/xkcd_comic/__init__.py +++ b/src/paperpi/plugins/xkcd_comic/__init__.py @@ -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} diff --git a/src/paperpi/plugins/xkcd_comic/layouts.py b/src/paperpi/plugins/xkcd_comic/layouts.py index fc6812f..2c3fe34 100644 --- a/src/paperpi/plugins/xkcd_comic/layouts.py +++ b/src/paperpi/plugins/xkcd_comic/layouts.py @@ -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. """ diff --git a/tests/images/system_info-full-5in65.png b/tests/images/system_info-full-5in65.png index 4ef3712..c282c44 100644 Binary files a/tests/images/system_info-full-5in65.png and b/tests/images/system_info-full-5in65.png differ diff --git a/tests/images/system_info-full-7in5.png b/tests/images/system_info-full-7in5.png index ff8c053..4219bac 100644 Binary files a/tests/images/system_info-full-7in5.png and b/tests/images/system_info-full-7in5.png differ diff --git a/tests/images/system_info-full-9in7.png b/tests/images/system_info-full-9in7.png index 31a263a..59e9d80 100644 Binary files a/tests/images/system_info-full-9in7.png and b/tests/images/system_info-full-9in7.png differ diff --git a/tests/images/system_info-full-inverse-5in65.png b/tests/images/system_info-full-inverse-5in65.png index 8e836f3..a27c380 100644 Binary files a/tests/images/system_info-full-inverse-5in65.png and b/tests/images/system_info-full-inverse-5in65.png differ diff --git a/tests/images/system_info-full-inverse-7in5.png b/tests/images/system_info-full-inverse-7in5.png index 12627e1..55c54ba 100644 Binary files a/tests/images/system_info-full-inverse-7in5.png and b/tests/images/system_info-full-inverse-7in5.png differ diff --git a/tests/images/system_info-full-inverse-9in7.png b/tests/images/system_info-full-inverse-9in7.png index 0f0936e..74cf23d 100644 Binary files a/tests/images/system_info-full-inverse-9in7.png and b/tests/images/system_info-full-inverse-9in7.png differ diff --git a/tests/images/system_info-portrait-5in65.png b/tests/images/system_info-portrait-5in65.png index 7d4927d..8488258 100644 Binary files a/tests/images/system_info-portrait-5in65.png and b/tests/images/system_info-portrait-5in65.png differ diff --git a/tests/images/system_info-portrait-7in5.png b/tests/images/system_info-portrait-7in5.png index 2f0d9b1..eb01713 100644 Binary files a/tests/images/system_info-portrait-7in5.png and b/tests/images/system_info-portrait-7in5.png differ diff --git a/tests/images/system_info-portrait-9in7.png b/tests/images/system_info-portrait-9in7.png index 6eccd58..1317937 100644 Binary files a/tests/images/system_info-portrait-9in7.png and b/tests/images/system_info-portrait-9in7.png differ diff --git a/tests/images/system_info-portrait-inverse-5in65.png b/tests/images/system_info-portrait-inverse-5in65.png index f961f41..9b16578 100644 Binary files a/tests/images/system_info-portrait-inverse-5in65.png and b/tests/images/system_info-portrait-inverse-5in65.png differ diff --git a/tests/images/system_info-portrait-inverse-7in5.png b/tests/images/system_info-portrait-inverse-7in5.png index 3dfcf6f..1d64f80 100644 Binary files a/tests/images/system_info-portrait-inverse-7in5.png and b/tests/images/system_info-portrait-inverse-7in5.png differ diff --git a/tests/images/system_info-portrait-inverse-9in7.png b/tests/images/system_info-portrait-inverse-9in7.png index 6b16f00..eb61977 100644 Binary files a/tests/images/system_info-portrait-inverse-9in7.png and b/tests/images/system_info-portrait-inverse-9in7.png differ diff --git a/tests/test_system_info.py b/tests/test_system_info.py index f90910a..91fbdef 100644 --- a/tests/test_system_info.py +++ b/tests/test_system_info.py @@ -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 ( @@ -115,7 +116,8 @@ 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 @@ -123,6 +125,34 @@ def test_bars(share, upright, dark_at): 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 diff --git a/uv.lock b/uv.lock index 791e521..a460884 100644 --- a/uv.lock +++ b/uv.lock @@ -58,7 +58,7 @@ wheels = [ [[package]] name = "epdlib" version = "1.0.0.dev0" -source = { git = "https://github.com/txoof/epdlib?rev=b4dd600b35959c26fd235fe1af6be94e49fcc0db#b4dd600b35959c26fd235fe1af6be94e49fcc0db" } +source = { git = "https://github.com/txoof/epdlib?rev=4633fcad64cf5b99a6ea7a513fa6bcf497c28a79#4633fcad64cf5b99a6ea7a513fa6bcf497c28a79" } dependencies = [ { name = "pillow" }, ] @@ -328,8 +328,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "epdlib", git = "https://github.com/txoof/epdlib?rev=b4dd600b35959c26fd235fe1af6be94e49fcc0db" }, - { name = "epdlib", extras = ["it8951"], marker = "sys_platform == 'linux'", git = "https://github.com/txoof/epdlib?rev=b4dd600b35959c26fd235fe1af6be94e49fcc0db" }, + { name = "epdlib", git = "https://github.com/txoof/epdlib?rev=4633fcad64cf5b99a6ea7a513fa6bcf497c28a79" }, + { name = "epdlib", extras = ["it8951"], marker = "sys_platform == 'linux'", git = "https://github.com/txoof/epdlib?rev=4633fcad64cf5b99a6ea7a513fa6bcf497c28a79" }, { name = "fastapi", specifier = ">=0.115" }, { name = "jinja2", specifier = ">=3.1" }, { name = "pydantic", specifier = ">=2.9" },