import argparse
import json
import os
import re
import subprocess
import sys
import time
from PIL import Image, ImageDraw, ImageFont
BASE16 = [
"#000000", "#cd0000", "#00cd00", "#cdcd00", "#0000ee", "#cd00cd", "#00cdcd", "#e5e5e5",
"#7f7f7f", "#ff0000", "#00ff00", "#ffff00", "#5c5cff", "#ff00ff", "#00ffff", "#ffffff",
]
CUBE = [0, 95, 135, 175, 215, 255]
FG_DEFAULT, BG_DEFAULT = "#c8ccd4", "#0e1116"
SESSION = "shotbox" DEJAVU = "/usr/share/fonts/truetype/dejavu/"
SGR = re.compile(r"\x1b\[([0-9;]*)m")
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
def redact(rows, extra):
rules = [(EMAIL, "you@example.com")] + list(extra)
for row in rows:
line = "".join(c[0] for c in row)
for pattern, replacement in rules:
for m in pattern.finditer(line):
span = m.end() - m.start()
fill = replacement.ljust(span)[:span]
for i, ch in enumerate(fill):
cell = row[m.start() + i]
row[m.start() + i] = (ch,) + cell[1:]
def palette(n):
if n < 16:
return BASE16[n]
if n < 232:
n -= 16
return "#%02x%02x%02x" % (CUBE[n // 36], CUBE[(n // 6) % 6], CUBE[n % 6])
v = 8 + (n - 232) * 10
return "#%02x%02x%02x" % (v, v, v)
def cells(text):
rows = []
for raw in text.rstrip("\n").split("\n"):
fg, bg, bold, row, i = None, None, False, [], 0
while i < len(raw):
m = SGR.match(raw, i)
if m:
args = [int(x) for x in m.group(1).split(";") if x != ""] or [0]
j = 0
while j < len(args):
a = args[j]
if a == 0:
fg, bg, bold = None, None, False
elif a == 1:
bold = True
elif a == 22:
bold = False
elif a == 39:
fg = None
elif a == 49:
bg = None
elif 30 <= a <= 37:
fg = palette(a - 30)
elif 90 <= a <= 97:
fg = palette(a - 90 + 8)
elif 40 <= a <= 47:
bg = palette(a - 40)
elif 100 <= a <= 107:
bg = palette(a - 100 + 8)
elif a in (38, 48) and j + 2 < len(args) and args[j + 1] == 5:
if a == 38:
fg = palette(args[j + 2])
else:
bg = palette(args[j + 2])
j += 2
j += 1
i = m.end()
continue
row.append((raw[i], fg, bg, bold))
i += 1
rows.append(row)
return rows
def runs(row):
out, start = [], 0
for i, cell in enumerate(row):
if i and cell[1:] != row[i - 1][1:]:
out.append((start, "".join(c[0] for c in row[start:i])) + row[start][1:])
start = i
if row:
out.append((start, "".join(c[0] for c in row[start:])) + row[start][1:])
return out
def newest_build():
builds = [p for p in ("./target/release/cctop", "./target/debug/cctop")
if os.path.exists(p)]
if not builds:
sys.exit("build cctop first: cargo build --release")
binary = max(builds, key=os.path.getmtime)
print(f"capturing {binary}")
return binary
def capture(size, keys, settle):
cols, lines = size.split("x")
subprocess.run(["tmux", "kill-session", "-t", SESSION],
stderr=subprocess.DEVNULL, check=False)
binary = newest_build()
subprocess.run(["tmux", "new-session", "-d", "-s", SESSION, "-x", cols, "-y", lines,
f"CI=1 {binary}"], check=True)
try:
time.sleep(settle)
for key in keys:
subprocess.run(["tmux", "send-keys", "-t", SESSION, key], check=True)
time.sleep(0.4)
if keys:
time.sleep(2)
out = subprocess.run(["tmux", "capture-pane", "-t", SESSION, "-p", "-e"],
capture_output=True, text=True, check=True)
return out.stdout
finally:
subprocess.run(["tmux", "kill-session", "-t", SESSION],
stderr=subprocess.DEVNULL, check=False)
BOXES = set("─━│┊╭╮╯╰█░")
def draw_box(draw, ch, x0, y0, cw, lh, colour, bg):
if ch not in BOXES:
return False
x1, y1 = x0 + cw, y0 + lh
cx, cy = x0 + cw / 2, y0 + lh / 2
light = max(1.0, round(lh / 20))
heavy = max(2.0, round(lh / 11))
def hbar(a, b, t):
draw.rectangle([a, cy - t / 2, b, cy + t / 2], fill=colour)
def vbar(a, b, t):
draw.rectangle([cx - t / 2, a, cx + t / 2, b], fill=colour)
if ch == "█":
draw.rectangle([x0, y0, x1, y1], fill=colour)
elif ch == "░":
base = tuple(int(bg[i:i + 2], 16) for i in (1, 3, 5))
want = tuple(int(colour[i:i + 2], 16) for i in (1, 3, 5))
draw.rectangle([x0, y0, x1, y1],
fill=tuple(round(b + (w - b) * 0.25) for b, w in zip(base, want)))
elif ch == "─":
hbar(x0, x1, light)
elif ch == "━":
hbar(x0, x1, heavy)
elif ch == "│":
vbar(y0, y1, light)
elif ch == "┊":
step = lh / 7
for k in (0, 2, 4, 6):
vbar(y0 + k * step, y0 + (k + 1) * step, light)
else:
r = min(cw, lh) * 0.45
right, down = ch in "╭╰", ch in "╭╮"
hbar(cx + r if right else x0, x1 if right else cx - r, light)
vbar(cy + r if down else y0, y1 if down else cy - r, light)
ox = cx + r if right else cx - r
oy = cy + r if down else cy - r
start = {(True, True): 180, (False, True): 270,
(False, False): 0, (True, False): 90}[(right, down)]
draw.arc([ox - r, oy - r, ox + r, oy + r], start, start + 90,
fill=colour, width=int(light))
return True
def render(rows, scale, pt):
fs = pt * scale
reg = ImageFont.truetype(DEJAVU + "DejaVuSansMono.ttf", fs)
bold_f = ImageFont.truetype(DEJAVU + "DejaVuSansMono-Bold.ttf", fs)
fallback = ImageFont.truetype(DEJAVU + "DejaVuSans.ttf", fs)
notdef = reg.getmask("").getbbox()
cw, lh, pad = reg.getlength("M"), int(fs * 1.35), 14 * scale
ascent, descent = reg.getmetrics()
lead = max(0, (lh - (ascent + descent)) // 2)
def absent(ch):
return reg.getmask(ch).getbbox() == notdef
cols = max(len(r) for r in rows)
img = Image.new("RGB", (int(cols * cw + pad * 2), int(len(rows) * lh + pad * 2)),
BG_DEFAULT)
draw = ImageDraw.Draw(img)
for y, row in enumerate(rows):
for col, text, _fg, bg, _b in runs(row):
if bg:
x, top = pad + col * cw, pad + y * lh
draw.rectangle([x, top, x + len(text) * cw, top + lh], fill=bg)
for y, row in enumerate(rows):
for col, text, fg, _bg, bold in runs(row):
if not text.strip():
continue
colour, font = fg or FG_DEFAULT, bold_f if bold else reg
if any(c in BOXES for c in text):
for k, ch in enumerate(text):
x0, y0 = pad + (col + k) * cw, pad + y * lh
if not draw_box(draw, ch, x0, y0, cw, lh, colour, _bg or BG_DEFAULT):
draw.text((x0, y0 + lead), ch,
font=fallback if absent(ch) else font, fill=colour)
continue
if any(absent(c) for c in text):
for k, ch in enumerate(text):
draw.text((pad + (col + k) * cw, pad + y * lh + lead), ch,
font=fallback if absent(ch) else font, fill=colour)
else:
draw.text((pad + col * cw, pad + y * lh + lead), text,
font=font, fill=colour)
return img
def to_ansi(rows):
out = []
for row in rows:
line = ["\x1b[0m"]
for _col, text, fg, bg, bold in runs(row):
codes = ["0"]
if bold:
codes.append("1")
for colour, prefix in ((fg, "38"), (bg, "48")):
if colour:
codes.append(f"{prefix};5;{nearest256(colour)}")
line.append(f"\x1b[{';'.join(codes)}m{text}")
out.append("".join(line) + "\x1b[0m")
return "\r\n".join(out)
def nearest256(hexcolour):
want = tuple(int(hexcolour[i:i + 2], 16) for i in (1, 3, 5))
best, dist = 0, 1 << 30
for n in range(256):
have = tuple(int(palette(n)[i:i + 2], 16) for i in (1, 3, 5))
d = sum((a - b) ** 2 for a, b in zip(want, have))
if d < dist:
best, dist = n, d
return best
DEMO = [
("", 4),
("Down", 1), ("Down", 3),
("Right", 2), ("Right", 2), ("Right", 4),
("Right", 1), ("Right", 1), ("Right", 1), ("Right", 5),
("/", 2), ("cctop", 4), ("Escape", 3),
]
KEYNAMES = {"Down", "Up", "Left", "Right", "Escape", "Tab", "Enter", "Space"}
def record(size, settle, extra, scale):
cols, lines = size.split("x")
subprocess.run(["tmux", "kill-session", "-t", SESSION],
stderr=subprocess.DEVNULL, check=False)
binary = newest_build()
subprocess.run(["tmux", "new-session", "-d", "-s", SESSION, "-x", cols, "-y", lines,
f"CI=1 {binary}"], check=True)
images, casts = [], []
try:
time.sleep(settle)
for key, hold in DEMO:
if key:
flag = [] if key in KEYNAMES else ["-l"]
subprocess.run(["tmux", "send-keys", "-t", SESSION] + flag + [key],
check=True)
time.sleep(0.35)
for _ in range(hold):
out = subprocess.run(["tmux", "capture-pane", "-t", SESSION, "-p", "-e"],
capture_output=True, text=True, check=True)
rows = cells(out.stdout)
redact(rows, extra)
images.append(render(rows, scale, 14))
casts.append(to_ansi(rows))
time.sleep(0.55)
finally:
subprocess.run(["tmux", "kill-session", "-t", SESSION],
stderr=subprocess.DEVNULL, check=False)
return images, casts
def write_cast(path, frames, size, delay):
cols, lines = size.split("x")
header = {"version": 2, "width": int(cols), "height": int(lines),
"env": {"TERM": "xterm-256color", "SHELL": "/bin/sh"}}
with open(path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(header) + "\n")
for i, frame in enumerate(frames):
payload = "\x1b[H\x1b[2J" + frame
fh.write(json.dumps([round(i * delay, 3), "o", payload]) + "\n")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("out")
ap.add_argument("--size", default="146x30", help="terminal size, COLSxLINES")
ap.add_argument("--keys", default="", help="keys to send, e.g. Tab*7")
ap.add_argument("--settle", type=float, default=18.0,
help="seconds to wait for the first full load")
ap.add_argument("--scale", type=int, default=2)
ap.add_argument("--redact", action="append", default=[], metavar="TEXT[=WITH]",
help="also scrub this literal; email addresses always are")
ap.add_argument("--record", metavar="CAST",
help="record the demo: writes an animated GIF to `out` and "
"an asciinema cast to this path")
args = ap.parse_args()
extra = []
for rule in args.redact:
target, _, replacement = rule.partition("=")
extra.append((re.compile(re.escape(target)), replacement or "redacted"))
keys = []
if args.keys:
name, _, count = args.keys.partition("*")
keys = [name] * int(count or 1)
if args.record:
delay = 0.9
images, casts = record(args.size, args.settle, extra, args.scale)
write_cast(args.record, casts, args.size, delay)
first, rest = images[0], images[1:]
first.save(args.out, save_all=True, append_images=rest,
duration=int(delay * 1000), loop=0, optimize=True)
print(f"wrote {args.out} {first.size[0]}x{first.size[1]} "
f"({len(images)} frames) and {args.record}")
return
rows = cells(capture(args.size, keys, args.settle))
redact(rows, extra)
img = render(rows, args.scale, 15)
img.save(args.out, optimize=True)
print(f"wrote {args.out} {img.size[0]}x{img.size[1]}")
if __name__ == "__main__":
sys.exit(main())