initial commit

This commit is contained in:
Andrew Hamilton 2026-07-20 12:53:51 +10:00
commit f04f3a8ae9
18 changed files with 4577 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.venv/
target/
__pycache__/
.pytest_cache/
.eris/
*.so

2207
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

18
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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",
]

View 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__

View 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
View 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
View 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
View 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
View 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
View 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
View 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(&params));
}
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
View 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
View 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
View 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" },
]