forgecommandline 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forgecli/__init__.py +2 -0
- forgecli/ads.py +322 -0
- forgecli/agent.py +1093 -0
- forgecli/art.py +574 -0
- forgecli/attach.py +246 -0
- forgecli/brief.py +215 -0
- forgecli/browsercheck.py +99 -0
- forgecli/checkpoints.py +81 -0
- forgecli/city.py +833 -0
- forgecli/cli.py +1668 -0
- forgecli/config.py +194 -0
- forgecli/courier.py +177 -0
- forgecli/courier_data.py +164 -0
- forgecli/courier_hd.py +431 -0
- forgecli/courier_hd_data.bin +0 -0
- forgecli/courier_hd_data.py +19 -0
- forgecli/ghlogin.py +76 -0
- forgecli/github.py +54 -0
- forgecli/guards.py +403 -0
- forgecli/hooks.py +50 -0
- forgecli/httpapi.py +434 -0
- forgecli/images.py +110 -0
- forgecli/keys.py +152 -0
- forgecli/keyvault.py +320 -0
- forgecli/mcp.py +674 -0
- forgecli/memory.py +42 -0
- forgecli/openapi.py +331 -0
- forgecli/parallel.py +308 -0
- forgecli/prices.py +72 -0
- forgecli/procs.py +144 -0
- forgecli/providers.py +758 -0
- forgecli/quality.py +192 -0
- forgecli/repomap.py +78 -0
- forgecli/roster.py +227 -0
- forgecli/safety.py +93 -0
- forgecli/sandbox.py +83 -0
- forgecli/sessions.py +97 -0
- forgecli/skills.py +446 -0
- forgecli/tools.py +1184 -0
- forgecli/ui.py +860 -0
- forgecli/worktrees.py +79 -0
- forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
- forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
- forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
- forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
- forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
- forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
- forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
- forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
- forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
- forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
- forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
- forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
- forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
- forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
- forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
- forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
- forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
- forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
- forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
- forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
- forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
- forgecommandline-0.8.0.dist-info/METADATA +357 -0
- forgecommandline-0.8.0.dist-info/RECORD +113 -0
- forgecommandline-0.8.0.dist-info/WHEEL +5 -0
- forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
- forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/art.py
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""Forge's pixel-art engine: the neon-city look.
|
|
2
|
+
|
|
3
|
+
A tiny canvas with soft-edged shapes, ordered dithering and neon bloom, and two ways to put it
|
|
4
|
+
on screen:
|
|
5
|
+
* half blocks (▀ with a foreground and a background colour = two pixels per character), which
|
|
6
|
+
work in any truecolor terminal and are what the live city scene uses;
|
|
7
|
+
* sixel, real pixels, for the startup banner where the terminal supports it (Windows Terminal
|
|
8
|
+
1.22+, WezTerm, foot, mlterm, iTerm2...). Pick with FORGE_GRAPHICS=auto|sixel|blocks|plain.
|
|
9
|
+
|
|
10
|
+
Everything is drawn in code from shapes and small sprite strings, so it is crisp at any size and
|
|
11
|
+
ships no image files. Pure Python, no dependencies.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import random
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
# The night palette. One ground (night indigo), one ink (lavender white), two neons: magenta is
|
|
21
|
+
# Forge itself, cyan is the machine. Amber is kept for warm, human things: windows, cargo, lamps.
|
|
22
|
+
NIGHT = (11, 7, 30)
|
|
23
|
+
INK = (236, 228, 255)
|
|
24
|
+
MAGENTA = (255, 79, 216)
|
|
25
|
+
CYAN = (62, 240, 255)
|
|
26
|
+
AMBER = (255, 200, 87)
|
|
27
|
+
GREEN = (91, 255, 157)
|
|
28
|
+
RED = (255, 77, 109)
|
|
29
|
+
LAVENDER = (160, 150, 210)
|
|
30
|
+
SKY = [(8, 5, 26), (20, 9, 54), (44, 13, 84), (92, 22, 112), (150, 36, 128)]
|
|
31
|
+
|
|
32
|
+
BAYER4 = [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def mix(a, b, t: float):
|
|
36
|
+
return (int(a[0] + (b[0] - a[0]) * t), int(a[1] + (b[1] - a[1]) * t), int(a[2] + (b[2] - a[2]) * t))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def scale(c, k: float):
|
|
40
|
+
return (min(255, int(c[0] * k)), min(255, int(c[1] * k)), min(255, int(c[2] * k)))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def hash32(*parts: int) -> int:
|
|
44
|
+
"""Cheap deterministic integer hash (same input, same answer, every run)."""
|
|
45
|
+
h = 2166136261
|
|
46
|
+
for p in parts:
|
|
47
|
+
h ^= p & 0xFFFFFFFF
|
|
48
|
+
h = (h * 16777619) & 0xFFFFFFFF
|
|
49
|
+
h ^= h >> 13
|
|
50
|
+
return h
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Canvas:
|
|
54
|
+
__slots__ = ("w", "h", "px", "glow")
|
|
55
|
+
|
|
56
|
+
def __init__(self, w: int, h: int, bg=NIGHT):
|
|
57
|
+
self.w, self.h = w, h
|
|
58
|
+
self.px = [[bg] * w for _ in range(h)]
|
|
59
|
+
self.glow = None
|
|
60
|
+
|
|
61
|
+
def copy(self) -> "Canvas":
|
|
62
|
+
c = Canvas.__new__(Canvas)
|
|
63
|
+
c.w, c.h = self.w, self.h
|
|
64
|
+
c.px = [row[:] for row in self.px]
|
|
65
|
+
c.glow = None
|
|
66
|
+
return c
|
|
67
|
+
|
|
68
|
+
# pixels -------------------------------------------------------------------------
|
|
69
|
+
def put(self, x: int, y: int, c, a: float = 1.0) -> None:
|
|
70
|
+
if 0 <= x < self.w and 0 <= y < self.h:
|
|
71
|
+
if a >= 0.999:
|
|
72
|
+
self.px[y][x] = c
|
|
73
|
+
elif a > 0.004:
|
|
74
|
+
self.px[y][x] = mix(self.px[y][x], c, a)
|
|
75
|
+
|
|
76
|
+
def light(self, x: int, y: int, c, s: float = 1.0) -> None:
|
|
77
|
+
"""Add neon light at a pixel; bloom() spreads it into a glow."""
|
|
78
|
+
if 0 <= x < self.w and 0 <= y < self.h:
|
|
79
|
+
if self.glow is None:
|
|
80
|
+
self.glow = [[0.0] * (self.w * 3) for _ in range(self.h)]
|
|
81
|
+
g = self.glow[y]
|
|
82
|
+
i = x * 3
|
|
83
|
+
g[i] += c[0] * s
|
|
84
|
+
g[i + 1] += c[1] * s
|
|
85
|
+
g[i + 2] += c[2] * s
|
|
86
|
+
|
|
87
|
+
def bloom(self, radius: int = 1, strength: float = 0.6) -> None:
|
|
88
|
+
"""Blur the light layer (box blur, twice) and add it on top: the neon glow."""
|
|
89
|
+
if self.glow is None:
|
|
90
|
+
return
|
|
91
|
+
w, h, g = self.w, self.h, self.glow
|
|
92
|
+
for _ in range(2):
|
|
93
|
+
# horizontal
|
|
94
|
+
for y in range(h):
|
|
95
|
+
row = g[y]
|
|
96
|
+
out = [0.0] * (w * 3)
|
|
97
|
+
for ch in range(3):
|
|
98
|
+
acc = 0.0
|
|
99
|
+
win = []
|
|
100
|
+
vals = row[ch::3]
|
|
101
|
+
pref = [0.0]
|
|
102
|
+
for v in vals:
|
|
103
|
+
pref.append(pref[-1] + v)
|
|
104
|
+
for x in range(w):
|
|
105
|
+
lo, hi = max(0, x - radius), min(w, x + radius + 1)
|
|
106
|
+
out[x * 3 + ch] = (pref[hi] - pref[lo]) / (2 * radius + 1)
|
|
107
|
+
g[y] = out
|
|
108
|
+
# vertical
|
|
109
|
+
for x3 in range(w * 3):
|
|
110
|
+
col = [g[y][x3] for y in range(h)]
|
|
111
|
+
pref = [0.0]
|
|
112
|
+
for v in col:
|
|
113
|
+
pref.append(pref[-1] + v)
|
|
114
|
+
for y in range(h):
|
|
115
|
+
lo, hi = max(0, y - radius), min(h, y + radius + 1)
|
|
116
|
+
g[y][x3] = (pref[hi] - pref[lo]) / (2 * radius + 1)
|
|
117
|
+
for y in range(h):
|
|
118
|
+
row, gr = self.px[y], g[y]
|
|
119
|
+
for x in range(w):
|
|
120
|
+
i = x * 3
|
|
121
|
+
r, gg, b = gr[i], gr[i + 1], gr[i + 2]
|
|
122
|
+
if r + gg + b > 3:
|
|
123
|
+
c = row[x]
|
|
124
|
+
row[x] = (min(255, int(c[0] + r * strength)), min(255, int(c[1] + gg * strength)),
|
|
125
|
+
min(255, int(c[2] + b * strength)))
|
|
126
|
+
self.glow = None
|
|
127
|
+
|
|
128
|
+
# shapes (float pixel coordinates, soft one-pixel edges) ------------------------------
|
|
129
|
+
def rect(self, x: int, y: int, w: int, h: int, c, a: float = 1.0) -> None:
|
|
130
|
+
for yy in range(max(0, y), min(self.h, y + h)):
|
|
131
|
+
for xx in range(max(0, x), min(self.w, x + w)):
|
|
132
|
+
self.put(xx, yy, c, a)
|
|
133
|
+
|
|
134
|
+
def ellipse(self, cx: float, cy: float, rx: float, ry: float, c, a: float = 1.0, glow: float = 0.0) -> None:
|
|
135
|
+
if rx <= 0 or ry <= 0:
|
|
136
|
+
return
|
|
137
|
+
r = min(rx, ry)
|
|
138
|
+
for y in range(max(0, int(cy - ry - 1)), min(self.h, int(cy + ry + 2))):
|
|
139
|
+
py = (y + 0.5 - cy) / ry
|
|
140
|
+
for x in range(max(0, int(cx - rx - 1)), min(self.w, int(cx + rx + 2))):
|
|
141
|
+
d = math.hypot((x + 0.5 - cx) / rx, py)
|
|
142
|
+
cov = 0.5 - (d - 1.0) * r
|
|
143
|
+
if cov > 0:
|
|
144
|
+
self.put(x, y, c, a * min(1.0, cov))
|
|
145
|
+
if glow:
|
|
146
|
+
self.light(x, y, c, glow * min(1.0, cov))
|
|
147
|
+
|
|
148
|
+
def poly(self, pts, c, a: float = 1.0, glow: float = 0.0) -> None:
|
|
149
|
+
xs = [p[0] for p in pts]
|
|
150
|
+
ys = [p[1] for p in pts]
|
|
151
|
+
edges = list(zip(pts, pts[1:] + pts[:1]))
|
|
152
|
+
for y in range(max(0, int(min(ys)) - 1), min(self.h, int(max(ys)) + 2)):
|
|
153
|
+
py = y + 0.5
|
|
154
|
+
for x in range(max(0, int(min(xs)) - 1), min(self.w, int(max(xs)) + 2)):
|
|
155
|
+
px = x + 0.5
|
|
156
|
+
inside = False
|
|
157
|
+
dist = 1e9
|
|
158
|
+
for (x1, y1), (x2, y2) in edges:
|
|
159
|
+
if (y1 > py) != (y2 > py) and px < (x2 - x1) * (py - y1) / ((y2 - y1) or 1e-9) + x1:
|
|
160
|
+
inside = not inside
|
|
161
|
+
dx, dy = x2 - x1, y2 - y1
|
|
162
|
+
t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / ((dx * dx + dy * dy) or 1e-9)))
|
|
163
|
+
dist = min(dist, math.hypot(px - x1 - t * dx, py - y1 - t * dy))
|
|
164
|
+
cov = 0.5 + dist if inside else 0.5 - dist
|
|
165
|
+
if cov > 0:
|
|
166
|
+
self.put(x, y, c, a * min(1.0, cov))
|
|
167
|
+
if glow:
|
|
168
|
+
self.light(x, y, c, glow * min(1.0, cov))
|
|
169
|
+
|
|
170
|
+
def gradient(self, stops, y0: int, y1: int, dither: bool = True) -> None:
|
|
171
|
+
"""Vertical sky gradient in hard bands with ordered (Bayer) dithering between them."""
|
|
172
|
+
n = len(stops) - 1
|
|
173
|
+
span = max(1, y1 - y0)
|
|
174
|
+
for y in range(max(0, y0), min(self.h, y1)):
|
|
175
|
+
t = (y - y0) / span * n
|
|
176
|
+
i = min(n - 1, int(t))
|
|
177
|
+
f = t - i
|
|
178
|
+
row = self.px[y]
|
|
179
|
+
for x in range(self.w):
|
|
180
|
+
th = (BAYER4[y % 4][x % 4] + 0.5) / 16 if dither else 0.5
|
|
181
|
+
row[x] = stops[i + 1] if f > th else stops[i]
|
|
182
|
+
|
|
183
|
+
def sprite(self, rows, palette: dict, x: int, y: int, flip: bool = False, glow: dict | None = None,
|
|
184
|
+
a: float = 1.0) -> None:
|
|
185
|
+
"""Draw a pixel sprite given as strings ('.' is transparent)."""
|
|
186
|
+
for dy, row in enumerate(rows):
|
|
187
|
+
if flip:
|
|
188
|
+
row = row[::-1]
|
|
189
|
+
for dx, ch in enumerate(row):
|
|
190
|
+
if ch == "." or ch == " ":
|
|
191
|
+
continue
|
|
192
|
+
c = palette.get(ch)
|
|
193
|
+
if c is None:
|
|
194
|
+
continue
|
|
195
|
+
self.put(x + dx, y + dy, c, a)
|
|
196
|
+
if glow and ch in glow:
|
|
197
|
+
self.light(x + dx, y + dy, c, glow[ch])
|
|
198
|
+
|
|
199
|
+
def text(self, word: str, x: int, y: int, c, font=None, k: int = 1, glow: float = 0.0, gap: int = 1) -> int:
|
|
200
|
+
"""Pixel text; returns the width drawn."""
|
|
201
|
+
font = font or FONT3
|
|
202
|
+
cx = x
|
|
203
|
+
for chr_ in word.upper():
|
|
204
|
+
glyph = font.get(chr_)
|
|
205
|
+
if glyph is None:
|
|
206
|
+
cx += 2 * k
|
|
207
|
+
continue
|
|
208
|
+
for gy, row in enumerate(glyph):
|
|
209
|
+
for gx, bit in enumerate(row):
|
|
210
|
+
if bit != ".":
|
|
211
|
+
for sy in range(k):
|
|
212
|
+
for sx in range(k):
|
|
213
|
+
self.put(cx + gx * k + sx, y + gy * k + sy, c)
|
|
214
|
+
if glow:
|
|
215
|
+
self.light(cx + gx * k + sx, y + gy * k + sy, c, glow)
|
|
216
|
+
cx += (len(glyph[0]) + gap) * k
|
|
217
|
+
return cx - x - gap * k
|
|
218
|
+
|
|
219
|
+
# output -------------------------------------------------------------------------
|
|
220
|
+
def ansi(self, x0: int = 0, w: int | None = None) -> str:
|
|
221
|
+
"""Half-block rendering: each character shows two pixels stacked."""
|
|
222
|
+
w = self.w if w is None else w
|
|
223
|
+
out = []
|
|
224
|
+
esc_fg, esc_bg = {}, {}
|
|
225
|
+
for y in range(0, self.h - 1, 2):
|
|
226
|
+
top, bot = self.px[y], self.px[y + 1]
|
|
227
|
+
line = []
|
|
228
|
+
fg = bg = None
|
|
229
|
+
for x in range(x0, x0 + w):
|
|
230
|
+
t, b = top[x], bot[x]
|
|
231
|
+
if b != bg:
|
|
232
|
+
s = esc_bg.get(b)
|
|
233
|
+
if s is None:
|
|
234
|
+
s = esc_bg[b] = f"\x1b[48;2;{b[0]};{b[1]};{b[2]}m"
|
|
235
|
+
line.append(s)
|
|
236
|
+
bg = b
|
|
237
|
+
if t == b:
|
|
238
|
+
line.append(" ")
|
|
239
|
+
continue
|
|
240
|
+
if t != fg:
|
|
241
|
+
s = esc_fg.get(t)
|
|
242
|
+
if s is None:
|
|
243
|
+
s = esc_fg[t] = f"\x1b[38;2;{t[0]};{t[1]};{t[2]}m"
|
|
244
|
+
line.append(s)
|
|
245
|
+
fg = t
|
|
246
|
+
line.append("▀")
|
|
247
|
+
line.append("\x1b[0m")
|
|
248
|
+
out.append("".join(line))
|
|
249
|
+
return "\n".join(out)
|
|
250
|
+
|
|
251
|
+
def sixel(self, k: int = 3) -> str:
|
|
252
|
+
"""Real pixels: each canvas pixel becomes a k x k square. Colours are quantised to a
|
|
253
|
+
6x6x6 cube with ordered dithering, which reads as deliberate pixel-art shading."""
|
|
254
|
+
w, h = self.w, self.h
|
|
255
|
+
pal: dict = {}
|
|
256
|
+
grid = []
|
|
257
|
+
for y in range(h):
|
|
258
|
+
row = []
|
|
259
|
+
for x in range(w):
|
|
260
|
+
c = self.px[y][x]
|
|
261
|
+
th = (BAYER4[y % 4][x % 4] + 0.5) / 16 - 0.5
|
|
262
|
+
q = tuple(max(0, min(5, int(v * 5 / 255 + 0.5 + th * 0.9))) for v in c)
|
|
263
|
+
i = pal.get(q)
|
|
264
|
+
if i is None:
|
|
265
|
+
i = pal[q] = len(pal)
|
|
266
|
+
row.append(i)
|
|
267
|
+
grid.append(row)
|
|
268
|
+
W, H = w * k, h * k
|
|
269
|
+
out = ["\x1bP0;1;0q", f'"1;1;{W};{H}']
|
|
270
|
+
for q, i in pal.items():
|
|
271
|
+
out.append(f"#{i};2;{q[0] * 20};{q[1] * 20};{q[2] * 20}")
|
|
272
|
+
for band in range(0, H, 6):
|
|
273
|
+
per: dict = {}
|
|
274
|
+
for x in range(w):
|
|
275
|
+
for kk in range(6):
|
|
276
|
+
yy = band + kk
|
|
277
|
+
if yy >= H:
|
|
278
|
+
break
|
|
279
|
+
ci = grid[yy // k][x]
|
|
280
|
+
bits = per.setdefault(ci, [0] * w)
|
|
281
|
+
bits[x] |= 1 << kk
|
|
282
|
+
chunks = []
|
|
283
|
+
for ci, bits in per.items():
|
|
284
|
+
s = [f"#{ci}"]
|
|
285
|
+
x = 0
|
|
286
|
+
while x < w:
|
|
287
|
+
b = bits[x]
|
|
288
|
+
run = 1
|
|
289
|
+
while x + run < w and bits[x + run] == b:
|
|
290
|
+
run += 1
|
|
291
|
+
n = run * k
|
|
292
|
+
ch = chr(63 + b)
|
|
293
|
+
s.append(f"!{n}{ch}" if n > 3 else ch * n)
|
|
294
|
+
x += run
|
|
295
|
+
chunks.append("".join(s))
|
|
296
|
+
out.append("$".join(chunks) + "-")
|
|
297
|
+
out.append("\x1b\\")
|
|
298
|
+
return "".join(out)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# fonts: '#' is a lit pixel ---------------------------------------------------------------
|
|
302
|
+
FONT3 = {
|
|
303
|
+
"A": ["###", "#.#", "###", "#.#", "#.#"], "C": ["###", "#..", "#..", "#..", "###"],
|
|
304
|
+
"D": ["##.", "#.#", "#.#", "#.#", "##."], "E": ["###", "#..", "##.", "#..", "###"],
|
|
305
|
+
"G": ["###", "#..", "#.#", "#.#", "###"], "H": ["#.#", "#.#", "###", "#.#", "#.#"],
|
|
306
|
+
"I": ["###", ".#.", ".#.", ".#.", "###"], "K": ["#.#", "#.#", "##.", "#.#", "#.#"],
|
|
307
|
+
"L": ["#..", "#..", "#..", "#..", "###"], "M": ["#.#", "###", "###", "#.#", "#.#"],
|
|
308
|
+
"N": ["##.", "#.#", "#.#", "#.#", "#.#"], "O": ["###", "#.#", "#.#", "#.#", "###"],
|
|
309
|
+
"P": ["###", "#.#", "###", "#..", "#.."], "Q": ["###", "#.#", "#.#", "###", "..#"],
|
|
310
|
+
"R": ["##.", "#.#", "##.", "#.#", "#.#"], "S": ["###", "#..", "###", "..#", "###"],
|
|
311
|
+
"T": ["###", ".#.", ".#.", ".#.", ".#."], "U": ["#.#", "#.#", "#.#", "#.#", "###"],
|
|
312
|
+
"V": ["#.#", "#.#", "#.#", ".#.", ".#."], "W": ["#.#", "#.#", "###", "###", "#.#"],
|
|
313
|
+
"F": ["###", "#..", "##.", "#..", "#.."], "!": ["#", "#", "#", ".", "#"],
|
|
314
|
+
"?": ["##.", "..#", ".#.", "...", ".#."], ".": [".", ".", ".", ".", "#"],
|
|
315
|
+
"0": ["###", "#.#", "#.#", "#.#", "###"], "1": [".#.", "##.", ".#.", ".#.", "###"],
|
|
316
|
+
"2": ["##.", "..#", ".#.", "#..", "###"], "3": ["##.", "..#", ".#.", "..#", "##."],
|
|
317
|
+
"4": ["#.#", "#.#", "###", "..#", "..#"], "5": ["###", "#..", "##.", "..#", "##."],
|
|
318
|
+
"6": [".##", "#..", "###", "#.#", "###"], "7": ["###", "..#", ".#.", ".#.", ".#."],
|
|
319
|
+
"8": ["###", "#.#", "###", "#.#", "###"], "9": ["###", "#.#", "###", "..#", "##."],
|
|
320
|
+
"V_": ["#.#", "#.#", "#.#", ".#.", ".#."],
|
|
321
|
+
}
|
|
322
|
+
FONT5 = {
|
|
323
|
+
"F": ["#####", "#....", "#....", "####.", "#....", "#....", "#...."],
|
|
324
|
+
"O": [".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."],
|
|
325
|
+
"R": ["####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"],
|
|
326
|
+
"G": [".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".###."],
|
|
327
|
+
"E": ["#####", "#....", "#....", "####.", "#....", "#....", "#####"],
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# the mascot: a tiger cub's head, drawn from shapes ------------------------------------------
|
|
332
|
+
FUR = (255, 132, 44)
|
|
333
|
+
FUR_LIGHT = (255, 170, 82)
|
|
334
|
+
FUR_SHADE = (222, 88, 30)
|
|
335
|
+
CREAM = (255, 236, 214)
|
|
336
|
+
WHITE = (255, 248, 240)
|
|
337
|
+
STRIPE = (44, 12, 32)
|
|
338
|
+
OUTLINE = (26, 8, 30)
|
|
339
|
+
NOSE = (255, 92, 150)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def tiger(cv: Canvas, cx: float, cy: float, size: float, glow: bool = True) -> None:
|
|
343
|
+
"""A tiger cub's head, about `size` pixels tall, centred at (cx, cy), lit by the city:
|
|
344
|
+
magenta neon on its left edge, cyan on its right, eyes glowing cyan."""
|
|
345
|
+
s = size / 84.0
|
|
346
|
+
|
|
347
|
+
def P(x, y):
|
|
348
|
+
return (cx + x * s, cy + y * s)
|
|
349
|
+
|
|
350
|
+
def E(x, y, rx, ry, c, a=1.0, g=0.0):
|
|
351
|
+
cv.ellipse(cx + x * s, cy + y * s, rx * s, ry * s, c, a, g if glow else 0.0)
|
|
352
|
+
|
|
353
|
+
def Q(pts, c, g=0.0):
|
|
354
|
+
cv.poly([P(x, y) for x, y in pts], c, 1.0, g if glow else 0.0)
|
|
355
|
+
|
|
356
|
+
# rim light from the neon city, then the outline, then the fur
|
|
357
|
+
for sx, col in ((-1, MAGENTA), (1, CYAN)):
|
|
358
|
+
E(sx * 27 + sx * 2.6, -26, 14.5, 14.5, col, 1.0, 0.5)
|
|
359
|
+
E(sx * 3.2, 2, 42, 37, col, 1.0, 0.35)
|
|
360
|
+
for sx in (-1, 1):
|
|
361
|
+
E(sx * 27, -26, 14.5, 14.5, OUTLINE)
|
|
362
|
+
E(0, 2, 41, 36, OUTLINE)
|
|
363
|
+
for sx in (-1, 1):
|
|
364
|
+
E(sx * 27, -26, 12.5, 12.5, FUR)
|
|
365
|
+
E(sx * 26, -23.5, 7, 7, CREAM)
|
|
366
|
+
Q([(sx * 34, -34), (sx * 39, -27), (sx * 31, -30)], STRIPE)
|
|
367
|
+
E(0, 2, 39.5, 34.5, FUR)
|
|
368
|
+
E(0, 15, 36, 20, FUR_SHADE)
|
|
369
|
+
E(0, 4, 37, 22, FUR)
|
|
370
|
+
E(-7, -12, 24, 14, FUR_LIGHT)
|
|
371
|
+
# stripes: forehead, brows, sides
|
|
372
|
+
Q([(-3.5, -32), (3.5, -32), (0, -16)], STRIPE)
|
|
373
|
+
Q([(-15, -29), (-9, -31), (-8, -19)], STRIPE)
|
|
374
|
+
Q([(9, -31), (15, -29), (8, -19)], STRIPE)
|
|
375
|
+
for sx in (-1, 1):
|
|
376
|
+
Q([(sx * 40, -4), (sx * 40, 4), (sx * 26, 1)], STRIPE)
|
|
377
|
+
Q([(sx * 38.5, 10), (sx * 36.5, 16), (sx * 27, 11)], STRIPE)
|
|
378
|
+
Q([(sx * 24, -12.5), (sx * 10, -14), (sx * 13, -9.5)], STRIPE)
|
|
379
|
+
# cheek fluff and muzzle
|
|
380
|
+
for sx in (-1, 1):
|
|
381
|
+
E(sx * 19, 18, 14, 10, CREAM)
|
|
382
|
+
Q([(sx * 31, 12), (sx * 43, 19), (sx * 30, 22)], CREAM)
|
|
383
|
+
Q([(sx * 30, 20), (sx * 38, 28), (sx * 24, 26)], CREAM)
|
|
384
|
+
Q([(sx * 41, 13), (sx * 44, 18), (sx * 35, 16)], STRIPE)
|
|
385
|
+
E(0, 19, 10.5, 7.5, WHITE)
|
|
386
|
+
E(0, 29, 8, 4.5, CREAM)
|
|
387
|
+
# eyes: glowing cyan with a slit pupil and a white catchlight
|
|
388
|
+
for sx in (-1, 1):
|
|
389
|
+
E(sx * 15, 1.5, 9, 9.5, OUTLINE)
|
|
390
|
+
E(sx * 15, 2, 7.2, 7.8, CYAN, 1.0, 0.9)
|
|
391
|
+
E(sx * 15, 3.6, 4.6, 4.4, (24, 170, 210))
|
|
392
|
+
E(sx * 15, 2.5, 2.2, 6.2, (12, 4, 20))
|
|
393
|
+
E(sx * 15 - 2.6, -1.8, 2.1, 2.1, (255, 255, 255))
|
|
394
|
+
# nose and mouth
|
|
395
|
+
Q([(-7, 10.5), (7, 10.5), (0, 17.5)], NOSE)
|
|
396
|
+
E(-1.8, 12.3, 1.6, 1.0, (255, 170, 205))
|
|
397
|
+
Q([(-0.6, 17), (0.6, 17), (0.6, 21), (-0.6, 21)], STRIPE)
|
|
398
|
+
Q([(-0.5, 20.2), (0.3, 21.2), (-6, 24), (-7.4, 22.6)], STRIPE)
|
|
399
|
+
Q([(0.5, 20.2), (-0.3, 21.2), (6, 24), (7.4, 22.6)], STRIPE)
|
|
400
|
+
for sx in (-1, 1):
|
|
401
|
+
for dx, dy in ((7, 18), (10, 20), (8.5, 22)):
|
|
402
|
+
E(sx * dx, dy, 0.9, 0.9, (200, 160, 150))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# the neon city behind the banner ------------------------------------------------------------
|
|
406
|
+
def skyline(cv: Canvas, horizon: int, seed: int = 7, neon: bool = True, stars: bool = True,
|
|
407
|
+
moon=None) -> list:
|
|
408
|
+
"""Sky, stars, moon and two rows of buildings down to `horizon`. Returns lit windows as
|
|
409
|
+
(x, y, colour) so animated scenes can switch them on and off."""
|
|
410
|
+
rnd = random.Random(seed)
|
|
411
|
+
w = cv.w
|
|
412
|
+
cv.gradient(SKY, 0, horizon + 1)
|
|
413
|
+
if stars:
|
|
414
|
+
for _ in range(max(4, w * horizon // 90)):
|
|
415
|
+
x, y = rnd.randrange(w), rnd.randrange(max(1, horizon * 2 // 3))
|
|
416
|
+
cv.put(x, y, mix(cv.px[y][x], (255, 230, 255), rnd.choice((0.35, 0.55, 0.8))))
|
|
417
|
+
if moon is not False:
|
|
418
|
+
mx, my = moon or (int(w * 0.9), max(2, horizon // 5))
|
|
419
|
+
mr = max(1.2, horizon / 11)
|
|
420
|
+
cv.ellipse(mx, my, mr * 1.9, mr * 1.9, (110, 40, 130), 0.35)
|
|
421
|
+
cv.ellipse(mx, my, mr, mr, (255, 220, 248), 1.0, 0.25)
|
|
422
|
+
cv.ellipse(mx + mr * 0.35, my - mr * 0.2, mr * 0.25, mr * 0.25, (230, 190, 225), 1.0)
|
|
423
|
+
windows = []
|
|
424
|
+
for layer, (lo, hi, col, lit_col, chance) in enumerate((
|
|
425
|
+
(0.35, 0.75, (34, 16, 72), (120, 80, 170), 0.25),
|
|
426
|
+
(0.15, 0.5, (17, 9, 40), None, 0.33))):
|
|
427
|
+
x = -rnd.randrange(4)
|
|
428
|
+
while x < w:
|
|
429
|
+
bw = rnd.randrange(5, 12) if horizon > 14 else rnd.randrange(4, 9)
|
|
430
|
+
top = horizon - int(horizon * rnd.uniform(lo, hi))
|
|
431
|
+
cv.rect(x, top, bw, horizon - top + 1, col)
|
|
432
|
+
if layer == 1 and rnd.random() < 0.3 and top > 2:
|
|
433
|
+
ax = x + bw // 2
|
|
434
|
+
cv.rect(ax, top - 2, 1, 2, (60, 40, 90))
|
|
435
|
+
windows.append((ax, top - 2, RED, "blink"))
|
|
436
|
+
step = 2
|
|
437
|
+
for wy in range(top + 2, horizon - 1, step):
|
|
438
|
+
for wx in range(x + 1, x + bw - 1, 2):
|
|
439
|
+
if rnd.random() < chance:
|
|
440
|
+
c = lit_col or rnd.choice((AMBER, AMBER, AMBER, CYAN, MAGENTA, (255, 170, 120)))
|
|
441
|
+
windows.append((wx, wy, c, "win" if layer else "far"))
|
|
442
|
+
if neon and layer == 1 and rnd.random() < 0.22 and horizon - top > 6:
|
|
443
|
+
nx = x + (0 if rnd.random() < 0.5 else bw - 1)
|
|
444
|
+
ncol = rnd.choice((MAGENTA, CYAN, (255, 120, 60)))
|
|
445
|
+
for ny in range(top + 2, min(horizon - 1, top + 2 + rnd.randrange(3, 7))):
|
|
446
|
+
windows.append((nx, ny, ncol, "neon"))
|
|
447
|
+
x += bw + (rnd.randrange(0, 2) if layer else rnd.randrange(-1, 2))
|
|
448
|
+
return windows
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def windows_on(cv: Canvas, windows, now: float, motion: bool = True) -> None:
|
|
452
|
+
"""Light the windows; some switch on and off slowly, antenna lights blink, neon flickers."""
|
|
453
|
+
t = now if motion else 0.0
|
|
454
|
+
for i, (x, y, c, kind) in enumerate(windows):
|
|
455
|
+
if kind == "blink":
|
|
456
|
+
if int(t * 1.2 + i) % 2 == 0:
|
|
457
|
+
cv.put(x, y, c)
|
|
458
|
+
cv.light(x, y, c, 0.8)
|
|
459
|
+
continue
|
|
460
|
+
if kind == "neon":
|
|
461
|
+
flick = hash32(i, int(t * 6)) % 41 == 0
|
|
462
|
+
if not flick:
|
|
463
|
+
cv.put(x, y, c)
|
|
464
|
+
cv.light(x, y, c, 0.7)
|
|
465
|
+
continue
|
|
466
|
+
period = 4 + (hash32(i, 3) % 9)
|
|
467
|
+
on = hash32(i, x, int((t + i * 0.37) / period)) % 100 < (78 if kind == "far" else 70)
|
|
468
|
+
if on:
|
|
469
|
+
cv.put(x, y, scale(c, 0.55) if kind == "far" else c)
|
|
470
|
+
if kind == "win":
|
|
471
|
+
cv.light(x, y, c, 0.18)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def street(cv: Canvas, y0: int, now: float, motion: bool = True) -> None:
|
|
475
|
+
"""Wet street from row y0 down: a neon curb, then the city mirrored in the puddles."""
|
|
476
|
+
if y0 >= cv.h:
|
|
477
|
+
return
|
|
478
|
+
t = now if motion else 0.0
|
|
479
|
+
curb = mix((60, 30, 110), CYAN, 0.35)
|
|
480
|
+
for x in range(cv.w):
|
|
481
|
+
cv.px[y0][x] = curb
|
|
482
|
+
for k, y in enumerate(range(y0 + 1, cv.h)):
|
|
483
|
+
src = y0 - 1 - k
|
|
484
|
+
if src < 0:
|
|
485
|
+
break
|
|
486
|
+
shift = int(round(1.2 * math.sin(t * 2.1 + y * 1.7)))
|
|
487
|
+
srow = cv.px[src]
|
|
488
|
+
row = cv.px[y]
|
|
489
|
+
for x in range(cv.w):
|
|
490
|
+
c = srow[min(cv.w - 1, max(0, x + shift))]
|
|
491
|
+
shimmer = 0.34 + 0.1 * math.sin(x * 0.9 + t * 3.0 + y * 2.3) - k * 0.04
|
|
492
|
+
row[x] = mix((9, 6, 24), c, max(0.12, shimmer))
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def neon_word(cv: Canvas, word: str, x: int, y: int, k: int, tube=MAGENTA, core=(255, 214, 246),
|
|
496
|
+
font=None) -> int:
|
|
497
|
+
"""A neon-tube sign: continuous tubes with a hot pale core where the stroke is thick enough,
|
|
498
|
+
and bloom around them."""
|
|
499
|
+
font = font or FONT5
|
|
500
|
+
lit = set()
|
|
501
|
+
cx = x
|
|
502
|
+
for ch in word:
|
|
503
|
+
glyph = font[ch]
|
|
504
|
+
for gy, row in enumerate(glyph):
|
|
505
|
+
for gx, bit in enumerate(row):
|
|
506
|
+
if bit == "#":
|
|
507
|
+
for sy in range(k):
|
|
508
|
+
for sx in range(k):
|
|
509
|
+
lit.add((cx + gx * k + sx, y + gy * k + sy))
|
|
510
|
+
cx += (len(glyph[0]) + 1) * k
|
|
511
|
+
for (px, py) in lit:
|
|
512
|
+
inner = k >= 3 and all(n in lit for n in ((px - 1, py), (px + 1, py), (px, py - 1), (px, py + 1),
|
|
513
|
+
(px - 1, py - 1), (px + 1, py + 1), (px - 1, py + 1), (px + 1, py - 1)))
|
|
514
|
+
cv.put(px, py, core if inner else tube)
|
|
515
|
+
cv.light(px, py, tube, 1.0)
|
|
516
|
+
return cx - x - k
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def banner(w: int, h: int, version: str = "") -> Canvas:
|
|
520
|
+
"""The startup scene: a big neon-lit tiger cub over the night city, FORGE as a neon sign."""
|
|
521
|
+
cv = Canvas(w, h)
|
|
522
|
+
horizon = int(h * 0.82)
|
|
523
|
+
s = (horizon - 1) / 80.0 # the head spans about -41..+38 in tiger() units
|
|
524
|
+
size = 84 * s
|
|
525
|
+
tx = 46 * s + 2
|
|
526
|
+
left = int(tx + 46 * s) + 3
|
|
527
|
+
room = w - left - 2
|
|
528
|
+
k = max(1, min(int(h * 0.48 / 7), room // 30))
|
|
529
|
+
word_w = 5 * 6 * k - k
|
|
530
|
+
wy = max(1, int(horizon * 0.46 - 3.5 * k))
|
|
531
|
+
sx = left + max(0, (room - word_w) // 2)
|
|
532
|
+
windows = skyline(cv, horizon, seed=11, moon=(min(w - 4, sx + word_w + max(4, k * 3)), max(3, wy // 2 + 2))
|
|
533
|
+
if sx + word_w + k * 5 < w else False)
|
|
534
|
+
windows_on(cv, windows, 0.0, motion=False)
|
|
535
|
+
tiger(cv, tx, horizon - 38.5 * s - 1, size)
|
|
536
|
+
neon_word(cv, "FORGE", sx, wy, k)
|
|
537
|
+
if version:
|
|
538
|
+
vy = wy + 7 * k + max(2, k)
|
|
539
|
+
if vy + 5 < horizon:
|
|
540
|
+
cv.text(version, sx + k, vy, CYAN, FONT3, 1, 0.6)
|
|
541
|
+
cv.bloom(radius=max(1, h // 22), strength=0.55)
|
|
542
|
+
street(cv, horizon + 1, 0.0, motion=False)
|
|
543
|
+
return cv
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def mascot(h: int) -> Canvas:
|
|
547
|
+
"""Just the tiger, on transparent-looking night, for the goodbye screen."""
|
|
548
|
+
w = int(h * 1.25)
|
|
549
|
+
cv = Canvas(w, h)
|
|
550
|
+
cv.gradient([(8, 5, 26), (20, 9, 54), (44, 13, 84)], 0, h)
|
|
551
|
+
tiger(cv, w / 2, h * 0.52, h * 0.94)
|
|
552
|
+
cv.bloom(radius=1, strength=0.5)
|
|
553
|
+
return cv
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# what the terminal can show ------------------------------------------------------------------
|
|
557
|
+
def graphics(console=None) -> str:
|
|
558
|
+
"""'sixel', 'blocks' or 'plain' for this terminal (FORGE_GRAPHICS overrides)."""
|
|
559
|
+
pick = os.environ.get("FORGE_GRAPHICS", "auto").strip().lower()
|
|
560
|
+
if pick in ("sixel", "blocks", "plain"):
|
|
561
|
+
return pick
|
|
562
|
+
if os.environ.get("NO_COLOR"):
|
|
563
|
+
return "plain"
|
|
564
|
+
if console is not None:
|
|
565
|
+
if not console.is_terminal or console.color_system not in ("truecolor", "256"):
|
|
566
|
+
return "plain"
|
|
567
|
+
elif not sys.stdout.isatty():
|
|
568
|
+
return "plain"
|
|
569
|
+
if os.environ.get("WT_SESSION") and os.environ.get("TERM_PROGRAM", "").lower() != "vscode":
|
|
570
|
+
return "sixel" # Windows Terminal 1.22+ draws sixel images
|
|
571
|
+
term = (os.environ.get("TERM_PROGRAM", "") + " " + os.environ.get("TERM", "")).lower()
|
|
572
|
+
if any(t in term for t in ("wezterm", "foot", "mlterm", "contour", "iterm")):
|
|
573
|
+
return "sixel"
|
|
574
|
+
return "blocks"
|