initial commit
This commit is contained in:
commit
f04f3a8ae9
18 changed files with 4577 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.venv/
|
||||
target/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.eris/
|
||||
*.so
|
||||
2207
Cargo.lock
generated
Normal file
2207
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "textgrid"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
pyo3 = { version = "0.23", features = ["extension-module"] }
|
||||
wgpu = "29"
|
||||
glfw = "0.62"
|
||||
pollster = "0.4"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
swash = "0.2"
|
||||
unicode-width = "0.2"
|
||||
ttf-parser = "0.24"
|
||||
png = "0.18"
|
||||
41
demo.py
Normal file
41
demo.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Animated Unicode pattern using the GPU-accelerated text grid extension."""
|
||||
|
||||
import math
|
||||
import textgrid
|
||||
import time
|
||||
|
||||
ROWS = 24
|
||||
COLS = 80
|
||||
FONT_PATH = None # set to a .ttf path to override DejaVu Sans Mono
|
||||
|
||||
# Unicode gradient blocks from light to dark
|
||||
BLOCKS = " ░▒▓█"
|
||||
|
||||
def main():
|
||||
tg = textgrid.TextGrid(ROWS, COLS, font_path=FONT_PATH)
|
||||
time.sleep(0.3)
|
||||
|
||||
t0 = time.monotonic()
|
||||
frame = 0
|
||||
duration = 10
|
||||
|
||||
while time.monotonic() - t0 < duration:
|
||||
rows = []
|
||||
for y in range(ROWS):
|
||||
row_chars = []
|
||||
phase = frame * 0.08 + y * 0.4
|
||||
for x in range(COLS):
|
||||
dx = x - COLS / 2
|
||||
dy = y - ROWS / 2
|
||||
v = math.sin(math.sqrt(dx * dx + dy * dy) * 0.25 - phase)
|
||||
v = max(-1, min(1, v))
|
||||
idx = int((v + 1) * 0.5 * (len(BLOCKS) - 1))
|
||||
row_chars.append(BLOCKS[idx])
|
||||
rows.append("".join(row_chars))
|
||||
tg.update(rows)
|
||||
frame += 1
|
||||
print(frame)
|
||||
tg.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
350
demo_features.py
Normal file
350
demo_features.py
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
"""Feature demo: colors, bold/italic, CJK, emoji, background colors — cycles through 5 panels."""
|
||||
|
||||
import math
|
||||
import time
|
||||
import termstr
|
||||
import textgrid
|
||||
from cwcwidth import wcswidth
|
||||
|
||||
ROWS = 24
|
||||
COLS = 80
|
||||
BLOCKS = " ░▒▓█"
|
||||
|
||||
|
||||
def center(text):
|
||||
"""Center text in COLS columns."""
|
||||
w = wcswidth(text)
|
||||
if w >= COLS:
|
||||
return text
|
||||
left = (COLS - w) // 2
|
||||
return " " * left + text + " " * (COLS - w - left)
|
||||
|
||||
|
||||
def pad(text):
|
||||
"""Left-align and pad text to display width COLS."""
|
||||
w = wcswidth(text)
|
||||
if w >= COLS:
|
||||
return text
|
||||
return text + " " * (COLS - w)
|
||||
|
||||
|
||||
def rainbow(hue):
|
||||
r = int(255 * (0.5 + 0.5 * math.cos(hue * math.tau)))
|
||||
g = int(255 * (0.5 + 0.5 * math.cos(hue * math.tau - 2.094)))
|
||||
b = int(255 * (0.5 + 0.5 * math.cos(hue * math.tau - 4.188)))
|
||||
return r, g, b
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 1 — Colors
|
||||
# ---------------------------------------------------------------------------
|
||||
def panel_colors(phase):
|
||||
rows = []
|
||||
|
||||
# header
|
||||
rows.append(termstr.TermStr(
|
||||
center("Colors: RGB and xterm 256"),
|
||||
termstr.CharStyle(fg_color=(120, 120, 120)),
|
||||
))
|
||||
|
||||
rows.append(" " * COLS)
|
||||
|
||||
# 2 rows of RGB rainbow
|
||||
for _ in range(2):
|
||||
s = termstr.CharStyle(fg_color=(0, 0, 0))
|
||||
ts = termstr.TermStr(
|
||||
"".join(BLOCKS[int((math.sin(x / COLS * math.pi) + 1) * 2)] for x in range(COLS)),
|
||||
s,
|
||||
)
|
||||
rows.append(ts)
|
||||
|
||||
# 2 rows of xterm 256 gradient
|
||||
for offset in (16, 80):
|
||||
text = "".join(BLOCKS[(offset + x) % 5] for x in range(COLS))
|
||||
styles = tuple(
|
||||
termstr.CharStyle(fg_color=(offset + x) % 256) for x in range(COLS)
|
||||
)
|
||||
rows.append(termstr.TermStr(text, styles))
|
||||
|
||||
rows.append(" " * COLS)
|
||||
|
||||
# ripple (plain str to show default rendering still works)
|
||||
for y in range(ROWS - len(rows)):
|
||||
line = []
|
||||
for x in range(COLS):
|
||||
dx = x - COLS / 2
|
||||
dy = (y - 2) - ROWS / 2 + 6
|
||||
v = math.sin(math.sqrt(dx * dx + dy * dy) * 0.3 - phase)
|
||||
v = max(-1, min(1, v))
|
||||
idx = int((v + 1) * 0.5 * (len(BLOCKS) - 1))
|
||||
line.append(BLOCKS[idx])
|
||||
rows.append("".join(line))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 2 — Font Styles
|
||||
# ---------------------------------------------------------------------------
|
||||
def panel_font_styles():
|
||||
rows = []
|
||||
|
||||
rows.append(termstr.TermStr(
|
||||
center("Font Styles: Bold · Italic · Bold+Italic"),
|
||||
termstr.CharStyle(fg_color=(120, 120, 120)),
|
||||
))
|
||||
|
||||
rows.append(" " * COLS)
|
||||
|
||||
demos = [
|
||||
("Regular", lambda s: s),
|
||||
("Bold", lambda s: s.bold()),
|
||||
("Italic", lambda s: s.italic()),
|
||||
("Underline", lambda s: s.underline()),
|
||||
("Bold+Italic", lambda s: s.bold().italic()),
|
||||
("Bold+Underline", lambda s: s.bold().underline()),
|
||||
("Italic+Underline", lambda s: s.italic().underline()),
|
||||
("Bold+Ita+Under", lambda s: s.bold().italic().underline()),
|
||||
]
|
||||
for label, mod in demos:
|
||||
text = f" {label:14s} The quick brown fox jumps over the lazy dog"
|
||||
text = pad(text)
|
||||
styles = tuple(
|
||||
mod(termstr.CharStyle(fg_color=(200, 200, 200)))
|
||||
for _ in range(COLS)
|
||||
)
|
||||
rows.append(termstr.TermStr(text, styles))
|
||||
|
||||
rows.append(" " * COLS)
|
||||
|
||||
# per-character alternation (no wide chars)
|
||||
text = " MIXED: Bold | Italic | Regular | Bold+Italic | Repeat..."
|
||||
text = pad(text)
|
||||
styles = []
|
||||
for i in range(COLS):
|
||||
s = termstr.CharStyle(fg_color=rainbow(i / COLS))
|
||||
if i % 4 == 0:
|
||||
s = s.bold()
|
||||
elif i % 4 == 1:
|
||||
s = s.italic()
|
||||
elif i % 4 == 3:
|
||||
s = s.bold().italic()
|
||||
styles.append(s)
|
||||
rows.append(termstr.TermStr(text, tuple(styles)))
|
||||
|
||||
while len(rows) < ROWS:
|
||||
rows.append(" " * COLS)
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 3 — Wide Characters
|
||||
# ---------------------------------------------------------------------------
|
||||
def panel_wide_chars():
|
||||
rows = []
|
||||
|
||||
rows.append(termstr.TermStr(
|
||||
center("Wide Characters: CJK & Emoji (2 columns each)"),
|
||||
termstr.CharStyle(fg_color=(120, 120, 120)),
|
||||
))
|
||||
|
||||
rows.append(" " * COLS)
|
||||
|
||||
# CJK rows — uniform colors, single CharStyle (broadcast)
|
||||
for text, fg in [
|
||||
(" CJK: 你好世界 你好世界 你好世界 你好世界", (0, 200, 255)),
|
||||
(" 你好世界 你好世界 你好世界 你好世界", (100, 220, 255)),
|
||||
(" 你好世界 你好世界 你好世界 你好世界", (180, 240, 255)),
|
||||
]:
|
||||
text = pad(text)
|
||||
rows.append(termstr.TermStr(text, termstr.CharStyle(fg_color=fg)))
|
||||
|
||||
rows.append(" " * COLS)
|
||||
|
||||
# Emoji rows
|
||||
for text, fg in [
|
||||
(" Emoji: 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡❤️💎", (255, 200, 0)),
|
||||
(" 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡❤️💎", (255, 220, 80)),
|
||||
(" 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡❤️💎", (255, 240, 120)),
|
||||
]:
|
||||
text = pad(text)
|
||||
rows.append(termstr.TermStr(text, termstr.CharStyle(fg_color=fg)))
|
||||
|
||||
rows.append(" " * COLS)
|
||||
|
||||
# Mixed row — use plain str (default colors)
|
||||
rows.append(pad("hello你好🍕world你好🍔!!!"))
|
||||
|
||||
rows.append(" " * COLS)
|
||||
rows.append(termstr.TermStr(
|
||||
center("(Zero-width padding chars are invisible; wide chars occupy 2 cells)"),
|
||||
termstr.CharStyle(fg_color=(100, 100, 100)),
|
||||
))
|
||||
|
||||
while len(rows) < ROWS:
|
||||
rows.append(" " * COLS)
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 4 — Background Colors
|
||||
# ---------------------------------------------------------------------------
|
||||
def panel_bg_colors():
|
||||
rows = []
|
||||
|
||||
rows.append(termstr.TermStr(
|
||||
center("Background Colors: text & emoji on colored backgrounds"),
|
||||
termstr.CharStyle(fg_color=(120, 120, 120)),
|
||||
))
|
||||
|
||||
rows.append(" " * COLS)
|
||||
|
||||
# Highlighted text rows
|
||||
labels = [" Highlight", " Selection", " Warning ", " Success "]
|
||||
bgs = [(60, 60, 100), (50, 50, 150), (100, 30, 30), (30, 80, 30)]
|
||||
for label, bg in zip(labels, bgs):
|
||||
text = f"{label}: Lorem ipsum dolor sit amet, consectetur adipiscing elit "
|
||||
text = pad(text)
|
||||
rows.append(termstr.TermStr(text, termstr.CharStyle(bg_color=bg)))
|
||||
|
||||
rows.append(" " * COLS)
|
||||
|
||||
# Underline with background (full row)
|
||||
rows.append(termstr.TermStr(
|
||||
pad(" Underlined text on colored background — highlight + underline "),
|
||||
termstr.CharStyle(bg_color=(50, 30, 60)).underline(),
|
||||
))
|
||||
|
||||
rows.append(" " * COLS)
|
||||
|
||||
# Emoji with background — tests bg + color emoji blending
|
||||
rows.append(termstr.TermStr(
|
||||
pad(" Emoji on colored backgrounds — 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡ "),
|
||||
termstr.CharStyle(bg_color=(40, 40, 80)),
|
||||
))
|
||||
rows.append(termstr.TermStr(
|
||||
pad(" Brighter bg — 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡ "),
|
||||
termstr.CharStyle(bg_color=(80, 60, 40)),
|
||||
))
|
||||
rows.append(termstr.TermStr(
|
||||
pad(" Green bg — 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡ "),
|
||||
termstr.CharStyle(bg_color=(20, 80, 40)),
|
||||
))
|
||||
|
||||
rows.append(" " * COLS)
|
||||
|
||||
# Alternating row highlights (like terminal selection)
|
||||
rows.append(termstr.TermStr(
|
||||
center("Alternating row backgrounds"),
|
||||
termstr.CharStyle(fg_color=(100, 100, 100)),
|
||||
))
|
||||
for i in range(6):
|
||||
bg = (30, 35 + i * 8, 50 + i * 12) if i % 2 == 0 else (0, 0, 0)
|
||||
icon = "🍕" if i % 2 == 0 else " "
|
||||
text = f" Row {i+1}: {icon} Alternating background colors in a list view "
|
||||
text = pad(text)
|
||||
rows.append(termstr.TermStr(text, termstr.CharStyle(bg_color=bg)))
|
||||
|
||||
while len(rows) < ROWS:
|
||||
rows.append(" " * COLS)
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 5 — Everything Together
|
||||
# ---------------------------------------------------------------------------
|
||||
def panel_mixed(phase):
|
||||
rows = []
|
||||
|
||||
rows.append(termstr.TermStr(
|
||||
center("All Features Combined"),
|
||||
termstr.CharStyle(fg_color=(120, 120, 120)),
|
||||
))
|
||||
|
||||
for y in range(ROWS - 1):
|
||||
# Top half: per-char styles with ASCII chars only
|
||||
if y < (ROWS - 1) // 2:
|
||||
row_chars = []
|
||||
row_styles = []
|
||||
for x in range(COLS):
|
||||
p = math.sin(x * 0.2 + y * 0.3 - phase)
|
||||
ch = BLOCKS[int((p + 1) * 2)]
|
||||
s = termstr.CharStyle(fg_color=rainbow(x / COLS + y / ROWS))
|
||||
if y % 2 == 0:
|
||||
s = s.bold()
|
||||
elif y % 3 == 0:
|
||||
s = s.italic()
|
||||
row_chars.append(ch)
|
||||
row_styles.append(s)
|
||||
rows.append(termstr.TermStr("".join(row_chars), tuple(row_styles)))
|
||||
# Bottom half: three bands with mixed features
|
||||
else:
|
||||
band = y - (ROWS - 1) // 2
|
||||
if band % 3 == 0:
|
||||
# xterm gradient
|
||||
styles = tuple(
|
||||
termstr.CharStyle(fg_color=(x + band * 10) % 256)
|
||||
for x in range(COLS)
|
||||
)
|
||||
text = "".join(BLOCKS[(x + band) % 5] for x in range(COLS))
|
||||
rows.append(termstr.TermStr(text, styles))
|
||||
elif band % 3 == 1:
|
||||
text = "CJK: 你好世界你好世界你好世界你好世界"
|
||||
text = pad(text)
|
||||
rows.append(termstr.TermStr(text, termstr.CharStyle(fg_color=(0, 200, 255))))
|
||||
else:
|
||||
text = "Emoji 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡"
|
||||
text = pad(text)
|
||||
rows.append(termstr.TermStr(text, termstr.CharStyle(fg_color=(255, 200, 0))))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
_closed = False
|
||||
|
||||
def _on_close():
|
||||
global _closed
|
||||
_closed = True
|
||||
|
||||
def main():
|
||||
tg = textgrid.TextGrid(
|
||||
ROWS, COLS,
|
||||
"TextGrid Feature Demo",
|
||||
font_path="/usr/local/share/fonts/truetype/sarasa/SarasaTermSC-Regular.ttf",
|
||||
color_font_path="/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
|
||||
on_close=_on_close,
|
||||
)
|
||||
phase = 0.0
|
||||
try:
|
||||
while not _closed:
|
||||
tg.update(panel_colors(phase))
|
||||
if _closed: break
|
||||
time.sleep(4)
|
||||
if _closed: break
|
||||
tg.update(panel_font_styles())
|
||||
if _closed: break
|
||||
time.sleep(4)
|
||||
if _closed: break
|
||||
tg.update(panel_wide_chars())
|
||||
if _closed: break
|
||||
time.sleep(4)
|
||||
if _closed: break
|
||||
tg.update(panel_bg_colors())
|
||||
if _closed: break
|
||||
time.sleep(4)
|
||||
if _closed: break
|
||||
tg.update(panel_mixed(phase))
|
||||
if _closed: break
|
||||
time.sleep(4)
|
||||
phase += 0.25
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
tg.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
52
demo_termstr.py
Normal file
52
demo_termstr.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import math
|
||||
import time
|
||||
|
||||
import textgrid
|
||||
import termstr
|
||||
|
||||
|
||||
ROWS = 24
|
||||
COLS = 80
|
||||
BLOCKS = " ░▒▓█"
|
||||
|
||||
|
||||
def rainbow(hue):
|
||||
r = int(255 * (0.5 + 0.5 * math.cos(hue * math.tau)))
|
||||
g = int(255 * (0.5 + 0.5 * math.cos(hue * math.tau - 2.094)))
|
||||
b = int(255 * (0.5 + 0.5 * math.cos(hue * math.tau - 4.188)))
|
||||
return r, g, b
|
||||
|
||||
|
||||
def main():
|
||||
tg = textgrid.TextGrid(ROWS, COLS, "TermStr Demo")
|
||||
phase = 0.0
|
||||
try:
|
||||
while True:
|
||||
rows = []
|
||||
for y in range(ROWS):
|
||||
text_chars = []
|
||||
styles = []
|
||||
for x in range(COLS):
|
||||
dx = x - COLS / 2
|
||||
dy = y - ROWS / 2
|
||||
v = math.sin(
|
||||
math.sqrt(dx * dx + dy * dy) * 0.25 - phase
|
||||
)
|
||||
v = max(-1, min(1, v))
|
||||
idx = int((v + 1) * 0.5 * (len(BLOCKS) - 1))
|
||||
text_chars.append(BLOCKS[idx])
|
||||
hue = (x / COLS + y / ROWS + phase * 0.25) % 1.0
|
||||
styles.append(termstr.CharStyle(fg_color=rainbow(hue)))
|
||||
text = "".join(text_chars)
|
||||
rows.append(termstr.TermStr(text, tuple(styles)))
|
||||
tg.update(rows)
|
||||
phase += 0.05
|
||||
time.sleep(0.03)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
tg.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
17
pyproject.toml
Normal file
17
pyproject.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[build-system]
|
||||
requires = ["maturin>=1.0,<2"]
|
||||
build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "textgrid"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[tool.maturin]
|
||||
python-source = "python"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"cwcwidth==0.1.5",
|
||||
"termstr @ file:///home/ahamilton/code/terminal/termstr",
|
||||
]
|
||||
6
python/textgrid/__init__.py
Normal file
6
python/textgrid/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .textgrid import *
|
||||
from ._pack_style import pack_style_colors
|
||||
|
||||
__doc__ = textgrid.__doc__
|
||||
if hasattr(textgrid, "__all__"):
|
||||
__all__ = textgrid.__all__
|
||||
46
python/textgrid/_pack_style.py
Normal file
46
python/textgrid/_pack_style.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from array import array
|
||||
|
||||
_xterm = [
|
||||
(0, 0, 0), (205, 0, 0), (0, 205, 0), (205, 205, 0),
|
||||
(0, 0, 238), (205, 0, 205), (0, 205, 205), (229, 229, 229),
|
||||
(127, 127, 127), (255, 0, 0), (0, 255, 0), (255, 255, 0),
|
||||
(92, 92, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
|
||||
]
|
||||
_grad = [0, 95, 135, 175, 215, 255]
|
||||
for _i in range(216):
|
||||
_xterm.append((_grad[_i // 36 % 6], _grad[_i // 6 % 6], _grad[_i % 6]))
|
||||
for _i in range(24):
|
||||
_v = 8 + _i * 10
|
||||
_xterm.append((_v, _v, _v))
|
||||
|
||||
|
||||
def _pack_one(s):
|
||||
fg = s.fg_color
|
||||
if isinstance(fg, tuple):
|
||||
fv = (fg[0] << 24) | (fg[1] << 16) | (fg[2] << 8)
|
||||
else:
|
||||
r, g, b = _xterm[fg]
|
||||
fv = (r << 24) | (g << 16) | (b << 8)
|
||||
bg = s.bg_color
|
||||
if isinstance(bg, tuple):
|
||||
bv = (bg[0] << 24) | (bg[1] << 16) | (bg[2] << 8)
|
||||
else:
|
||||
r, g, b = _xterm[bg]
|
||||
bv = (r << 24) | (g << 16) | (b << 8)
|
||||
attr = (s.is_bold << 0) | (s.is_italic << 1) | (s.is_underlined << 2)
|
||||
return fv, bv, attr
|
||||
|
||||
|
||||
def pack_style_colors(style):
|
||||
n = len(style)
|
||||
first = style[0]
|
||||
if n > 1 and all(s is first for s in style):
|
||||
fv, bv, attr = _pack_one(first)
|
||||
return array('I', [fv, bv, attr] * n)
|
||||
out = array('I')
|
||||
for s in style:
|
||||
fv, bv, attr = _pack_one(s)
|
||||
out.append(fv)
|
||||
out.append(bv)
|
||||
out.append(attr)
|
||||
return out
|
||||
47
speed.py
Normal file
47
speed.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Benchmark update throughput: plain str vs TermStr (RGB & xterm)."""
|
||||
|
||||
import textgrid
|
||||
import termstr
|
||||
import time
|
||||
|
||||
ROWS = 40
|
||||
COLS = 100
|
||||
SECONDS = 5
|
||||
|
||||
|
||||
def main():
|
||||
tg = textgrid.TextGrid(ROWS, COLS)
|
||||
time.sleep(0.3)
|
||||
|
||||
plain = ["A" * COLS] * ROWS
|
||||
|
||||
style_rgb = termstr.CharStyle(fg_color=(255, 80, 80))
|
||||
term_rgb = [termstr.TermStr("A" * COLS, style_rgb)] * ROWS
|
||||
|
||||
style_xterm = termstr.CharStyle(fg_color=9)
|
||||
term_xterm = [termstr.TermStr("A" * COLS, style_xterm)] * ROWS
|
||||
|
||||
for label, data in [
|
||||
("plain str ", plain),
|
||||
("TermStr RGB ", term_rgb),
|
||||
("TermStr xterm ", term_xterm),
|
||||
]:
|
||||
for _ in range(10):
|
||||
tg.update(data)
|
||||
|
||||
n = 0
|
||||
t0 = time.monotonic()
|
||||
while True:
|
||||
tg.update(data)
|
||||
n += 1
|
||||
elapsed = time.monotonic() - t0
|
||||
if elapsed >= SECONDS:
|
||||
break
|
||||
|
||||
print(f" {label} {n / elapsed:>8.0f} updates/s ({n} in {elapsed:.1f}s)")
|
||||
|
||||
tg.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
221
src/atlas.rs
Normal file
221
src/atlas.rs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use crate::font::{self, FontSet, ColorFont, GlyphRasterizer};
|
||||
|
||||
pub(crate) const ATLAS_SIZE: u32 = 2560;
|
||||
|
||||
pub(crate) struct GlyphAtlas {
|
||||
texture: wgpu::Texture,
|
||||
texture_view: wgpu::TextureView,
|
||||
font_set: FontSet,
|
||||
color_font: Option<ColorFont>,
|
||||
scale: f32,
|
||||
cell_width: u32,
|
||||
cell_height: u32,
|
||||
rasterizer: GlyphRasterizer,
|
||||
next_slot: u32,
|
||||
glyph_map: HashMap<(char, u8), u32>,
|
||||
}
|
||||
|
||||
impl GlyphAtlas {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
font_set: FontSet,
|
||||
scale: f32,
|
||||
cell_width: u32,
|
||||
cell_height: u32,
|
||||
color_font_path: Option<&str>,
|
||||
) -> Self {
|
||||
let color_font = color_font_path.map(font::load_color_font);
|
||||
|
||||
let size = wgpu::Extent3d {
|
||||
width: ATLAS_SIZE,
|
||||
height: ATLAS_SIZE,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("glyph_atlas"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
let zero_pixels = vec![0u8; (cell_width * cell_height * 4) as usize];
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d { x: 0, y: 0, z: 0 },
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
&zero_pixels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(cell_width * 4),
|
||||
rows_per_image: Some(cell_height),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: cell_width,
|
||||
height: cell_height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
|
||||
GlyphAtlas {
|
||||
texture,
|
||||
texture_view,
|
||||
font_set,
|
||||
color_font,
|
||||
scale,
|
||||
cell_width,
|
||||
cell_height,
|
||||
rasterizer: GlyphRasterizer::new(),
|
||||
next_slot: 1,
|
||||
glyph_map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(&self) -> &wgpu::TextureView {
|
||||
&self.texture_view
|
||||
}
|
||||
|
||||
pub fn cell_width(&self) -> u32 {
|
||||
self.cell_width
|
||||
}
|
||||
|
||||
pub fn cell_height(&self) -> u32 {
|
||||
self.cell_height
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> f32 {
|
||||
self.scale
|
||||
}
|
||||
|
||||
pub fn atlas_cols(&self) -> u32 {
|
||||
ATLAS_SIZE / self.cell_width
|
||||
}
|
||||
|
||||
pub fn atlas_rows(&self) -> u32 {
|
||||
ATLAS_SIZE / self.cell_height
|
||||
}
|
||||
|
||||
pub fn max_glyphs(&self) -> u32 {
|
||||
self.atlas_cols() * self.atlas_rows()
|
||||
}
|
||||
|
||||
pub fn set_scale(&mut self, new_scale: f32, device: &wgpu::Device, queue: &wgpu::Queue) {
|
||||
self.scale = new_scale;
|
||||
let font_data = self.font_set.face_data(0);
|
||||
let (w, h) = font::compute_cell_metrics(font_data, new_scale);
|
||||
self.cell_width = w;
|
||||
self.cell_height = h;
|
||||
|
||||
let size = wgpu::Extent3d {
|
||||
width: ATLAS_SIZE,
|
||||
height: ATLAS_SIZE,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
self.texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("glyph_atlas"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
self.texture_view = self.texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
let zero_pixels = vec![0u8; (self.cell_width * self.cell_height * 4) as usize];
|
||||
self.write_slot(0, &zero_pixels, queue);
|
||||
|
||||
self.next_slot = 1;
|
||||
self.glyph_map.clear();
|
||||
}
|
||||
|
||||
fn write_slot(&self, slot: u32, pixels: &[u8], queue: &wgpu::Queue) {
|
||||
let aw = self.atlas_cols();
|
||||
let col = slot % aw;
|
||||
let row = slot / aw;
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &self.texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d {
|
||||
x: col * self.cell_width,
|
||||
y: row * self.cell_height,
|
||||
z: 0,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
pixels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(self.cell_width * 4),
|
||||
rows_per_image: Some(self.cell_height),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: self.cell_width,
|
||||
height: self.cell_height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn ensure(&mut self, ch: char, style: u8, queue: &wgpu::Queue) -> u32 {
|
||||
if let Some(&slot) = self.glyph_map.get(&(ch, style)) {
|
||||
return slot;
|
||||
}
|
||||
|
||||
let is_wide = ch.width().unwrap_or(1) > 1;
|
||||
let need_slots = if is_wide { 2 } else { 1 };
|
||||
|
||||
if self.next_slot + need_slots - 1 >= self.max_glyphs() {
|
||||
return 0;
|
||||
}
|
||||
let slot = self.next_slot;
|
||||
self.next_slot += need_slots;
|
||||
|
||||
let (render_w, render_h) = if is_wide {
|
||||
(self.cell_width * 2, self.cell_height)
|
||||
} else {
|
||||
(self.cell_width, self.cell_height)
|
||||
};
|
||||
|
||||
let mut pixels = None;
|
||||
if let Some(color_font) = &self.color_font {
|
||||
pixels = font::render_color_glyph(ch, color_font, render_w, render_h);
|
||||
}
|
||||
let pixels = pixels.unwrap_or_else(|| {
|
||||
let font_data = self.font_set.face_data(style);
|
||||
self.rasterizer.render(font_data, ch, self.scale, render_w, render_h)
|
||||
});
|
||||
|
||||
if is_wide {
|
||||
let half_bytes = (self.cell_width * 4) as usize;
|
||||
let row_bytes = (render_w * 4) as usize;
|
||||
for half in 0..2 {
|
||||
let mut half_pixels = Vec::with_capacity((self.cell_width * self.cell_height * 4) as usize);
|
||||
for row in 0..self.cell_height {
|
||||
let start = (row as usize * row_bytes) + (half as usize * half_bytes);
|
||||
half_pixels.extend_from_slice(&pixels[start..start + half_bytes]);
|
||||
}
|
||||
self.write_slot(slot + half, &half_pixels, queue);
|
||||
}
|
||||
} else {
|
||||
self.write_slot(slot, &pixels, queue);
|
||||
}
|
||||
|
||||
self.glyph_map.insert((ch, style), slot);
|
||||
slot
|
||||
}
|
||||
}
|
||||
16
src/color.rs
Normal file
16
src/color.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
pub fn pack_rgb(r: u8, g: u8, b: u8) -> u32 {
|
||||
(r as u32) << 24 | (g as u32) << 16 | (b as u32) << 8
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_pack_rgb() {
|
||||
assert_eq!(pack_rgb(255, 0, 0), 0xFF000000);
|
||||
assert_eq!(pack_rgb(0, 255, 0), 0x00FF0000);
|
||||
assert_eq!(pack_rgb(0, 0, 255), 0x0000FF00);
|
||||
assert_eq!(pack_rgb(0, 0, 0), 0x00000000);
|
||||
assert_eq!(pack_rgb(255, 255, 255), 0xFFFFFF00);
|
||||
}
|
||||
}
|
||||
415
src/font.rs
Normal file
415
src/font.rs
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
use std::io::Cursor;
|
||||
use swash::scale::{ScaleContext, outline::Outline};
|
||||
use swash::{FontRef, TableProvider, tag_from_bytes};
|
||||
use ttf_parser::RasterImageFormat;
|
||||
|
||||
pub struct FontSet {
|
||||
pub regular: Vec<u8>,
|
||||
pub bold: Vec<u8>,
|
||||
pub italic: Vec<u8>,
|
||||
pub bold_italic: Vec<u8>,
|
||||
}
|
||||
|
||||
impl FontSet {
|
||||
pub fn face_data(&self, style: u8) -> &[u8] {
|
||||
match style & 3 {
|
||||
0 => &self.regular,
|
||||
1 => &self.bold,
|
||||
2 => &self.italic,
|
||||
3 => &self.bold_italic,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ColorFont {
|
||||
pub face: ttf_parser::Face<'static>,
|
||||
}
|
||||
|
||||
unsafe impl Send for ColorFont {}
|
||||
unsafe impl Sync for ColorFont {}
|
||||
|
||||
fn font_upem(font: &FontRef) -> f32 {
|
||||
font.table_by_tag(tag_from_bytes(b"head"))
|
||||
.and_then(|head| {
|
||||
if head.len() >= 20 {
|
||||
Some(u16::from_be_bytes([head[18], head[19]]) as f32)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(1000.0)
|
||||
}
|
||||
|
||||
fn glyph_advance(font: &FontRef, glyph_id: u16, upem: f32, scale: f32) -> f32 {
|
||||
let num_hmetrics = font
|
||||
.table_by_tag(tag_from_bytes(b"hhea"))
|
||||
.and_then(|hhea| {
|
||||
if hhea.len() >= 36 {
|
||||
Some(u16::from_be_bytes([hhea[34], hhea[35]]) as usize)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(1);
|
||||
let adv_units = font
|
||||
.table_by_tag(tag_from_bytes(b"hmtx"))
|
||||
.and_then(|hmtx| {
|
||||
let idx = (glyph_id as usize).min(num_hmetrics.saturating_sub(1));
|
||||
let off = idx * 4;
|
||||
if off + 2 <= hmtx.len() {
|
||||
Some(u16::from_be_bytes([hmtx[off], hmtx[off + 1]]) as f32)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(500.0);
|
||||
adv_units * scale / upem
|
||||
}
|
||||
|
||||
pub struct GlyphRasterizer {
|
||||
ctx: ScaleContext,
|
||||
scratch: swash::zeno::Scratch,
|
||||
}
|
||||
|
||||
impl GlyphRasterizer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
ctx: ScaleContext::new(),
|
||||
scratch: swash::zeno::Scratch::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&mut self,
|
||||
font_data: &[u8],
|
||||
ch: char,
|
||||
scale: f32,
|
||||
slot_w: u32,
|
||||
slot_h: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut img = vec![0u8; (slot_w * slot_h * 4) as usize];
|
||||
|
||||
let font = match FontRef::from_index(font_data, 0) {
|
||||
Some(f) => f,
|
||||
None => return img,
|
||||
};
|
||||
let glyph_id = font.charmap().map(ch);
|
||||
|
||||
let upem = font_upem(&font);
|
||||
let metrics = font.metrics(&[]);
|
||||
let descent_px = metrics.descent * scale / upem;
|
||||
|
||||
let advance = glyph_advance(&font, glyph_id, upem, scale);
|
||||
let x_off = (slot_w as f32 - advance) / 2.0;
|
||||
|
||||
let mut scaler = self.ctx.builder(font)
|
||||
.hint(false)
|
||||
.size(scale)
|
||||
.build();
|
||||
|
||||
let mut outline = Outline::new();
|
||||
if scaler.scale_outline_into(glyph_id, &mut outline) {
|
||||
let mut alpha = vec![0u8; (slot_w * slot_h) as usize];
|
||||
let _placement = swash::zeno::Mask::with_scratch(outline.path(), &mut self.scratch)
|
||||
.format(swash::zeno::Format::Alpha)
|
||||
.origin(swash::zeno::Origin::BottomLeft)
|
||||
.size(slot_w, slot_h)
|
||||
.offset(swash::zeno::Vector::ZERO)
|
||||
.render_offset(swash::zeno::Vector { x: x_off, y: descent_px })
|
||||
.render_into(&mut alpha, None);
|
||||
|
||||
for i in 0..(slot_w * slot_h) as usize {
|
||||
let a = alpha[i];
|
||||
if a > 0 {
|
||||
img[i * 4] = 255;
|
||||
img[i * 4 + 1] = 255;
|
||||
img[i * 4 + 2] = 255;
|
||||
img[i * 4 + 3] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp top/bottom edge rows — eliminates 1-sub-pixel fringe at
|
||||
// cell boundaries when the path exactly fills the fixed-size mask.
|
||||
if slot_h > 1 {
|
||||
for x in 0..slot_w as usize {
|
||||
let a_top = x * 4 + 3;
|
||||
if img[a_top] > 0 && img[slot_w as usize * 4 + 3 + x * 4] > 0 {
|
||||
img[a_top] = 255;
|
||||
}
|
||||
let bot_row = (slot_h - 1) as usize;
|
||||
let a_bot = (bot_row * slot_w as usize + x) * 4 + 3;
|
||||
let a_bot_up = ((bot_row - 1) * slot_w as usize + x) * 4 + 3;
|
||||
if img[a_bot] > 0 && img[a_bot_up] > 0 {
|
||||
img[a_bot] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img
|
||||
}
|
||||
}
|
||||
|
||||
fn read_font_data(path: &str) -> Vec<u8> {
|
||||
std::fs::read(path).expect("failed to read font file")
|
||||
}
|
||||
|
||||
fn variant_path(base: &str, suffix: &str) -> String {
|
||||
let p = std::path::Path::new(base);
|
||||
let dir = p.parent().unwrap_or(std::path::Path::new(""));
|
||||
let stem = p.file_stem().unwrap().to_str().unwrap();
|
||||
let ext = p.extension().unwrap().to_str().unwrap();
|
||||
let stem = stem.strip_suffix("-Regular").unwrap_or(stem);
|
||||
let dir_str = dir.to_str().unwrap_or("");
|
||||
if dir_str.is_empty() {
|
||||
format!("{}-{}.{}", stem, suffix, ext)
|
||||
} else {
|
||||
format!("{}/{}-{}.{}", dir_str, stem, suffix, ext)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_cell_metrics(font_data: &[u8], scale: f32) -> (u32, u32) {
|
||||
let font = FontRef::from_index(font_data, 0).unwrap_or_else(|| panic!("failed to parse font"));
|
||||
let upem = font_upem(&font);
|
||||
let glyph_id = font.charmap().map('M');
|
||||
let advance = glyph_advance(&font, glyph_id, upem, scale);
|
||||
let metrics = font.metrics(&[]);
|
||||
let cell_w = (advance.ceil() as u32).max(1);
|
||||
let cell_h = (((metrics.ascent + metrics.descent) * scale / upem).ceil() as u32).max(1);
|
||||
(cell_w, cell_h)
|
||||
}
|
||||
|
||||
pub fn load_font(font_path: Option<&str>) -> (FontSet, f32) {
|
||||
let scale = 32.0;
|
||||
match font_path {
|
||||
Some(path) => {
|
||||
let regular = read_font_data(path);
|
||||
let bold = read_font_data(&variant_path(path, "Bold"));
|
||||
let italic = read_font_data(&variant_path(path, "Italic"));
|
||||
let bold_italic = read_font_data(&variant_path(path, "BoldItalic"));
|
||||
(FontSet { regular, bold, italic, bold_italic }, scale)
|
||||
}
|
||||
None => {
|
||||
let regular = read_font_data("/usr/local/share/fonts/truetype/sarasa-mono/SarasaMonoSC-Regular.ttf");
|
||||
let bold = read_font_data("/usr/local/share/fonts/truetype/sarasa-mono/SarasaMonoSC-Bold.ttf");
|
||||
let italic = read_font_data("/usr/local/share/fonts/truetype/sarasa-mono/SarasaMonoSC-Italic.ttf");
|
||||
let bold_italic = read_font_data("/usr/local/share/fonts/truetype/sarasa-mono/SarasaMonoSC-BoldItalic.ttf");
|
||||
(FontSet { regular, bold, italic, bold_italic }, scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_color_font(path: &str) -> ColorFont {
|
||||
let data = std::fs::read(path).expect("failed to read color font file");
|
||||
let leaked: &'static [u8] = Box::leak(data.into_boxed_slice());
|
||||
let face = ttf_parser::Face::parse(leaked, 0).expect("failed to parse color font");
|
||||
ColorFont { face }
|
||||
}
|
||||
|
||||
fn decode_png(data: &[u8]) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder = png::Decoder::new(Cursor::new(data));
|
||||
decoder.set_transformations(png::Transformations::normalize_to_color8());
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let w = reader.info().width;
|
||||
let h = reader.info().height;
|
||||
let buf_size = reader.output_buffer_size()?;
|
||||
let mut buf = vec![0u8; buf_size];
|
||||
reader.next_frame(&mut buf).ok()?;
|
||||
|
||||
let (color_type, bit_depth) = reader.output_color_type();
|
||||
match (color_type, bit_depth) {
|
||||
(png::ColorType::Rgba, png::BitDepth::Eight) => Some((w, h, buf)),
|
||||
(png::ColorType::Rgb, png::BitDepth::Eight) => {
|
||||
let mut rgba = vec![0u8; (w * h * 4) as usize];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let si = (y * w + x) as usize * 3;
|
||||
let di = (y * w + x) as usize * 4;
|
||||
rgba[di] = buf[si];
|
||||
rgba[di + 1] = buf[si + 1];
|
||||
rgba[di + 2] = buf[si + 2];
|
||||
rgba[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
Some((w, h, rgba))
|
||||
}
|
||||
(png::ColorType::GrayscaleAlpha, png::BitDepth::Eight) => {
|
||||
let mut rgba = vec![0u8; (w * h * 4) as usize];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let si = (y * w + x) as usize * 2;
|
||||
let di = (y * w + x) as usize * 4;
|
||||
let gray = buf[si];
|
||||
rgba[di] = gray;
|
||||
rgba[di + 1] = gray;
|
||||
rgba[di + 2] = gray;
|
||||
rgba[di + 3] = buf[si + 1];
|
||||
}
|
||||
}
|
||||
Some((w, h, rgba))
|
||||
}
|
||||
(png::ColorType::Grayscale, png::BitDepth::Eight) => {
|
||||
let mut rgba = vec![0u8; (w * h * 4) as usize];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let si = (y * w + x) as usize;
|
||||
let di = (y * w + x) as usize * 4;
|
||||
let gray = buf[si];
|
||||
rgba[di] = gray;
|
||||
rgba[di + 1] = gray;
|
||||
rgba[di + 2] = gray;
|
||||
rgba[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
Some((w, h, rgba))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn un_premultiply_bgra(data: &[u8], w: u32, h: u32) -> Vec<u8> {
|
||||
let mut rgba = vec![0u8; (w * h * 4) as usize];
|
||||
for i in 0..(w * h) as usize {
|
||||
let si = i * 4;
|
||||
let di = i * 4;
|
||||
let b = data[si];
|
||||
let g = data[si + 1];
|
||||
let r = data[si + 2];
|
||||
let a = data[si + 3];
|
||||
if a > 0 {
|
||||
rgba[di] = ((r as u16 * 255) / a as u16).min(255) as u8;
|
||||
rgba[di + 1] = ((g as u16 * 255) / a as u16).min(255) as u8;
|
||||
rgba[di + 2] = ((b as u16 * 255) / a as u16).min(255) as u8;
|
||||
} else {
|
||||
rgba[di] = r;
|
||||
rgba[di + 1] = g;
|
||||
rgba[di + 2] = b;
|
||||
}
|
||||
rgba[di + 3] = a;
|
||||
}
|
||||
rgba
|
||||
}
|
||||
|
||||
fn resize_rgba(src: &[u8], sw: u32, sh: u32, slot_w: u32, slot_h: u32) -> Vec<u8> {
|
||||
let mut img = vec![0u8; (slot_w * slot_h * 4) as usize];
|
||||
for dy in 0..slot_h {
|
||||
for dx in 0..slot_w {
|
||||
let sx = (dx * sw) / slot_w;
|
||||
let sy = (dy * sh) / slot_h;
|
||||
let si = (sy * sw + sx) as usize * 4;
|
||||
let di = (dy * slot_w + dx) as usize * 4;
|
||||
img[di] = src[si];
|
||||
img[di + 1] = src[si + 1];
|
||||
img[di + 2] = src[si + 2];
|
||||
img[di + 3] = src[si + 3];
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
pub fn render_color_glyph(
|
||||
ch: char,
|
||||
color_font: &ColorFont,
|
||||
slot_w: u32,
|
||||
slot_h: u32,
|
||||
) -> Option<Vec<u8>> {
|
||||
let glyph_id = color_font.face.glyph_index(ch)?;
|
||||
let image = color_font.face.glyph_raster_image(glyph_id, slot_h as u16)
|
||||
.or_else(|| color_font.face.glyph_raster_image(glyph_id, 128))
|
||||
.or_else(|| color_font.face.glyph_raster_image(glyph_id, 64))?;
|
||||
if image.width == 0 || image.height == 0 {
|
||||
return None;
|
||||
}
|
||||
let sw = image.width as u32;
|
||||
let sh = image.height as u32;
|
||||
let (img_w, img_h, rgba) = match image.format {
|
||||
RasterImageFormat::PNG => decode_png(image.data)?,
|
||||
RasterImageFormat::BitmapPremulBgra32 => {
|
||||
let rgba = un_premultiply_bgra(image.data, sw, sh);
|
||||
(sw, sh, rgba)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(resize_rgba(&rgba, img_w, img_h, slot_w, slot_h))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_color_glyph_decode() {
|
||||
let cf = load_color_font("/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf");
|
||||
let pixels = render_color_glyph('🍕', &cf, 16, 40).expect("emoji should render");
|
||||
assert_eq!(pixels.len(), (16 * 40 * 4) as usize, "output should be 16x40 RGBA");
|
||||
let has_color = pixels.chunks_exact(4).any(|p| p[0] != 255 || p[1] != 255 || p[2] != 255);
|
||||
assert!(has_color, "emoji should contain non-white color pixels");
|
||||
let has_alpha = pixels.chunks_exact(4).any(|p| p[3] != 0);
|
||||
assert!(has_alpha, "emoji should have non-zero alpha pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ascii_falls_back() {
|
||||
let cf = load_color_font("/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf");
|
||||
assert!(render_color_glyph('A', &cf, 16, 40).is_none(), "ASCII should return None from color font");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_box_draw_vertical_coverage() {
|
||||
let paths = [
|
||||
("SarasaTermSC", "/usr/local/share/fonts/truetype/sarasa/SarasaTermSC-Regular.ttf"),
|
||||
("SarasaMonoSC", "/usr/local/share/fonts/truetype/sarasa-mono/SarasaMonoSC-Regular.ttf"),
|
||||
("IosevkaTerm", "/usr/local/share/fonts/truetype/iosevka/IosevkaTerm-Regular.ttf"),
|
||||
("DejaVuSansMono", "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"),
|
||||
];
|
||||
let scale = 32.0;
|
||||
let slot_w = 16u32;
|
||||
let slot_h = 40u32;
|
||||
|
||||
let mut rasterizer = GlyphRasterizer::new();
|
||||
for (name, path) in &paths {
|
||||
println!("\n=== {} ===", name);
|
||||
let font_data = std::fs::read(path).expect("failed to read font file");
|
||||
let chs = ['│', '┌', '┐', '└', '┘', '─'];
|
||||
for &ch in &chs {
|
||||
let pixels = rasterizer.render(&font_data, ch, scale, slot_w, slot_h);
|
||||
let bar_x = (0..slot_w).find(|&x| {
|
||||
(slot_h/4..3*slot_h/4).any(|y| {
|
||||
let idx = ((y * slot_w + x) * 4 + 3) as usize;
|
||||
pixels[idx] > 128
|
||||
})
|
||||
});
|
||||
let bar_x = match bar_x {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
println!(" {}: no vertical bar found", ch);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let rows: Vec<(u32, u8)> = (0..slot_h)
|
||||
.map(|y| {
|
||||
let idx = ((y * slot_w + bar_x) * 4 + 3) as usize;
|
||||
(y, pixels[idx])
|
||||
})
|
||||
.collect();
|
||||
let covered: Vec<(u32, u8)> = rows.iter().filter(|(_, a)| *a == 255).copied().collect();
|
||||
println!(" {} bar_x={} covered={}/{} rows first={:?} last={:?}",
|
||||
ch, bar_x, covered.len(), slot_h,
|
||||
covered.first().map(|(r, a)| (r, a)),
|
||||
covered.last().map(|(r, a)| (r, a)));
|
||||
println!(" coverage (top 5): {:?}",
|
||||
&rows.iter().take(5).map(|(r, a)| (r, a)).collect::<Vec<_>>());
|
||||
println!(" coverage (bottom 5): {:?}",
|
||||
&rows.iter().rev().take(5).map(|(r, a)| (r, a)).collect::<Vec<_>>());
|
||||
if ch == '│' && *name != "DejaVuSansMono" {
|
||||
assert_eq!(
|
||||
covered.len(), slot_h as usize,
|
||||
"{} {}: expected all {} rows at alpha=255, got {}",
|
||||
name, ch, slot_h, covered.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
205
src/lib.rs
Normal file
205
src/lib.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
mod atlas;
|
||||
mod color;
|
||||
mod font;
|
||||
mod renderer;
|
||||
|
||||
use pyo3::buffer::PyBuffer;
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::{PyAny, PyString};
|
||||
use std::ffi::CString;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
static PACK_HELPER: OnceLock<Py<PyAny>> = OnceLock::new();
|
||||
|
||||
fn get_pack_helper(py: Python<'_>) -> PyResult<&Py<PyAny>> {
|
||||
if let Some(f) = PACK_HELPER.get() {
|
||||
return Ok(f);
|
||||
}
|
||||
let code = include_str!("../python/textgrid/_pack_style.py");
|
||||
let code_c = CString::new(code).expect("pack_style.py contains null byte");
|
||||
let file_c = CString::new("_pack_style.py").unwrap();
|
||||
let module_c = CString::new("textgrid._pack_style").unwrap();
|
||||
let module = PyModule::from_code(py, &code_c, &file_c, &module_c)?;
|
||||
let func: Py<PyAny> = module.getattr("pack_style_colors")?.into();
|
||||
let _ = PACK_HELPER.set(func);
|
||||
Ok(PACK_HELPER.get().unwrap())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct UpdateBatch {
|
||||
pub(crate) cols: u32,
|
||||
pub(crate) rows: Vec<(String, Vec<[u32; 3]>)>,
|
||||
}
|
||||
|
||||
#[pyclass]
|
||||
pub struct TextGrid {
|
||||
cols: Arc<AtomicU32>,
|
||||
rows: Arc<AtomicU32>,
|
||||
needs_render: Arc<AtomicBool>,
|
||||
notify_pair: Arc<(Mutex<bool>, Condvar)>,
|
||||
close_requested: Arc<AtomicBool>,
|
||||
update_tx: std::sync::mpsc::Sender<UpdateBatch>,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl TextGrid {
|
||||
#[new]
|
||||
#[pyo3(signature = (rows, cols, title=None, font_path=None, color_font_path=None, on_key=None, appearance_changed=None, event_loop=None, on_close=None))]
|
||||
fn new(
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
title: Option<String>,
|
||||
font_path: Option<String>,
|
||||
color_font_path: Option<String>,
|
||||
on_key: Option<PyObject>,
|
||||
appearance_changed: Option<PyObject>,
|
||||
event_loop: Option<PyObject>,
|
||||
on_close: Option<PyObject>,
|
||||
) -> PyResult<Self> {
|
||||
let needs_render = Arc::new(AtomicBool::new(true));
|
||||
let notify_pair = Arc::new((Mutex::new(false), Condvar::new()));
|
||||
let close_requested = Arc::new(AtomicBool::new(false));
|
||||
let cols_atomic = Arc::new(AtomicU32::new(cols));
|
||||
let rows_atomic = Arc::new(AtomicU32::new(rows));
|
||||
|
||||
let (init_tx, init_rx) = std::sync::mpsc::channel();
|
||||
let (update_tx, update_rx) = std::sync::mpsc::channel();
|
||||
|
||||
let nr = needs_render.clone();
|
||||
let np = notify_pair.clone();
|
||||
let cr = close_requested.clone();
|
||||
let ca = cols_atomic.clone();
|
||||
let ra = rows_atomic.clone();
|
||||
|
||||
let handle = thread::Builder::new()
|
||||
.name("textgrid-render".into())
|
||||
.spawn(move || {
|
||||
renderer::start(
|
||||
rows,
|
||||
cols,
|
||||
nr,
|
||||
np,
|
||||
cr,
|
||||
title.unwrap_or_else(|| "Text Grid".to_string()),
|
||||
font_path,
|
||||
color_font_path,
|
||||
init_tx,
|
||||
update_rx,
|
||||
on_key,
|
||||
appearance_changed,
|
||||
ca,
|
||||
ra,
|
||||
event_loop,
|
||||
on_close,
|
||||
);
|
||||
})
|
||||
.map_err(|e| PyValueError::new_err(format!("failed to spawn render thread: {e}")))?;
|
||||
|
||||
init_rx
|
||||
.recv()
|
||||
.map_err(|_| PyValueError::new_err("render thread failed during initialization"))?;
|
||||
|
||||
Ok(TextGrid {
|
||||
cols: cols_atomic,
|
||||
rows: rows_atomic,
|
||||
needs_render,
|
||||
notify_pair,
|
||||
close_requested,
|
||||
update_tx,
|
||||
thread: Some(handle),
|
||||
})
|
||||
}
|
||||
|
||||
fn update(&self, py: Python, rows: Vec<Bound<PyAny>>) -> PyResult<()> {
|
||||
if self.close_requested.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut row_data: Vec<(String, Vec<[u32; 3]>)> = Vec::with_capacity(rows.len());
|
||||
let mut batch_cols: u32 = 0;
|
||||
|
||||
for row_obj in rows.iter() {
|
||||
let has_data = row_obj.getattr("data").ok();
|
||||
let (text, colors) = if let Some(data_attr) = has_data {
|
||||
let text: String = data_attr.extract()?;
|
||||
let display_width: u32 =
|
||||
text.chars().map(|c| c.width().unwrap_or(1) as u32).sum();
|
||||
if batch_cols == 0 {
|
||||
batch_cols = display_width;
|
||||
}
|
||||
let style_attr = row_obj.getattr("style")?;
|
||||
let helper = get_pack_helper(py)?;
|
||||
let result = helper.call1(py, (style_attr,))?;
|
||||
|
||||
let buf = PyBuffer::<u32>::get(result.bind(py))?;
|
||||
let cell_slice = buf.as_slice(py).ok_or_else(|| {
|
||||
PyValueError::new_err("color buffer is not C-contiguous")
|
||||
})?;
|
||||
let n = cell_slice.len();
|
||||
let mut colors = Vec::with_capacity(n / 3);
|
||||
let mut idx = 0;
|
||||
while idx + 2 < n {
|
||||
colors.push([
|
||||
cell_slice[idx].get(),
|
||||
cell_slice[idx + 1].get(),
|
||||
cell_slice[idx + 2].get(),
|
||||
]);
|
||||
idx += 3;
|
||||
}
|
||||
(text, colors)
|
||||
} else {
|
||||
let py_str = row_obj.downcast::<PyString>()?;
|
||||
let text: String = py_str.to_cow()?.into_owned();
|
||||
let display_width: u32 =
|
||||
text.chars().map(|c| c.width().unwrap_or(1) as u32).sum();
|
||||
if batch_cols == 0 {
|
||||
batch_cols = display_width;
|
||||
}
|
||||
let default_fg = color::pack_rgb(255, 255, 255);
|
||||
let default_bg = color::pack_rgb(0, 0, 0);
|
||||
let colors = vec![[default_fg, default_bg, 0]; display_width as usize];
|
||||
(text, colors)
|
||||
};
|
||||
row_data.push((text, colors));
|
||||
}
|
||||
|
||||
self.update_tx.send(UpdateBatch { cols: batch_cols, rows: row_data }).map_err(|_| {
|
||||
PyValueError::new_err("render thread disconnected")
|
||||
})?;
|
||||
|
||||
self.needs_render.store(true, Ordering::Release);
|
||||
let (lock, cvar) = &*self.notify_pair;
|
||||
*lock.lock().unwrap() = true;
|
||||
cvar.notify_one();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_dimensions(&self) -> (u32, u32) {
|
||||
(self.cols.load(Ordering::Acquire), self.rows.load(Ordering::Acquire))
|
||||
}
|
||||
|
||||
fn close(&mut self, py: Python) {
|
||||
self.close_requested.store(true, Ordering::Release);
|
||||
let (lock, cvar) = &*self.notify_pair;
|
||||
*lock.lock().unwrap() = true;
|
||||
cvar.notify_one();
|
||||
if let Some(handle) = self.thread.take() {
|
||||
py.allow_threads(|| {
|
||||
let _ = handle.join();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pymodule]
|
||||
fn textgrid(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<TextGrid>()?;
|
||||
Ok(())
|
||||
}
|
||||
660
src/renderer.rs
Normal file
660
src/renderer.rs
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use pyo3::prelude::*;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use crate::atlas::GlyphAtlas;
|
||||
use crate::font;
|
||||
use crate::UpdateBatch;
|
||||
|
||||
#[derive(Copy, Clone, Pod, Zeroable)]
|
||||
#[repr(C)]
|
||||
struct GridParams {
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
cell_w: f32,
|
||||
cell_h: f32,
|
||||
window_w: f32,
|
||||
window_h: f32,
|
||||
atlas_cols: u32,
|
||||
atlas_rows: u32,
|
||||
atlas_size: f32,
|
||||
_pad0: u32,
|
||||
_pad1: u32,
|
||||
_pad2: u32,
|
||||
}
|
||||
|
||||
const VERTICES: &[f32] = &[
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
1.0, 0.0, 1.0, 0.0,
|
||||
0.0, 1.0, 0.0, 1.0,
|
||||
1.0, 1.0, 1.0, 1.0,
|
||||
];
|
||||
|
||||
const INDICES: &[u32] = &[0, 1, 2, 1, 3, 2];
|
||||
|
||||
pub fn start(
|
||||
mut rows: u32,
|
||||
mut cols: u32,
|
||||
needs_render: Arc<AtomicBool>,
|
||||
notify_pair: Arc<(Mutex<bool>, Condvar)>,
|
||||
close_requested: Arc<AtomicBool>,
|
||||
title: String,
|
||||
font_path: Option<String>,
|
||||
color_font_path: Option<String>,
|
||||
init_tx: std::sync::mpsc::Sender<()>,
|
||||
update_rx: Receiver<UpdateBatch>,
|
||||
on_key: Option<PyObject>,
|
||||
appearance_changed: Option<PyObject>,
|
||||
cols_atomic: Arc<AtomicU32>,
|
||||
rows_atomic: Arc<AtomicU32>,
|
||||
event_loop: Option<PyObject>,
|
||||
on_close: Option<PyObject>,
|
||||
) {
|
||||
// ----- Font loading & cell metrics -----
|
||||
let (font_set, scale) = font::load_font(font_path.as_deref());
|
||||
let font_data = font_set.face_data(0);
|
||||
let (cell_w, cell_h) = font::compute_cell_metrics(font_data, scale);
|
||||
|
||||
// ----- GLFW -----
|
||||
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
|
||||
if desktop == "GNOME" {
|
||||
glfw::init_hint(glfw::InitHint::Platform(glfw::Platform::X11));
|
||||
}
|
||||
let mut glfw_ctx = glfw::init(glfw::fail_on_errors).unwrap();
|
||||
|
||||
let mut fb_w: u32;
|
||||
let mut fb_h: u32;
|
||||
|
||||
let (window, events_rx) = {
|
||||
glfw_ctx.window_hint(glfw::WindowHint::ClientApi(glfw::ClientApiHint::NoApi));
|
||||
let (mut w, r) = glfw_ctx
|
||||
.create_window(cols * cell_w, rows * cell_h, &title, glfw::WindowMode::Windowed)
|
||||
.unwrap();
|
||||
w.set_size_polling(true);
|
||||
w.set_close_polling(true);
|
||||
w.set_key_polling(true);
|
||||
w.set_char_polling(true);
|
||||
let fb = w.get_framebuffer_size();
|
||||
fb_w = fb.0.max(1) as u32;
|
||||
fb_h = fb.1.max(1) as u32;
|
||||
(w, r)
|
||||
};
|
||||
|
||||
// ----- wgpu -----
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::all(),
|
||||
flags: wgpu::InstanceFlags::default(),
|
||||
memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
|
||||
backend_options: wgpu::BackendOptions::default(),
|
||||
display: None,
|
||||
});
|
||||
|
||||
let surface = instance.create_surface(&window).unwrap();
|
||||
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: Some(&surface),
|
||||
force_fallback_adapter: false,
|
||||
}))
|
||||
.expect("no suitable GPU adapter found");
|
||||
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(
|
||||
&wgpu::DeviceDescriptor {
|
||||
label: None,
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_limits: wgpu::Limits::default(),
|
||||
experimental_features: wgpu::ExperimentalFeatures::disabled(),
|
||||
memory_hints: wgpu::MemoryHints::Performance,
|
||||
trace: wgpu::Trace::Off,
|
||||
},
|
||||
))
|
||||
.expect("failed to create GPU device");
|
||||
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
let format = caps.formats[0];
|
||||
|
||||
let mut surface_config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format,
|
||||
width: fb_w,
|
||||
height: fb_h,
|
||||
present_mode: wgpu::PresentMode::Fifo,
|
||||
alpha_mode: wgpu::CompositeAlphaMode::Auto,
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 2,
|
||||
};
|
||||
surface.configure(&device, &surface_config);
|
||||
|
||||
// ----- Glyph atlas (owned by render thread) -----
|
||||
let mut atlas = GlyphAtlas::new(
|
||||
&device, &queue, font_set, scale, cell_w, cell_h,
|
||||
color_font_path.as_deref(),
|
||||
);
|
||||
let mut atlas_view = atlas.view().clone();
|
||||
|
||||
// ----- Pipeline -----
|
||||
let (pipeline, bind_group_layout) = create_pipeline(&device, format);
|
||||
|
||||
// ----- Vertex / index buffers -----
|
||||
let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("vertex"),
|
||||
size: (VERTICES.len() * core::mem::size_of::<f32>()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(&vertex_buffer, 0, bytemuck::cast_slice(VERTICES));
|
||||
|
||||
let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("index"),
|
||||
size: (INDICES.len() * core::mem::size_of::<u32>()) as u64,
|
||||
usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(&index_buffer, 0, bytemuck::cast_slice(INDICES));
|
||||
|
||||
// ----- Uniform buffer -----
|
||||
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("grid_params"),
|
||||
size: std::mem::size_of::<GridParams>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
fn write_uniform(queue: &wgpu::Queue, buf: &wgpu::Buffer, cols: u32, rows: u32, cell_w: f32, cell_h: f32, w: u32, h: u32, atlas_cols: u32, atlas_rows: u32, atlas_size: f32) {
|
||||
let params = GridParams {
|
||||
cols,
|
||||
rows,
|
||||
cell_w,
|
||||
cell_h,
|
||||
window_w: w as f32,
|
||||
window_h: h as f32,
|
||||
atlas_cols,
|
||||
atlas_rows,
|
||||
atlas_size,
|
||||
_pad0: 0u32,
|
||||
_pad1: 0u32,
|
||||
_pad2: 0u32,
|
||||
};
|
||||
queue.write_buffer(buf, 0, bytemuck::bytes_of(¶ms));
|
||||
}
|
||||
|
||||
fn create_bind_group(
|
||||
device: &wgpu::Device,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
atlas_view: &wgpu::TextureView,
|
||||
sampler: &wgpu::Sampler,
|
||||
uniform_buffer: &wgpu::Buffer,
|
||||
char_buffer: &wgpu::Buffer,
|
||||
char_buffer_size: u64,
|
||||
) -> wgpu::BindGroup {
|
||||
let ub_size = Some(wgpu::BufferSize::new(std::mem::size_of::<GridParams>() as u64).unwrap());
|
||||
let cb_size = Some(wgpu::BufferSize::new(char_buffer_size).unwrap());
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("main"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(atlas_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: uniform_buffer,
|
||||
offset: 0,
|
||||
size: ub_size,
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: char_buffer,
|
||||
offset: 0,
|
||||
size: cb_size,
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
write_uniform(&queue, &uniform_buffer, cols, rows, cell_w as f32, cell_h as f32, fb_w, fb_h, atlas.atlas_cols(), atlas.atlas_rows(), crate::atlas::ATLAS_SIZE as f32);
|
||||
|
||||
// ----- Sampler -----
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("font_sampler"),
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// ----- Char storage buffer (vec4<u32> per char: glyph, fg, bg, attr) -----
|
||||
let char_stride = 16u64;
|
||||
let mut char_buffer_capacity = cols * rows;
|
||||
let char_buffer_size = (char_buffer_capacity as u64) * char_stride;
|
||||
let mut char_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("char_data"),
|
||||
size: char_buffer_size,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let init_data: Vec<u32> = vec![0u32; (char_buffer_capacity * 4) as usize];
|
||||
queue.write_buffer(&char_buffer, 0, bytemuck::cast_slice(&init_data));
|
||||
|
||||
// ----- Bind group -----
|
||||
let mut bind_group = create_bind_group(
|
||||
&device, &bind_group_layout, &atlas_view, &sampler,
|
||||
&uniform_buffer, &char_buffer, char_buffer_size,
|
||||
);
|
||||
|
||||
// Signal Python that the window and GPU are ready
|
||||
let _ = init_tx.send(());
|
||||
|
||||
let waker = Python::with_gil(|py| -> Py<PyAny> {
|
||||
let code = std::ffi::CString::new("lambda: None").unwrap();
|
||||
py.eval(&code, None, None).unwrap().into()
|
||||
});
|
||||
|
||||
// ----- Render loop -----
|
||||
let (lock, cvar) = &*notify_pair;
|
||||
let mut last_batch: Option<UpdateBatch> = None;
|
||||
|
||||
while !close_requested.load(Ordering::Acquire) {
|
||||
glfw_ctx.poll_events();
|
||||
|
||||
for (_, event) in glfw::flush_messages(&events_rx) {
|
||||
match event {
|
||||
glfw::WindowEvent::Close => {
|
||||
close_requested.store(true, Ordering::Release);
|
||||
if let Some(ref cb) = on_close {
|
||||
Python::with_gil(|py| {
|
||||
let _ = cb.call0(py);
|
||||
});
|
||||
}
|
||||
}
|
||||
glfw::WindowEvent::Char(codepoint) => {
|
||||
if let Some(ref cb) = on_key {
|
||||
let s: String = codepoint.into();
|
||||
Python::with_gil(|py| {
|
||||
let _ = cb.call1(py, (s,));
|
||||
});
|
||||
}
|
||||
}
|
||||
glfw::WindowEvent::Key(key, _, glfw::Action::Press | glfw::Action::Repeat, mods) => {
|
||||
if mods.contains(glfw::Modifiers::Control)
|
||||
&& (key == glfw::Key::Equal || key == glfw::Key::Minus)
|
||||
{
|
||||
let factor = if key == glfw::Key::Equal { 1.2 } else { 1.0 / 1.2 };
|
||||
let new_scale = (atlas.scale() * factor).clamp(4.0, 200.0);
|
||||
atlas.set_scale(new_scale, &device, &queue);
|
||||
atlas_view = atlas.view().clone();
|
||||
while update_rx.try_recv().is_ok() {}
|
||||
let new_cols = fb_w / atlas.cell_width();
|
||||
let new_rows = fb_h / atlas.cell_height();
|
||||
cols_atomic.store(new_cols.max(1), Ordering::Release);
|
||||
rows_atomic.store(new_rows.max(1), Ordering::Release);
|
||||
if let Some(ref event) = appearance_changed {
|
||||
Python::with_gil(|py| {
|
||||
let _ = event.call_method0(py, "set");
|
||||
if let Some(ref loop_) = event_loop {
|
||||
let _ = loop_.call_method1(py,
|
||||
"call_soon_threadsafe", (waker.clone_ref(py),));
|
||||
}
|
||||
});
|
||||
}
|
||||
let needed = new_cols.max(1) * new_rows.max(1);
|
||||
if needed > char_buffer_capacity {
|
||||
char_buffer_capacity = needed;
|
||||
let new_size = (char_buffer_capacity as u64) * 16;
|
||||
char_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("char_data"),
|
||||
size: new_size,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
let cb_size = (char_buffer_capacity as u64) * 16;
|
||||
bind_group = create_bind_group(
|
||||
&device, &bind_group_layout, &atlas_view, &sampler,
|
||||
&uniform_buffer, &char_buffer, cb_size,
|
||||
);
|
||||
if let Some(ref batch) = last_batch {
|
||||
let mut data = Vec::with_capacity((batch.rows.len() * batch.cols as usize) * 4);
|
||||
for (text, colors) in &batch.rows {
|
||||
for (ch, [fg, bg, attr]) in text.chars().zip(colors.iter()) {
|
||||
let w = ch.width().unwrap_or(1);
|
||||
if w == 0 {
|
||||
continue;
|
||||
}
|
||||
let glyph = atlas.ensure(ch, (*attr & 3) as u8, &queue);
|
||||
data.push(glyph);
|
||||
data.push(*fg);
|
||||
data.push(*bg);
|
||||
data.push(*attr);
|
||||
if w > 1 {
|
||||
data.push(glyph + 1);
|
||||
data.push(*fg);
|
||||
data.push(*bg);
|
||||
data.push(*attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
queue.write_buffer(&char_buffer, 0, bytemuck::cast_slice(&data));
|
||||
cols = batch.cols;
|
||||
rows = batch.rows.len() as u32;
|
||||
} else {
|
||||
let zero_data = vec![0u32; (cols * rows * 4) as usize];
|
||||
queue.write_buffer(&char_buffer, 0, bytemuck::cast_slice(&zero_data));
|
||||
}
|
||||
let a_cols = atlas.atlas_cols();
|
||||
let a_rows = atlas.atlas_rows();
|
||||
write_uniform(&queue, &uniform_buffer, cols, rows,
|
||||
atlas.cell_width() as f32, atlas.cell_height() as f32,
|
||||
fb_w, fb_h, a_cols, a_rows, crate::atlas::ATLAS_SIZE as f32);
|
||||
needs_render.store(true, Ordering::Release);
|
||||
} else {
|
||||
let s: Option<String> =
|
||||
if key == glfw::Key::V && mods.contains(glfw::Modifiers::Control) {
|
||||
if mods.contains(glfw::Modifiers::Shift) {
|
||||
window.get_clipboard_string()
|
||||
} else {
|
||||
Some(String::from('\x16'))
|
||||
}
|
||||
} else if mods.contains(glfw::Modifiers::Control) {
|
||||
let code = key as i32;
|
||||
if (65..=90).contains(&code) {
|
||||
Some(String::from((code - 64) as u8 as char))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
match key {
|
||||
glfw::Key::Enter => Some("\n".into()),
|
||||
glfw::Key::Escape => Some("\x1b".into()),
|
||||
glfw::Key::Backspace => Some("\x7f".into()),
|
||||
glfw::Key::Tab => Some("\t".into()),
|
||||
glfw::Key::Up => Some("\x1b[A".into()),
|
||||
glfw::Key::Down => Some("\x1b[B".into()),
|
||||
glfw::Key::Left => Some("\x1b[D".into()),
|
||||
glfw::Key::Right => Some("\x1b[C".into()),
|
||||
glfw::Key::Home => Some("\x1b[H".into()),
|
||||
glfw::Key::End => Some("\x1b[F".into()),
|
||||
glfw::Key::PageUp => Some("\x1b[5~".into()),
|
||||
glfw::Key::PageDown => Some("\x1b[6~".into()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(s) = s {
|
||||
if let Some(ref cb) = on_key {
|
||||
Python::with_gil(|py| {
|
||||
let _ = cb.call1(py, (s,));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
glfw::WindowEvent::Size(_, _) => {
|
||||
let fb = window.get_framebuffer_size();
|
||||
let fbw = fb.0.max(1) as u32;
|
||||
let fbh = fb.1.max(1) as u32;
|
||||
if fbw != fb_w || fbh != fb_h {
|
||||
fb_w = fbw;
|
||||
fb_h = fbh;
|
||||
surface_config.width = fbw;
|
||||
surface_config.height = fbh;
|
||||
surface.configure(&device, &surface_config);
|
||||
|
||||
let new_cols = fbw / atlas.cell_width();
|
||||
let new_rows = fbh / atlas.cell_height();
|
||||
cols_atomic.store(new_cols.max(1), Ordering::Release);
|
||||
rows_atomic.store(new_rows.max(1), Ordering::Release);
|
||||
if let Some(ref event) = appearance_changed {
|
||||
Python::with_gil(|py| {
|
||||
let _ = event.call_method0(py, "set");
|
||||
if let Some(ref loop_) = event_loop {
|
||||
let _ = loop_.call_method1(py,
|
||||
"call_soon_threadsafe", (waker.clone_ref(py),));
|
||||
}
|
||||
});
|
||||
}
|
||||
let needed = new_cols.max(1) * new_rows.max(1);
|
||||
if needed > char_buffer_capacity {
|
||||
char_buffer_capacity = needed;
|
||||
let new_size = (char_buffer_capacity as u64) * 16;
|
||||
char_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("char_data"),
|
||||
size: new_size,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let cb_size = (char_buffer_capacity as u64) * 16;
|
||||
bind_group = create_bind_group(
|
||||
&device, &bind_group_layout, &atlas_view, &sampler,
|
||||
&uniform_buffer, &char_buffer, cb_size,
|
||||
);
|
||||
}
|
||||
needs_render.store(true, Ordering::Release);
|
||||
let a_cols = atlas.atlas_cols();
|
||||
let a_rows = atlas.atlas_rows();
|
||||
write_uniform(&queue, &uniform_buffer, cols, rows,
|
||||
atlas.cell_width() as f32, atlas.cell_height() as f32,
|
||||
fbw, fbh, a_cols, a_rows, crate::atlas::ATLAS_SIZE as f32);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if close_requested.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Drain and process incoming update data
|
||||
let mut batch_cols = 0u32;
|
||||
let mut batch_rows = 0u32;
|
||||
while let Ok(batch) = update_rx.try_recv() {
|
||||
last_batch = Some(batch.clone());
|
||||
batch_cols = batch.cols;
|
||||
batch_rows = batch.rows.len() as u32;
|
||||
let mut data = Vec::with_capacity((batch.rows.len() * batch.cols as usize) * 4);
|
||||
for (text, colors) in &batch.rows {
|
||||
for (ch, [fg, bg, attr]) in text.chars().zip(colors.iter()) {
|
||||
let w = ch.width().unwrap_or(1);
|
||||
if w == 0 {
|
||||
continue;
|
||||
}
|
||||
let glyph = atlas.ensure(ch, (*attr & 3) as u8, &queue);
|
||||
data.push(glyph);
|
||||
data.push(*fg);
|
||||
data.push(*bg);
|
||||
data.push(*attr);
|
||||
if w > 1 {
|
||||
data.push(glyph + 1);
|
||||
data.push(*fg);
|
||||
data.push(*bg);
|
||||
data.push(*attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
queue.write_buffer(&char_buffer, 0, bytemuck::cast_slice(&data));
|
||||
needs_render.store(true, Ordering::Release);
|
||||
}
|
||||
if batch_cols > 0 {
|
||||
cols = batch_cols;
|
||||
rows = batch_rows;
|
||||
let a_cols = atlas.atlas_cols();
|
||||
let a_rows = atlas.atlas_rows();
|
||||
write_uniform(&queue, &uniform_buffer, cols, rows,
|
||||
atlas.cell_width() as f32, atlas.cell_height() as f32,
|
||||
fb_w, fb_h, a_cols, a_rows, crate::atlas::ATLAS_SIZE as f32);
|
||||
}
|
||||
|
||||
if needs_render.load(Ordering::Acquire) {
|
||||
let st = match surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(st) => st,
|
||||
wgpu::CurrentSurfaceTexture::Suboptimal(st) => st,
|
||||
_ => continue,
|
||||
};
|
||||
let view = st.texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("render"),
|
||||
});
|
||||
|
||||
{
|
||||
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: 0.06,
|
||||
g: 0.06,
|
||||
b: 0.1,
|
||||
a: 1.0,
|
||||
}),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
rp.set_pipeline(&pipeline);
|
||||
rp.set_vertex_buffer(0, vertex_buffer.slice(..));
|
||||
rp.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||
rp.set_bind_group(0, Some(&bind_group), &[]);
|
||||
rp.draw_indexed(0..6, 0, 0..(rows * cols));
|
||||
}
|
||||
|
||||
queue.submit([encoder.finish()]);
|
||||
st.present();
|
||||
needs_render.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
let guard = lock.lock().unwrap();
|
||||
let _ = cvar.wait_timeout(guard, std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
fn create_pipeline(
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout) {
|
||||
let shader_code = include_str!("shader.wgsl");
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("main"),
|
||||
source: wgpu::ShaderSource::Wgsl(shader_code.into()),
|
||||
});
|
||||
|
||||
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("main"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: Some(
|
||||
std::num::NonZeroU64::new(std::mem::size_of::<GridParams>() as u64)
|
||||
.unwrap(),
|
||||
),
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 3,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(16),
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("main"),
|
||||
bind_group_layouts: &[Some(&bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
let vertex_buffers = [wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &[
|
||||
wgpu::VertexAttribute {
|
||||
format: wgpu::VertexFormat::Float32x2,
|
||||
offset: 0,
|
||||
shader_location: 0,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
format: wgpu::VertexFormat::Float32x2,
|
||||
offset: 8,
|
||||
shader_location: 1,
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("main"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &vertex_buffers,
|
||||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
front_face: wgpu::FrontFace::Ccw,
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
(pipeline, bind_group_layout)
|
||||
}
|
||||
68
src/shader.wgsl
Normal file
68
src/shader.wgsl
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
struct GridParams {
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
cell_w: f32,
|
||||
cell_h: f32,
|
||||
window_w: f32,
|
||||
window_h: f32,
|
||||
atlas_cols: u32,
|
||||
atlas_rows: u32,
|
||||
atlas_size: f32,
|
||||
_pad0: u32,
|
||||
_pad1: u32,
|
||||
_pad2: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var font_atlas: texture_2d<f32>;
|
||||
@group(0) @binding(1) var font_sampler: sampler;
|
||||
@group(0) @binding(2) var<uniform> grid: GridParams;
|
||||
@group(0) @binding(3) var<storage, read> char_data: array<vec4<u32>>;
|
||||
|
||||
struct VIn { @location(0) pos: vec2f, @location(1) uv: vec2f, };
|
||||
struct VOut { @builtin(position) position: vec4f, @location(0) uv: vec2f, @location(1) fg: vec4f, @location(2) bg: vec4f, @location(3) attr: u32, @location(4) cell_uv: vec2f, };
|
||||
|
||||
fn unpack_color(packed: u32) -> vec4f {
|
||||
return vec4f(
|
||||
f32((packed >> 24u) & 0xFFu) / 255.0,
|
||||
f32((packed >> 16u) & 0xFFu) / 255.0,
|
||||
f32((packed >> 8u) & 0xFFu) / 255.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_main(input: VIn, @builtin(instance_index) iid: u32) -> VOut {
|
||||
let col = iid % grid.cols;
|
||||
let row = iid / grid.cols;
|
||||
let x = f32(col) * grid.cell_w + input.pos.x * grid.cell_w;
|
||||
let y = f32(row) * grid.cell_h + input.pos.y * grid.cell_h;
|
||||
let ndc_x = x / grid.window_w * 2.0 - 1.0;
|
||||
let ndc_y = 1.0 - y / grid.window_h * 2.0;
|
||||
var out: VOut;
|
||||
out.position = vec4f(ndc_x, ndc_y, 0.0, 1.0);
|
||||
let entry = char_data[iid];
|
||||
out.fg = unpack_color(entry.y);
|
||||
out.bg = unpack_color(entry.z);
|
||||
out.attr = entry.w;
|
||||
let slot = entry.x;
|
||||
let ac = slot % grid.atlas_cols;
|
||||
let ar = slot / grid.atlas_cols;
|
||||
let uvx = (f32(ac) + input.uv.x) * grid.cell_w / grid.atlas_size;
|
||||
let uvy = (f32(ar) + input.uv.y) * grid.cell_h / grid.atlas_size;
|
||||
out.uv = vec2f(uvx, uvy);
|
||||
out.cell_uv = input.uv;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(@location(0) uv: vec2f, @location(1) fg: vec4f, @location(2) bg: vec4f, @location(3) attr: u32, @location(4) cell_uv: vec2f) -> @location(0) vec4f {
|
||||
let texel = textureSample(font_atlas, font_sampler, uv);
|
||||
let glyph_alpha = texel.a;
|
||||
let is_color = texel.r != 1.0 || texel.g != 1.0 || texel.b != 1.0;
|
||||
let glyph_color = select(fg.rgb * texel.rgb, texel.rgb, is_color);
|
||||
var color = vec4f(mix(bg.rgb, glyph_color, glyph_alpha), 1.0);
|
||||
if (attr & 4u) != 0u && cell_uv.y >= 0.79 && cell_uv.y <= 0.85 {
|
||||
color = vec4f(fg.rgb, 1.0);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
163
tests/test_color_packing.py
Normal file
163
tests/test_color_packing.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""Tests for textgrid color packing (no GPU required)."""
|
||||
|
||||
from textgrid._pack_style import pack_style_colors, _pack_one, _xterm
|
||||
|
||||
|
||||
def test_pack_red():
|
||||
"""(255, 0, 0) → 0xFF000000 (R in bits 31-24)"""
|
||||
from termstr import CharStyle
|
||||
result = pack_style_colors((CharStyle(fg_color=(255, 0, 0)),))
|
||||
assert result[0] == 0xFF000000
|
||||
|
||||
|
||||
def test_pack_green():
|
||||
"""(0, 255, 0) → 0x00FF0000 (G in bits 23-16)"""
|
||||
from termstr import CharStyle
|
||||
result = pack_style_colors((CharStyle(fg_color=(0, 255, 0)),))
|
||||
assert result[0] == 0x00FF0000
|
||||
|
||||
|
||||
def test_pack_blue():
|
||||
"""(0, 0, 255) → 0x0000FF00 (B in bits 15-8)"""
|
||||
from termstr import CharStyle
|
||||
result = pack_style_colors((CharStyle(fg_color=(0, 0, 255)),))
|
||||
assert result[0] == 0x0000FF00
|
||||
|
||||
|
||||
def test_pack_black():
|
||||
"""(0, 0, 0) → 0x00000000"""
|
||||
from termstr import CharStyle
|
||||
result = pack_style_colors((CharStyle(fg_color=(0, 0, 0)),))
|
||||
assert result[0] == 0x00000000
|
||||
|
||||
|
||||
def test_pack_white():
|
||||
"""(255, 255, 255) → 0xFFFFFF00"""
|
||||
from termstr import CharStyle
|
||||
result = pack_style_colors((CharStyle(fg_color=(255, 255, 255)),))
|
||||
assert result[0] == 0xFFFFFF00
|
||||
|
||||
|
||||
def test_pack_fg_and_bg():
|
||||
"""fg and bg are interleaved in the output array"""
|
||||
from termstr import CharStyle
|
||||
result = pack_style_colors((CharStyle(fg_color=(255, 0, 0), bg_color=(0, 0, 255)),))
|
||||
assert result[0] == 0xFF000000, "fg should be first"
|
||||
assert result[1] == 0x0000FF00, "bg should be second"
|
||||
assert result[2] == 0, "attr should be 0 for default style"
|
||||
|
||||
|
||||
def test_pack_xterm_index_9():
|
||||
"""xterm index 9 is bright red = (255, 0, 0)"""
|
||||
from termstr import CharStyle
|
||||
result = pack_style_colors((CharStyle(fg_color=9),))
|
||||
expected = pack_style_colors((CharStyle(fg_color=(255, 0, 0)),))
|
||||
assert result[0] == expected[0], "index 9 should match (255, 0, 0)"
|
||||
|
||||
|
||||
def test_xterm_table_index_0():
|
||||
"""xterm index 0 is (0, 0, 0)"""
|
||||
assert _xterm[0] == (0, 0, 0)
|
||||
|
||||
|
||||
def test_xterm_table_index_1():
|
||||
"""xterm index 1 is (205, 0, 0)"""
|
||||
assert _xterm[1] == (205, 0, 0)
|
||||
|
||||
|
||||
def test_xterm_table_index_15():
|
||||
"""xterm index 15 is (255, 255, 255)"""
|
||||
assert _xterm[15] == (255, 255, 255)
|
||||
|
||||
|
||||
def test_xterm_table_index_16():
|
||||
"""xterm index 16 is (0, 0, 0) — start of color cube"""
|
||||
assert _xterm[16] == (0, 0, 0)
|
||||
|
||||
|
||||
def test_xterm_table_index_231():
|
||||
"""xterm index 231 is (255, 255, 255) — end of color cube"""
|
||||
assert _xterm[231] == (255, 255, 255)
|
||||
|
||||
|
||||
def test_xterm_table_index_232():
|
||||
"""xterm index 232 is (8, 8, 8) — start of gray ramp"""
|
||||
assert _xterm[232] == (8, 8, 8)
|
||||
|
||||
|
||||
def test_xterm_table_index_255():
|
||||
"""xterm index 255 is (238, 238, 238) — end of gray ramp"""
|
||||
assert _xterm[255] == (238, 238, 238)
|
||||
|
||||
|
||||
def test_identity_fast_path_matches_slow_path():
|
||||
"""all(s is first) fast path produces same output as per-element iteration"""
|
||||
from termstr import CharStyle
|
||||
style = CharStyle(fg_color=(255, 80, 80), bg_color=(20, 40, 60))
|
||||
styles = tuple([style] * 100)
|
||||
# The identity check will trigger for n > 1
|
||||
result = pack_style_colors(styles)
|
||||
# Length: 100 chars * 3 values (fg, bg, attr) = 300
|
||||
assert len(result) == 300
|
||||
# Every triplet should be identical
|
||||
for i in range(0, 300, 3):
|
||||
assert result[i] == result[0], f"fg differs at char {i//3}"
|
||||
assert result[i + 1] == result[1], f"bg differs at char {i//3}"
|
||||
assert result[i + 2] == result[2], f"attr differs at char {i//3}"
|
||||
|
||||
|
||||
def test_mixed_styles_are_all_different():
|
||||
"""Each char has different colors — no identity optimization triggered"""
|
||||
from termstr import CharStyle
|
||||
styles = tuple([
|
||||
CharStyle(fg_color=(255, 0, 0)),
|
||||
CharStyle(fg_color=(0, 255, 0)),
|
||||
CharStyle(fg_color=(0, 0, 255)),
|
||||
])
|
||||
result = pack_style_colors(styles)
|
||||
assert len(result) == 9
|
||||
assert result[0] != result[3], "red != green"
|
||||
assert result[0] != result[6], "red != blue"
|
||||
assert result[3] != result[6], "green != blue"
|
||||
|
||||
|
||||
def test_output_length():
|
||||
"""N chars produces 3N output values"""
|
||||
from termstr import CharStyle
|
||||
for n in [1, 5, 100]:
|
||||
styles = tuple([CharStyle(fg_color=(255, 0, 0))] * n)
|
||||
result = pack_style_colors(styles)
|
||||
assert len(result) == 3 * n, f"expected {3*n}, got {len(result)}"
|
||||
|
||||
|
||||
def test_xterm_pack_matches_rgb():
|
||||
"""Packing xterm index should produce same u32 as direct RGB"""
|
||||
from termstr import CharStyle
|
||||
for idx in [1, 9, 16, 100, 200, 255]:
|
||||
r, g, b = _xterm[idx]
|
||||
xterm_result = pack_style_colors((CharStyle(fg_color=idx),))
|
||||
rgb_result = pack_style_colors((CharStyle(fg_color=(r, g, b)),))
|
||||
assert xterm_result[0] == rgb_result[0], \
|
||||
f"index {idx} = ({r},{g},{b}) didn't match direct RGB"
|
||||
|
||||
|
||||
def test_attr_encoding():
|
||||
"""bold/italic flags encoded in attr byte"""
|
||||
from termstr import CharStyle
|
||||
r = pack_style_colors((CharStyle(fg_color=(255, 0, 0)),))
|
||||
assert r[2] == 0, "default: no bold, no italic"
|
||||
|
||||
r = pack_style_colors((CharStyle(fg_color=(255, 0, 0)).bold(),))
|
||||
assert r[2] & 1, "bold bit set"
|
||||
|
||||
r = pack_style_colors((CharStyle(fg_color=(255, 0, 0)).italic(),))
|
||||
assert r[2] & 2, "italic bit set"
|
||||
|
||||
r = pack_style_colors((CharStyle(fg_color=(255, 0, 0)).bold().italic(),))
|
||||
assert (r[2] & 3) == 3, "both bold and italic bits set"
|
||||
|
||||
r = pack_style_colors((CharStyle(fg_color=(255, 0, 0)).underline(),))
|
||||
assert r[2] & 4, "underline bit set"
|
||||
|
||||
r = pack_style_colors((CharStyle(fg_color=(255, 0, 0)).bold().italic().underline(),))
|
||||
assert (r[2] & 7) == 7, "bold, italic, and underline bits set"
|
||||
39
uv.lock
generated
Normal file
39
uv.lock
generated
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "cwcwidth"
|
||||
version = "0.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/17/aadd0c6190dca91aa27c4d7e84d69d30fdf4966e7764247cdc395f8fe7d9/cwcwidth-0.1.5.tar.gz", hash = "sha256:2c840e7d85f6de45c45986b416d79312c91882e1121b78d4c347e49c4238c09d", size = 11071, upload-time = "2021-10-10T16:46:56.825Z" }
|
||||
|
||||
[[package]]
|
||||
name = "termstr"
|
||||
version = "2025.6.21"
|
||||
source = { directory = "../termstr" }
|
||||
dependencies = [
|
||||
{ name = "cwcwidth" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "cwcwidth", specifier = "==0.1.5" }]
|
||||
|
||||
[[package]]
|
||||
name = "textgrid"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "cwcwidth" },
|
||||
{ name = "termstr" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "cwcwidth", specifier = "==0.1.5" },
|
||||
{ name = "termstr", directory = "../termstr" },
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue