chore: fix all clippy warnings across workspace
This commit is contained in:
212
scripts/render_terminal.py
Executable file
212
scripts/render_terminal.py
Executable file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render two terminal pane captures (with ANSI escapes) into a single PNG.
|
||||
|
||||
Usage:
|
||||
python3 scripts/render_terminal.py left.ansi right.ansi -o assets/screenshot.png
|
||||
python3 scripts/render_terminal.py left.ansi right.ansi --labels "alice" "bob" -o out.png
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# ── Theme (dark terminal) ────────────────────────────────────────────────────
|
||||
BG = (30, 30, 46) # base (catppuccin mocha-ish)
|
||||
FG = (205, 214, 244) # default text
|
||||
DIM = (108, 112, 134) # dim/grey
|
||||
GREEN = (166, 227, 161)
|
||||
CYAN = (137, 220, 235)
|
||||
YELLOW = (249, 226, 175)
|
||||
RED = (243, 139, 168)
|
||||
BLUE = (137, 180, 250)
|
||||
MAGENTA = (203, 166, 247)
|
||||
BOLD_WHITE = (255, 255, 255)
|
||||
BORDER = (69, 71, 90)
|
||||
TITLE_BG = (49, 50, 68)
|
||||
|
||||
ANSI_COLORS = {
|
||||
30: (30, 30, 46), 31: RED, 32: GREEN, 33: YELLOW,
|
||||
34: BLUE, 35: MAGENTA, 36: CYAN, 37: FG,
|
||||
90: DIM, 91: RED, 92: GREEN, 93: YELLOW,
|
||||
94: BLUE, 95: MAGENTA, 96: CYAN, 97: BOLD_WHITE,
|
||||
}
|
||||
|
||||
# ── ANSI parsing ─────────────────────────────────────────────────────────────
|
||||
ESC_RE = re.compile(r'\x1b\[([0-9;]*)m')
|
||||
|
||||
def parse_ansi_line(line):
|
||||
"""Yield (text, fg_color, bold) spans from an ANSI-escaped line."""
|
||||
fg = FG
|
||||
bold = False
|
||||
dim = False
|
||||
pos = 0
|
||||
for m in ESC_RE.finditer(line):
|
||||
if m.start() > pos:
|
||||
color = fg
|
||||
if dim and color == FG:
|
||||
color = DIM
|
||||
yield (line[pos:m.start()], color, bold)
|
||||
codes = m.group(1).split(';') if m.group(1) else ['0']
|
||||
for code_s in codes:
|
||||
code = int(code_s) if code_s else 0
|
||||
if code == 0:
|
||||
fg, bold, dim = FG, False, False
|
||||
elif code == 1:
|
||||
bold = True
|
||||
elif code == 2:
|
||||
dim = True
|
||||
elif code in ANSI_COLORS:
|
||||
fg = ANSI_COLORS[code]
|
||||
pos = m.end()
|
||||
tail = line[pos:]
|
||||
if tail:
|
||||
color = fg
|
||||
if dim and color == FG:
|
||||
color = DIM
|
||||
yield (tail, color, bold)
|
||||
|
||||
|
||||
def load_font(size):
|
||||
"""Try to load a monospace font."""
|
||||
candidates = [
|
||||
"/usr/share/fonts/google-noto/NotoSansMono-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||
"/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSansMono.ttf",
|
||||
"/usr/share/fonts/liberation-mono/LiberationMono-Regular.ttf",
|
||||
"/usr/share/fonts/google-droid-sans-mono-fonts/DroidSansMono.ttf",
|
||||
]
|
||||
for path in candidates:
|
||||
if Path(path).exists():
|
||||
return ImageFont.truetype(path, size)
|
||||
# fallback
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def load_bold_font(size):
|
||||
candidates = [
|
||||
"/usr/share/fonts/google-noto/NotoSansMono-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansMono-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",
|
||||
"/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono-Bold.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf",
|
||||
"/usr/share/fonts/liberation-mono/LiberationMono-Bold.ttf",
|
||||
]
|
||||
for path in candidates:
|
||||
if Path(path).exists():
|
||||
return ImageFont.truetype(path, size)
|
||||
return load_font(size)
|
||||
|
||||
|
||||
def strip_ansi(s):
|
||||
return ESC_RE.sub('', s)
|
||||
|
||||
|
||||
def render_pane(lines, width_chars, font, bold_font, font_size, line_height):
|
||||
"""Render terminal lines to an Image."""
|
||||
char_w = font.getbbox("M")[2]
|
||||
img_w = char_w * width_chars + 24 # 12px padding each side
|
||||
img_h = line_height * len(lines) + 16 # 8px padding top+bottom
|
||||
|
||||
img = Image.new("RGB", (img_w, img_h), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
y = 8
|
||||
for line in lines:
|
||||
x = 12
|
||||
for text, color, bold in parse_ansi_line(line):
|
||||
f = bold_font if bold else font
|
||||
draw.text((x, y), text, fill=color, font=f)
|
||||
x += f.getlength(text)
|
||||
y += line_height
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Render terminal panes to PNG")
|
||||
ap.add_argument("left", help="Left pane ANSI capture file")
|
||||
ap.add_argument("right", help="Right pane ANSI capture file")
|
||||
ap.add_argument("-o", "--output", default="assets/screenshot.png")
|
||||
ap.add_argument("--labels", nargs=2, default=["alice", "bob"],
|
||||
help="Labels for the two panes")
|
||||
ap.add_argument("--font-size", type=int, default=14)
|
||||
ap.add_argument("--width", type=int, default=58,
|
||||
help="Width of each pane in characters")
|
||||
args = ap.parse_args()
|
||||
|
||||
font_size = args.font_size
|
||||
line_height = int(font_size * 1.5)
|
||||
font = load_font(font_size)
|
||||
bold_font = load_bold_font(font_size)
|
||||
char_w = font.getbbox("M")[2]
|
||||
pane_w = char_w * args.width + 24
|
||||
|
||||
left_lines = Path(args.left).read_text().splitlines()
|
||||
right_lines = Path(args.right).read_text().splitlines()
|
||||
|
||||
# Render each pane
|
||||
left_img = render_pane(left_lines, args.width, font, bold_font,
|
||||
font_size, line_height)
|
||||
right_img = render_pane(right_lines, args.width, font, bold_font,
|
||||
font_size, line_height)
|
||||
|
||||
# Composite: title bar + two panes side by side
|
||||
title_h = 32
|
||||
gap = 2
|
||||
max_h = max(left_img.height, right_img.height)
|
||||
total_w = left_img.width + gap + right_img.width
|
||||
total_h = title_h + max_h
|
||||
|
||||
# Window chrome
|
||||
canvas = Image.new("RGB", (total_w, total_h), BORDER)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
# Title bar
|
||||
draw.rectangle([(0, 0), (total_w, title_h - 1)], fill=TITLE_BG)
|
||||
|
||||
# Traffic lights
|
||||
for i, color in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
|
||||
cx = 16 + i * 22
|
||||
cy = title_h // 2
|
||||
draw.ellipse([(cx - 6, cy - 6), (cx + 6, cy + 6)], fill=color)
|
||||
|
||||
# Pane labels
|
||||
label_font = load_font(font_size - 1)
|
||||
left_label = args.labels[0]
|
||||
right_label = args.labels[1]
|
||||
left_label_w = label_font.getlength(left_label)
|
||||
right_label_w = label_font.getlength(right_label)
|
||||
draw.text((left_img.width // 2 - left_label_w // 2, 7),
|
||||
left_label, fill=DIM, font=label_font)
|
||||
draw.text((left_img.width + gap + right_img.width // 2 - right_label_w // 2, 7),
|
||||
right_label, fill=DIM, font=label_font)
|
||||
|
||||
# Paste panes
|
||||
canvas.paste(left_img, (0, title_h))
|
||||
canvas.paste(right_img, (left_img.width + gap, title_h))
|
||||
|
||||
# Round corners (simple mask)
|
||||
radius = 10
|
||||
mask = Image.new("L", canvas.size, 255)
|
||||
mask_draw = ImageDraw.Draw(mask)
|
||||
mask_draw.rectangle([(0, 0), (radius, radius)], fill=0)
|
||||
mask_draw.pieslice([(0, 0), (radius * 2, radius * 2)], 180, 270, fill=255)
|
||||
mask_draw.rectangle([(total_w - radius, 0), (total_w, radius)], fill=0)
|
||||
mask_draw.pieslice([(total_w - radius * 2, 0), (total_w, radius * 2)], 270, 360, fill=255)
|
||||
|
||||
# Apply rounded corners with transparent background
|
||||
final = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||||
final.paste(canvas, mask=mask)
|
||||
|
||||
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
||||
final.save(args.output)
|
||||
print(f"Saved {args.output} ({final.width}x{final.height})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
92
scripts/screenshot.sh
Executable file
92
scripts/screenshot.sh
Executable file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/screenshot.sh — generate a README screenshot automatically
|
||||
set -euo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
INTERACTIVE=false
|
||||
[[ "${1:-}" == "--interactive" ]] && INTERACTIVE=true
|
||||
|
||||
SESSION="qpq-screenshot"
|
||||
SERVER_PORT=17123
|
||||
SERVER_ADDR="127.0.0.1:${SERVER_PORT}"
|
||||
DATA_DIR=$(mktemp -d)
|
||||
CERT="${DATA_DIR}/server-cert.der"
|
||||
KEY="${DATA_DIR}/server-key.der"
|
||||
QPQ="./target/debug/qpq"
|
||||
SERVER="./target/debug/qpq-server"
|
||||
SLOG="${DATA_DIR}/server.log"
|
||||
|
||||
cleanup() {
|
||||
[[ -n "${SERVER_PID:-}" ]] && kill "$SERVER_PID" 2>/dev/null || true
|
||||
tmux kill-session -t "$SESSION" 2>/dev/null || true
|
||||
# Keep DATA_DIR for debugging
|
||||
echo "Data dir: $DATA_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── Build ────────────────────────────────────────────────────────────────────
|
||||
echo "Building binaries..."
|
||||
cargo build --bin qpq --bin qpq-server 2>&1 | tail -1
|
||||
|
||||
# ── Start server ─────────────────────────────────────────────────────────────
|
||||
echo "Starting server on ${SERVER_ADDR}..."
|
||||
RUST_LOG=debug "$SERVER" \
|
||||
--allow-insecure-auth \
|
||||
--listen "$SERVER_ADDR" \
|
||||
--tls-cert "$CERT" \
|
||||
--tls-key "$KEY" \
|
||||
--data-dir "$DATA_DIR" \
|
||||
&>"$SLOG" &
|
||||
SERVER_PID=$!
|
||||
|
||||
for _ in $(seq 1 30); do [[ -f "$CERT" ]] && break; sleep 0.2; done
|
||||
if [[ ! -f "$CERT" ]]; then echo "ERROR: server did not start"; cat "$SLOG"; exit 1; fi
|
||||
echo "Server ready (PID ${SERVER_PID})"
|
||||
|
||||
# ── tmux session ─────────────────────────────────────────────────────────────
|
||||
tmux new-session -d -s "$SESSION" -x 114 -y 28
|
||||
|
||||
send_alice() { tmux send-keys -t "${SESSION}:0.0" "$1" Enter; }
|
||||
send_bob() { tmux send-keys -t "${SESSION}:0.1" "$1" Enter; }
|
||||
|
||||
# Start Alice (left pane)
|
||||
tmux send-keys -t "$SESSION" \
|
||||
"RUST_LOG=debug $QPQ repl --username alice --password demopass1 --server $SERVER_ADDR --ca-cert $CERT --state ${DATA_DIR}/alice.bin 2>${DATA_DIR}/alice-debug.log" Enter
|
||||
|
||||
# Start Bob (right pane)
|
||||
tmux split-window -h -t "$SESSION"
|
||||
tmux send-keys -t "$SESSION" \
|
||||
"RUST_LOG=debug $QPQ repl --username bob --password demopass2 --server $SERVER_ADDR --ca-cert $CERT --state ${DATA_DIR}/bob.bin 2>${DATA_DIR}/bob-debug.log" Enter
|
||||
|
||||
tmux select-layout -t "$SESSION" even-horizontal
|
||||
sleep 5
|
||||
|
||||
# Alice creates DM with Bob
|
||||
send_alice "/dm bob"
|
||||
sleep 4
|
||||
|
||||
# Wait for Bob's poller (8 seconds = 8 poll cycles)
|
||||
echo "Waiting for Bob to poll Welcome..."
|
||||
sleep 10
|
||||
|
||||
# Check Bob's list
|
||||
send_bob "/list"
|
||||
sleep 2
|
||||
|
||||
# Capture both panes
|
||||
{
|
||||
echo "=== Alice ==="
|
||||
tmux capture-pane -t "${SESSION}:0.0" -p
|
||||
echo ""
|
||||
echo "=== Bob ==="
|
||||
tmux capture-pane -t "${SESSION}:0.1" -p
|
||||
} > "${DATA_DIR}/capture.txt"
|
||||
|
||||
echo ""
|
||||
cat "${DATA_DIR}/capture.txt"
|
||||
echo ""
|
||||
echo "Server log tail:"
|
||||
tail -30 "$SLOG" 2>/dev/null || true
|
||||
echo ""
|
||||
echo "Debug logs in: $DATA_DIR"
|
||||
echo "Check: $DATA_DIR/alice-debug.log and $DATA_DIR/bob-debug.log"
|
||||
Reference in New Issue
Block a user