Publish showcase_core.py from marimo
Browse files- showcase_core.py +346 -0
showcase_core.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build the capability gallery: one screenshot per thing the harness can do.
|
| 2 |
+
|
| 3 |
+
Run it on a real desktop (or under Xvfb) and it writes ``gallery/<id>.png`` for every
|
| 4 |
+
capability registered in this file, plus ``gallery/index.json`` and a gallery page.
|
| 5 |
+
|
| 6 |
+
The screenshots are of the harness's *actual* behaviour, not mock-ups:
|
| 7 |
+
|
| 8 |
+
* **terminal shots** are captured stdout/stderr from a real command, rendered to an image
|
| 9 |
+
* **desktop shots** are real screen captures taken while the harness acts on a real
|
| 10 |
+
X11/Windows desktop (element boxes, the resolved click point, and the app's own
|
| 11 |
+
reaction are drawn or visible)
|
| 12 |
+
|
| 13 |
+
Nothing here is faked: if a command fails, its real failure is what gets pictured.
|
| 14 |
+
|
| 15 |
+
python showcase.py # everything
|
| 16 |
+
python showcase.py --only clarify # one capability by id substring
|
| 17 |
+
python showcase.py --list # list ids without rendering
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import platform
|
| 25 |
+
import shutil
|
| 26 |
+
import subprocess
|
| 27 |
+
import sys
|
| 28 |
+
import textwrap
|
| 29 |
+
import time
|
| 30 |
+
from dataclasses import dataclass, field
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
from typing import Callable, Optional
|
| 33 |
+
|
| 34 |
+
ROOT = Path(__file__).resolve().parent
|
| 35 |
+
GALLERY = ROOT / "gallery"
|
| 36 |
+
PY = sys.executable
|
| 37 |
+
MONO_TTFS = (
|
| 38 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", # Debian/Ubuntu
|
| 39 |
+
"C:/Windows/Fonts/consola.ttf", # Windows (Consolas)
|
| 40 |
+
"C:/Windows/Fonts/lucon.ttf", # Windows (Lucida)
|
| 41 |
+
"/System/Library/Fonts/Menlo.ttc", # macOS
|
| 42 |
+
"/Library/Fonts/Menlo.ttc",
|
| 43 |
+
)
|
| 44 |
+
MONO_BOLD_TTFS = (
|
| 45 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",
|
| 46 |
+
"C:/Windows/Fonts/consolab.ttf",
|
| 47 |
+
"/System/Library/Fonts/Menlo.ttc",
|
| 48 |
+
)
|
| 49 |
+
SCREEN_W, SCREEN_H = 1280, 800
|
| 50 |
+
TERM_COLS = 108
|
| 51 |
+
TERM_BG = (13, 17, 23)
|
| 52 |
+
TERM_FG = (201, 209, 217)
|
| 53 |
+
TERM_GREEN = (63, 185, 80)
|
| 54 |
+
TERM_RED = (248, 81, 73)
|
| 55 |
+
TERM_DIM = (139, 148, 158)
|
| 56 |
+
|
| 57 |
+
CATALOG: list["Shot"] = []
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass
|
| 61 |
+
class Shot:
|
| 62 |
+
"""One capability, and the evidence that it works."""
|
| 63 |
+
|
| 64 |
+
id: str
|
| 65 |
+
category: str
|
| 66 |
+
title: str
|
| 67 |
+
shows: list[str] # the public callables this picture demonstrates
|
| 68 |
+
note: str = "" # one line the card can print under the image
|
| 69 |
+
kind: str = "terminal" # "terminal" | "desktop"
|
| 70 |
+
argv: list[str] | None = None # for terminal shots: the command to run
|
| 71 |
+
env: Optional[dict] = None # extra environment for the command
|
| 72 |
+
code: str | None = None # for terminal shots: a python snippet to run
|
| 73 |
+
scene: str | None = None # for desktop shots: a named scene below
|
| 74 |
+
make: Optional[Callable] = None # for desktop shots: builds the picture
|
| 75 |
+
prepare: Optional[Callable] = None # for terminal shots: set the scene up first
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def shot(**kwargs):
|
| 79 |
+
def wrap(fn=None):
|
| 80 |
+
s = Shot(**kwargs)
|
| 81 |
+
CATALOG.append(s)
|
| 82 |
+
return fn
|
| 83 |
+
return wrap
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# --------------------------------------------------------------------------- #
|
| 87 |
+
# rendering
|
| 88 |
+
# --------------------------------------------------------------------------- #
|
| 89 |
+
|
| 90 |
+
def _fonts(size=15, bold=False):
|
| 91 |
+
from PIL import ImageFont
|
| 92 |
+
|
| 93 |
+
for path in (MONO_BOLD_TTFS if bold else MONO_TTFS):
|
| 94 |
+
try:
|
| 95 |
+
return ImageFont.truetype(path, size)
|
| 96 |
+
except Exception:
|
| 97 |
+
continue
|
| 98 |
+
return ImageFont.load_default() # last resort: still produces a picture
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _wrap(text: str, cols: int) -> list[str]:
|
| 102 |
+
out: list[str] = []
|
| 103 |
+
for line in text.replace("\t", " ").splitlines():
|
| 104 |
+
if not line:
|
| 105 |
+
out.append("")
|
| 106 |
+
continue
|
| 107 |
+
out.extend(textwrap.wrap(line, cols, drop_whitespace=False,
|
| 108 |
+
replace_whitespace=False) or [""])
|
| 109 |
+
return out
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def terminal_image(title: str, output: str, subtitle: str = "") -> "Image.Image":
|
| 113 |
+
"""Render captured output as a terminal-window screenshot."""
|
| 114 |
+
from PIL import Image, ImageDraw
|
| 115 |
+
|
| 116 |
+
body_font, title_font = _fonts(15), _fonts(14, bold=True)
|
| 117 |
+
lines = _wrap(output.rstrip("\n") or "(no output)", TERM_COLS)
|
| 118 |
+
wrapped_title = _wrap(title, 78)
|
| 119 |
+
sub = _wrap(subtitle, 96) if subtitle else []
|
| 120 |
+
line_h, pad = 21, 18
|
| 121 |
+
head_h = 46 + 20 * len(wrapped_title) + 19 * len(sub)
|
| 122 |
+
height = head_h + line_h * len(lines) + 26
|
| 123 |
+
|
| 124 |
+
image = Image.new("RGB", (1240, height), TERM_BG)
|
| 125 |
+
draw = ImageDraw.Draw(image)
|
| 126 |
+
draw.rectangle([0, 0, 1240, 34], fill=(22, 27, 34))
|
| 127 |
+
for i, colour in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
|
| 128 |
+
draw.ellipse([18 + i * 22, 12, 30 + i * 22, 24], fill=colour)
|
| 129 |
+
y = 46
|
| 130 |
+
for line in wrapped_title:
|
| 131 |
+
draw.text((24, y), line, font=title_font, fill=(230, 237, 243))
|
| 132 |
+
y += 20
|
| 133 |
+
for line in sub:
|
| 134 |
+
draw.text((24, y), line, font=_fonts(13), fill=TERM_DIM)
|
| 135 |
+
y += 19
|
| 136 |
+
y = head_h
|
| 137 |
+
for line in lines:
|
| 138 |
+
colour = TERM_FG
|
| 139 |
+
stripped = line.strip()
|
| 140 |
+
if stripped.startswith("$ ") or stripped.startswith("python -m"):
|
| 141 |
+
colour = TERM_GREEN
|
| 142 |
+
elif any(word in stripped for word in ("result: failure", "ERROR", "FAILED",
|
| 143 |
+
"refused", "Traceback", "Error:")):
|
| 144 |
+
colour = TERM_RED
|
| 145 |
+
elif stripped.startswith("result: success") or stripped.startswith("score: correct"):
|
| 146 |
+
colour = TERM_GREEN
|
| 147 |
+
draw.text((24, y), line, font=body_font, fill=colour)
|
| 148 |
+
y += line_h
|
| 149 |
+
return image
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def run_argv(argv: list[str], *, extra_env: dict | None = None) -> tuple[int, str]:
|
| 153 |
+
env = dict(os.environ, **(extra_env or {}))
|
| 154 |
+
proc = subprocess.run(argv, capture_output=True, text=True, timeout=900, env=env)
|
| 155 |
+
text = proc.stdout + (("\n" + proc.stderr) if proc.stderr.strip() else "")
|
| 156 |
+
return proc.returncode, text
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _header(display: str) -> str:
|
| 160 |
+
return (f"$ {display}" + chr(10))
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# --------------------------------------------------------------------------- #
|
| 164 |
+
# the desktop: a real X server, real capture, real input
|
| 165 |
+
# --------------------------------------------------------------------------- #
|
| 166 |
+
|
| 167 |
+
DISPLAY_PROC = None
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _display_works() -> bool:
|
| 171 |
+
return subprocess.run(["xdpyinfo"], capture_output=True).returncode == 0
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def ensure_display() -> str:
|
| 175 |
+
"""Give the process a real display, starting Xvfb on Linux when there is none.
|
| 176 |
+
|
| 177 |
+
Verified rather than assumed: a stale ``DISPLAY`` pointing at a dead server is
|
| 178 |
+
worse than no display at all, because every scene then fails on ``tk.Tk()``.
|
| 179 |
+
"""
|
| 180 |
+
global DISPLAY_PROC
|
| 181 |
+
if platform.system() in ("Windows", "Darwin"):
|
| 182 |
+
return os.environ.get("DISPLAY", "(native)")
|
| 183 |
+
if os.environ.get("DISPLAY") and _display_works():
|
| 184 |
+
return os.environ["DISPLAY"]
|
| 185 |
+
if shutil.which("Xvfb") is None:
|
| 186 |
+
return "(none)"
|
| 187 |
+
os.environ["DISPLAY"] = ":99"
|
| 188 |
+
Path.home().joinpath(".Xauthority").touch()
|
| 189 |
+
os.environ["XAUTHORITY"] = str(Path.home() / ".Xauthority")
|
| 190 |
+
DISPLAY_PROC = subprocess.Popen(
|
| 191 |
+
["Xvfb", ":99", "-screen", "0", f"{SCREEN_W}x{SCREEN_H}x24", "-ac", "-nolisten", "tcp"],
|
| 192 |
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
|
| 193 |
+
)
|
| 194 |
+
for _ in range(80):
|
| 195 |
+
if _display_works():
|
| 196 |
+
break
|
| 197 |
+
time.sleep(0.25)
|
| 198 |
+
return os.environ["DISPLAY"]
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
SCENES: dict[str, str] = {}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def scene(name: str, source: str) -> str:
|
| 205 |
+
"""Register a Tk app used as the desktop a capability is demonstrated on."""
|
| 206 |
+
SCENES[name] = source
|
| 207 |
+
return name
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def open_scene(name: str, tmp: Path) -> subprocess.Popen:
|
| 211 |
+
"""Launch a scene on the real display and wait until its window is mapped."""
|
| 212 |
+
marker = tmp / f"{name}.marker"
|
| 213 |
+
source = SCENES[name].replace("__MARKER__", str(marker))
|
| 214 |
+
script = tmp / f"{name}.py"
|
| 215 |
+
script.write_text(source)
|
| 216 |
+
proc = subprocess.Popen([PY, str(script)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
| 217 |
+
ready = Path(str(marker) + ".ready")
|
| 218 |
+
deadline = time.time() + 25
|
| 219 |
+
while time.time() < deadline and not ready.exists():
|
| 220 |
+
if proc.poll() is not None:
|
| 221 |
+
raise RuntimeError(f"scene {name!r} exited early: {proc.stderr.read().decode()[:400]}")
|
| 222 |
+
time.sleep(0.2)
|
| 223 |
+
if not ready.exists():
|
| 224 |
+
raise RuntimeError(f"scene {name!r} never mapped a window")
|
| 225 |
+
time.sleep(0.6) # let the toolkit finish painting
|
| 226 |
+
return proc
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def close_scene(proc: subprocess.Popen) -> None:
|
| 230 |
+
proc.terminate()
|
| 231 |
+
try:
|
| 232 |
+
proc.wait(timeout=10)
|
| 233 |
+
except subprocess.TimeoutExpired:
|
| 234 |
+
proc.kill()
|
| 235 |
+
proc.wait(timeout=5)
|
| 236 |
+
time.sleep(0.5)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def grab() -> "Image.Image":
|
| 240 |
+
from tiny_computer_agent import GrabScreen
|
| 241 |
+
return GrabScreen(monitor=1).capture().frame
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def annotate(image, elements, *, click=None, title=""):
|
| 245 |
+
"""Draw what the harness *saw* (boxes + labels) and where it clicked."""
|
| 246 |
+
from PIL import ImageDraw
|
| 247 |
+
out = image.convert("RGB").copy()
|
| 248 |
+
draw = ImageDraw.Draw(out)
|
| 249 |
+
font = _fonts(13, bold=True)
|
| 250 |
+
for e in elements:
|
| 251 |
+
x, y, w, h = e.x, e.y, max(e.width, 1), max(e.height, 1)
|
| 252 |
+
colour = (255, 138, 0) if e.kind != "grid" else (90, 120, 200)
|
| 253 |
+
draw.rectangle([x, y, x + w, y + h], outline=colour, width=2)
|
| 254 |
+
tag = f"{e.label}"[:34]
|
| 255 |
+
tw = draw.textlength(tag, font=font)
|
| 256 |
+
draw.rectangle([x, max(0, y - 17), x + tw + 8, y], fill=colour)
|
| 257 |
+
draw.text((x + 4, max(0, y - 16)), tag, font=font, fill=(16, 16, 16))
|
| 258 |
+
if click is not None:
|
| 259 |
+
cx, cy = click
|
| 260 |
+
draw.line([cx - 22, cy, cx + 22, cy], fill=(255, 0, 60), width=2)
|
| 261 |
+
draw.line([cx, cy - 22, cx, cy + 22], fill=(255, 0, 60), width=2)
|
| 262 |
+
draw.ellipse([cx - 9, cy - 9, cx + 9, cy + 9], outline=(255, 0, 60), width=3)
|
| 263 |
+
if title:
|
| 264 |
+
draw.rectangle([0, 0, out.width, 30], fill=(20, 22, 28))
|
| 265 |
+
draw.text((12, 8), title, font=_fonts(15, bold=True), fill=(255, 255, 255))
|
| 266 |
+
return out
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def save(image, sid: str) -> Path:
|
| 270 |
+
GALLERY.mkdir(exist_ok=True)
|
| 271 |
+
target = GALLERY / f"{sid}.png"
|
| 272 |
+
image.save(target, optimize=True)
|
| 273 |
+
return target
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# --------------------------------------------------------------------------- #
|
| 277 |
+
# rendering a shot
|
| 278 |
+
# --------------------------------------------------------------------------- #
|
| 279 |
+
|
| 280 |
+
def render_terminal(s: "Shot", tmp: Optional[Path] = None) -> "Image.Image":
|
| 281 |
+
"""Run the shot's command for real and picture what it printed."""
|
| 282 |
+
cleanup = s.prepare(tmp) if s.prepare is not None else None
|
| 283 |
+
try:
|
| 284 |
+
return _terminal_body(s)
|
| 285 |
+
finally:
|
| 286 |
+
if cleanup is not None:
|
| 287 |
+
cleanup()
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _terminal_body(s: "Shot") -> "Image.Image":
|
| 291 |
+
if s.argv is not None:
|
| 292 |
+
argv = [str(a).replace("{py}", PY) for a in s.argv]
|
| 293 |
+
_, text = run_argv(argv, extra_env=s.env)
|
| 294 |
+
shown = " ".join("python" if a == PY else str(a) for a in s.argv)
|
| 295 |
+
else:
|
| 296 |
+
argv = [PY, "-c", s.code]
|
| 297 |
+
_, text = run_argv(argv, extra_env=s.env)
|
| 298 |
+
shown = "python -c # " + (s.note or s.title)
|
| 299 |
+
return terminal_image(s.title, text, shown)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def render_desktop(s: "Shot", tmp: Path) -> "Image.Image":
|
| 303 |
+
"""Run the shot's desktop scene for real and picture the harness acting on it."""
|
| 304 |
+
if s.make is None:
|
| 305 |
+
raise RuntimeError(f"desktop shot {s.id!r} has no make() callable")
|
| 306 |
+
if not os.environ.get("DISPLAY"):
|
| 307 |
+
raise RuntimeError(f"desktop shot {s.id!r} needs a display")
|
| 308 |
+
return s.make(tmp)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def overlay_lines(image, lines, *, title=""):
|
| 312 |
+
"""A dark strip of text under a picture - for the episode trace next to the pixels."""
|
| 313 |
+
from PIL import ImageDraw
|
| 314 |
+
out = image.convert("RGB").copy()
|
| 315 |
+
draw = ImageDraw.Draw(out)
|
| 316 |
+
font = _fonts(15)
|
| 317 |
+
strip = 24 * len(lines) + 14
|
| 318 |
+
draw.rectangle([0, out.height - strip, out.width, out.height], fill=(13, 17, 23))
|
| 319 |
+
y = out.height - strip + 7
|
| 320 |
+
for line in lines:
|
| 321 |
+
draw.text((12, y), line, font=font, fill=TERM_FG)
|
| 322 |
+
y += 24
|
| 323 |
+
if title:
|
| 324 |
+
draw.rectangle([0, 0, out.width, 30], fill=(20, 22, 28))
|
| 325 |
+
draw.text((12, 8), title, font=_fonts(15, bold=True), fill=(255, 255, 255))
|
| 326 |
+
return out
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def side_by_side(left, right, *, title="", gap_label=("before", "after")):
|
| 330 |
+
"""Two frames of the same window, split down the middle."""
|
| 331 |
+
from PIL import Image, ImageDraw
|
| 332 |
+
width, height = left.width + right.width + 4, max(left.height, right.height) + 34
|
| 333 |
+
out = Image.new("RGB", (width, height), (13, 17, 23))
|
| 334 |
+
draw = ImageDraw.Draw(out)
|
| 335 |
+
out.paste(left, (0, 34))
|
| 336 |
+
out.paste(right, (left.width + 4, 34))
|
| 337 |
+
draw.rectangle([left.width, 34, left.width + 4, height], fill=(255, 255, 255))
|
| 338 |
+
draw.rectangle([0, 0, width, 30], fill=(20, 22, 28))
|
| 339 |
+
draw.text((12, 8), title, font=_fonts(15, bold=True), fill=(255, 255, 255))
|
| 340 |
+
draw.text((12, 16 + left.height), gap_label[0], font=_fonts(14, bold=True), fill=TERM_DIM)
|
| 341 |
+
draw.text((left.width + 16, 16 + left.height), gap_label[1], font=_fonts(14, bold=True), fill=TERM_DIM)
|
| 342 |
+
return out
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def crop(image, box):
|
| 346 |
+
return image.convert("RGB").crop(box)
|