Skip to content

Commit 763aa4e

Browse files
authored
Merge pull request #10 from scoobynko/chore/readme-logo
chore: add animated logo to README and PyPI page
2 parents 58810ab + a503ed1 commit 763aa4e

3 files changed

Lines changed: 304 additions & 2 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
# 💀 Welchost
1+
<p align="center">
2+
<img src="https://raw.githubusercontent.com/scoobynko/welchost/main/assets/welchost-logo.gif" alt="Welchost" width="620">
3+
</p>
24

3-
> A macOS CLI that creates and manages a welcome screen for the [Ghostty](https://ghostty.org) terminal.
5+
<h1 align="center">Welchost</h1>
6+
7+
<p align="center">
8+
A macOS CLI that creates and manages a welcome screen for the <a href="https://ghostty.org">Ghostty</a> terminal.
9+
</p>
410

511
Welchost generates a banner that greets you every time you open Ghostty: big
612
[pyfiglet](https://github.com/pwaller/pyfiglet) ASCII art, solid colors or

assets/welchost-logo.gif

22.7 KB
Loading

scripts/export_logo.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
"""Export the Welchost splash logo (pixel-art ghost + wordmark) to PNG / GIF.
2+
3+
The logo is generated at runtime by ``welchost.tui.logo`` as a Rich ``Text`` grid
4+
of block glyphs in the terracotta accent. Rather than rasterize those glyphs with
5+
a font (which never matches a terminal's tall 1:2 cells), this script decomposes
6+
each block character into the *pixels it represents* on a 1-wide x 2-tall sub-cell
7+
grid, then scales it up. That reproduces the terminal exactly and makes animation
8+
free: each animation frame is just another grid to decompose.
9+
10+
Glyph -> (top sub-pixel, bottom sub-pixel):
11+
' ' -> background '█' -> accent, accent
12+
'▀' -> accent, background '▄' -> background, accent
13+
'░' '▒' -> shade, shade (the double_blocky two-tone seen in the TUI)
14+
15+
Requires Pillow (dev-only tool, not a project dependency): pip install Pillow
16+
17+
Usage:
18+
python scripts/export_logo.py [OUT.png] # static, frame 0
19+
python scripts/export_logo.py OUT.gif --gif # animated splash
20+
python scripts/export_logo.py ... --scale 14 --bg "#1a1a1a" --frames-dir DIR
21+
python scripts/export_logo.py ... --bg transparent # PNG only (GIF needs solid)
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import argparse
27+
import sys
28+
from pathlib import Path
29+
30+
from PIL import Image
31+
32+
# Make src/ importable when run from the repo root.
33+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
34+
35+
from welchost.themes import ACCENT # noqa: E402
36+
from welchost.tui.logo import _FRAMES, _ghost_frame, _wordmark # noqa: E402
37+
38+
DEFAULT_BG = "#1a1a1a" # Ghostty-ish dark, so the ░ shade reads with depth (Image #2)
39+
40+
41+
def hex_rgb(value: str) -> tuple[int, int, int]:
42+
v = value.lstrip("#")
43+
return int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16)
44+
45+
46+
def blend(bg: tuple[int, int, int], fg: tuple[int, int, int], t: float) -> tuple[int, int, int]:
47+
return tuple(round(b + (f - b) * t) for b, f in zip(bg, fg, strict=True)) # type: ignore[return-value]
48+
49+
50+
def frame_lines(frame: int) -> list[str]:
51+
"""Ghost beside the wordmark for one animation frame — the same composition as
52+
``logo_text`` minus the shell-prompt/tagline chrome. The ghost floats inside a
53+
fixed-size lane, so every frame has identical dimensions (required for a GIF)."""
54+
word = _wordmark()
55+
ghost = _ghost_frame(frame)
56+
gw = max((len(g) for g in ghost), default=0)
57+
total = max(len(ghost), len(word))
58+
g_off = (total - len(ghost)) // 2
59+
w_off = (total - len(word)) // 2
60+
61+
rows: list[str] = []
62+
for i in range(total):
63+
gi, wi = i - g_off, i - w_off
64+
g = ghost[gi] if 0 <= gi < len(ghost) else ""
65+
w = word[wi] if 0 <= wi < len(word) else ""
66+
rows.append(g.ljust(gw) + " " + w)
67+
width = max((len(r) for r in rows), default=0)
68+
return [r.ljust(width) for r in rows]
69+
70+
71+
def render_frame(
72+
rows: list[str],
73+
scale: int,
74+
padding: int,
75+
bg: tuple[int, int, int] | None,
76+
accent: tuple[int, int, int],
77+
shade: tuple[int, int, int],
78+
) -> Image.Image:
79+
"""Decompose block glyphs to a sub-pixel grid and scale up with nearest-neighbor
80+
so the pixels stay crisp (no anti-alias blur)."""
81+
transparent = bg is None
82+
fill_bg = (0, 0, 0, 0) if transparent else (*bg, 255)
83+
a, s = (*accent, 255), (*shade, 255)
84+
85+
cols = max((len(r) for r in rows), default=0)
86+
grid_w, grid_h = cols, len(rows) * 2 # each char row = 2 stacked sub-pixels
87+
img = Image.new("RGBA", (grid_w, grid_h), fill_bg)
88+
px = img.load()
89+
90+
for r, line in enumerate(rows):
91+
for c, ch in enumerate(line):
92+
if ch == "█":
93+
top = bot = a
94+
elif ch == "▀":
95+
top, bot = a, fill_bg
96+
elif ch == "▄":
97+
top, bot = fill_bg, a
98+
elif ch in "░▒":
99+
top = bot = s
100+
elif ch == " ":
101+
continue
102+
else: # ▓ or any unexpected dense glyph
103+
top = bot = a
104+
px[c, r * 2] = top
105+
px[c, r * 2 + 1] = bot
106+
107+
img = img.resize((grid_w * scale, grid_h * scale), Image.NEAREST)
108+
if padding:
109+
canvas = Image.new("RGBA", (img.width + 2 * padding, img.height + 2 * padding), fill_bg)
110+
canvas.alpha_composite(img, (padding, padding))
111+
img = canvas
112+
return img
113+
114+
115+
def _hex(rgb: tuple[int, int, int]) -> str:
116+
return "#{:02x}{:02x}{:02x}".format(*rgb)
117+
118+
119+
def render_svg(
120+
rows: list[str],
121+
scale: int,
122+
padding: int,
123+
bg: tuple[int, int, int] | None,
124+
accent: tuple[int, int, int],
125+
shade: tuple[int, int, int],
126+
) -> str:
127+
"""Emit the (static) logo as SVG: each block glyph becomes solid <rect>s on a
128+
1x2 sub-cell grid. Horizontally-adjacent same-color cells are merged into one
129+
rect (run-length) to keep the file small. Vector = crisp at any size."""
130+
a, s = _hex(accent), _hex(shade)
131+
cols = max((len(r) for r in rows), default=0)
132+
133+
# sub[y][x] = color hex or None, where each char row spans two sub-rows.
134+
sub: list[list[str | None]] = []
135+
for line in rows:
136+
top: list[str | None] = []
137+
bot: list[str | None] = []
138+
for ch in line.ljust(cols):
139+
if ch == "█":
140+
t, b = a, a
141+
elif ch == "▀":
142+
t, b = a, None
143+
elif ch == "▄":
144+
t, b = None, a
145+
elif ch in "░▒":
146+
t, b = s, s
147+
elif ch == " ":
148+
t, b = None, None
149+
else:
150+
t, b = a, a
151+
top.append(t)
152+
bot.append(b)
153+
sub.append(top)
154+
sub.append(bot)
155+
156+
rects: list[str] = []
157+
for y, srow in enumerate(sub):
158+
x = 0
159+
while x < cols:
160+
color = srow[x]
161+
if color is None:
162+
x += 1
163+
continue
164+
run = 1
165+
while x + run < cols and srow[x + run] == color:
166+
run += 1
167+
rects.append(
168+
f'<rect x="{padding + x * scale}" y="{padding + y * scale}" '
169+
f'width="{run * scale}" height="{scale}" fill="{color}"/>'
170+
)
171+
x += run
172+
173+
width = cols * scale + 2 * padding
174+
height = len(sub) * scale + 2 * padding
175+
head = (
176+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
177+
f'viewBox="0 0 {width} {height}" shape-rendering="crispEdges">'
178+
)
179+
body = f'<rect width="{width}" height="{height}" fill="{_hex(bg)}"/>' if bg else ""
180+
return head + body + "".join(rects) + "</svg>\n"
181+
182+
183+
def _to_indexed(
184+
frame: Image.Image, accent: tuple[int, int, int], shade: tuple[int, int, int]
185+
) -> Image.Image:
186+
"""Map a flat-colored RGBA frame to a P-mode image whose palette index 0 is the
187+
transparent background. Works only because every pixel is exactly transparent,
188+
accent, or shade — so a 3-entry palette is lossless (no quantization)."""
189+
w, h = frame.size
190+
src = frame.load()
191+
out = Image.new("P", (w, h), 0)
192+
dst = out.load()
193+
for y in range(h):
194+
for x in range(w):
195+
r, g, b, al = src[x, y]
196+
if al == 0:
197+
dst[x, y] = 0
198+
elif (r, g, b) == accent:
199+
dst[x, y] = 1
200+
else:
201+
dst[x, y] = 2
202+
out.putpalette([255, 0, 255, *accent, *shade]) # index 0 is the transparent slot
203+
return out
204+
205+
206+
def main() -> None:
207+
ap = argparse.ArgumentParser(description=__doc__)
208+
ap.add_argument(
209+
"output", nargs="?", help="output path (default: ~/Downloads/welchost-logo.{png,gif})"
210+
)
211+
ap.add_argument(
212+
"--gif", action="store_true", help="render the animated splash instead of a still"
213+
)
214+
ap.add_argument("--scale", type=int, default=14, help="pixels per sub-cell (default: 14)")
215+
ap.add_argument("--padding", type=int, default=28, help="margin in px (default: 28)")
216+
ap.add_argument(
217+
"--bg", default=DEFAULT_BG, help='background hex or "transparent" (default: #1a1a1a)'
218+
)
219+
ap.add_argument(
220+
"--shade", type=float, default=0.42, help="░ density toward accent, 0..1 (default: 0.42)"
221+
)
222+
ap.add_argument(
223+
"--duration", type=int, default=500, help="ms per GIF frame (default: 500, matches the TUI)"
224+
)
225+
ap.add_argument("--frames-dir", help="also write each GIF frame as a PNG into this dir")
226+
ap.add_argument("--svg", action="store_true", help="render a static vector SVG (frame 0)")
227+
args = ap.parse_args()
228+
229+
transparent = args.bg.lower() == "transparent"
230+
bg = None if transparent else hex_rgb(args.bg)
231+
accent = hex_rgb(ACCENT)
232+
shade_bg = bg if bg is not None else (0, 0, 0)
233+
shade = blend(shade_bg, accent, args.shade)
234+
235+
svg = args.svg or (args.output is not None and args.output.lower().endswith(".svg"))
236+
default_name = (
237+
"welchost-logo.svg" if svg else "welchost-logo.gif" if args.gif else "welchost-logo.png"
238+
)
239+
out = Path(args.output) if args.output else Path.home() / "Downloads" / default_name
240+
out.parent.mkdir(parents=True, exist_ok=True)
241+
242+
if svg:
243+
markup = render_svg(frame_lines(0), args.scale, args.padding, bg, accent, shade)
244+
out.write_text(markup)
245+
print(f"Wrote {out} ({len(markup)} bytes)")
246+
return
247+
248+
if not args.gif:
249+
img = render_frame(frame_lines(0), args.scale, args.padding, bg, accent, shade)
250+
img.save(out)
251+
print(f"Wrote {out} ({img.width}x{img.height})")
252+
return
253+
254+
frames = [
255+
render_frame(frame_lines(i), args.scale, args.padding, bg, accent, shade)
256+
for i in range(len(_FRAMES))
257+
]
258+
if args.frames_dir:
259+
fdir = Path(args.frames_dir)
260+
fdir.mkdir(parents=True, exist_ok=True)
261+
for i, fr in enumerate(frames):
262+
fr.save(fdir / f"frame-{i:02d}.png")
263+
print(f"Wrote {len(frames)} frames -> {fdir}")
264+
265+
if transparent:
266+
# 1-bit transparent GIF: palette index 0 is the transparent slot; disposal=2
267+
# clears each frame back to transparent so the floating ghost leaves no trail.
268+
idx = [_to_indexed(f, accent, shade) for f in frames]
269+
idx[0].save(
270+
out,
271+
save_all=True,
272+
append_images=idx[1:],
273+
duration=args.duration,
274+
loop=0,
275+
transparency=0,
276+
disposal=2,
277+
optimize=False,
278+
)
279+
else:
280+
# Solid bg: flatten and quantize to a tight palette.
281+
flat = [f.convert("RGB").quantize(colors=8, dither=Image.NONE) for f in frames]
282+
flat[0].save(
283+
out,
284+
save_all=True,
285+
append_images=flat[1:],
286+
duration=args.duration,
287+
loop=0,
288+
optimize=True,
289+
disposal=2,
290+
)
291+
w, h = frames[0].width, frames[0].height
292+
print(f"Wrote {out} ({w}x{h}, {len(frames)} frames @ {args.duration}ms)")
293+
294+
295+
if __name__ == "__main__":
296+
main()

0 commit comments

Comments
 (0)