Ken Van Hoeylandt 86010186d8
Improve Terminal, App List, app starting, and more (#658)
app-module:

- Implement `AppStartContext` to reduce amount of `app_start_` and `app_exec` functions: replace them with `app_start_with_context()`
- Add support for environment variables when starting apps
- Implement support for environment variables for apps

graphics-module:

- Add this new module
- Move font-module code into this module
- Implement pixel_buffer

apps:

- Terminal app: reduce memory usage by only buffering a single row of characters, and try to buffer in IRAM when possible. Implement new pixel_buffer.
- App List: Mark apps as favourite to pin them on top. Use touch or press "x" key.
- App List: Add toolbar button that shows alert dialog with info about using favourites
2026-09-24 00:44:28 +02:00

191 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""
Downloads a monospace TTF and rasterizes it into a FixedWidthFont (see include/font/font.h) covering:
- U+0020-U+007E
- Height rows per glyph
- Each row ceil(width / 8)
- Bytes (bit 7 of the first byte = leftmost pixel)
Plus a header declaring the font. Glyph width is the font's own natural advance width at the given size.
Requires the freetype-py package: pip install freetype-py
Usage:
python3 generate.py --url URL --size SIZE --output NAME
"""
import argparse
import math
import os
import re
import urllib.request
import freetype
GLYPH_FIRST = 0x20
GLYPH_LAST = 0x7E
MODULE_DIR = os.path.join(os.path.dirname(__file__), "..")
SOURCE_DIR = os.path.join(MODULE_DIR, "source")
INCLUDE_DIR = os.path.join(MODULE_DIR, "include", "font")
def download_file(url: str, filename: str):
if not os.path.exists(filename):
print(f"Downloading {filename} from {url}")
urllib.request.urlretrieve(url, filename)
else:
print(f"{filename} already exists, skipping download.")
def measure_advance_width(face: freetype.Face, size: int) -> float:
"""Natural advance width (px) of the font at `size` px tall, isotropic DPI."""
face.set_char_size(0, size * 64, 72, 72)
face.load_char("M", freetype.FT_LOAD_DEFAULT)
return face.glyph.advance.x / 64
def get_bit(bitmap, x: int, y: int) -> bool:
if x < 0 or x >= bitmap.width or y < 0 or y >= bitmap.rows:
return False
byte = bitmap.buffer[y * abs(bitmap.pitch) + x // 8]
return bool((byte >> (7 - x % 8)) & 1)
def measure_baseline(face: freetype.Face, cell_height: int) -> int:
"""
Row (from the cell top) the baseline sits on.
face.size.ascender/descender are line-spacing metrics, not ink extents - many fonts set them
taller than the glyphs actually are, which pushes the baseline row below the bottom of the
cell and clips it off entirely. Measuring the real cap-height ('A') and descender depth ('g')
at this pixel size keeps both in view instead.
"""
face.load_char("A", freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_MONO)
cap_top = face.glyph.bitmap_top
face.load_char("g", freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_MONO)
descender = face.glyph.bitmap.rows - face.glyph.bitmap_top
max_baseline = cell_height - 1 - descender
if max_baseline >= cap_top:
baseline = cap_top + (max_baseline - cap_top) // 2
else:
# Cap height and descender depth don't both fit in cell_height rows (common at very
# small sizes) - keep the cap/baseline row intact and let the descender clip instead.
baseline = cap_top
return min(max(baseline, 0), cell_height - 1)
def render_glyph(face: freetype.Face, codepoint: int, cell_width: int, cell_height: int, baseline_y: int, bytes_per_row: int) -> bytearray:
face.load_char(chr(codepoint), freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_MONO)
glyph = face.glyph
bitmap = glyph.bitmap
out = bytearray(cell_height * bytes_per_row)
for row in range(cell_height):
src_y = row - baseline_y + glyph.bitmap_top
for col in range(cell_width):
src_x = col - glyph.bitmap_left
if get_bit(bitmap, src_x, src_y):
out[row * bytes_per_row + col // 8] |= 1 << (7 - col % 8)
return out
def split_name(output: str) -> tuple:
"""'ibmplexmono14' -> ('ibmplexmono', '14'), so identifiers can put an underscore between the
font name and its size instead of running them together."""
match = re.match(r"^([a-zA-Z]+)(\d+)$", output)
if not match:
raise ValueError(f"'{output}' must be a name followed by digits, e.g. 'ibmplexmono14'")
return match.group(1), match.group(2)
def escape_char(ch: str) -> str:
if ch == "\\":
return "\\\\"
if ch == '"':
return '\\"'
return ch
def write_c_file(path: str, output: str, width: int, height: int, bytes_per_row: int, glyphs: dict):
glyph_count = GLYPH_LAST - GLYPH_FIRST + 1
glyph_bytes = height * bytes_per_row
total_bytes = glyph_count * glyph_bytes
name, size_digits = split_name(output)
bitmap_name = f"{output}_glyph_bitmap"
struct_name = f"{name}_{size_digits}_font"
config_macro = f"CONFIG_TT_FONT_{name.upper()}_{size_digits}"
with open(path, "w") as f:
f.write(f"/*\n * {os.path.basename(path)} - {output} FixedWidthFont\n")
f.write(f" *\n * Rasterized from a TTF with Modules/graphics-module/scripts/generate.py.\n")
f.write(f" * Each glyph is {width} pixels wide and {height} pixels tall, stored as {height} rows of\n")
f.write(f" * {bytes_per_row} byte{'s' if bytes_per_row != 1 else ''} each (bit 7 of the first byte = leftmost pixel).\n */\n\n")
f.write(f"#include <font/{output}.h>\n\n")
f.write("#ifdef ESP_PLATFORM\n")
f.write("#include <sdkconfig.h>\n")
f.write("#endif\n\n")
f.write(f"// See Modules/graphics-module/Kconfig: undefined (POSIX, or an ESP-IDF build predating\n")
f.write(f"// this font's Kconfig entry) is treated the same as enabled.\n")
f.write(f"#if !defined({config_macro}) || {config_macro}\n\n")
f.write(f"// Glyph bitmap data: {glyph_count} characters (0x20-0x7E), {glyph_bytes} bytes each = {total_bytes} bytes\n")
f.write(f"static const uint8_t {bitmap_name}[] = {{\n")
for cp in range(GLYPH_FIRST, GLYPH_LAST + 1):
f.write(f' /* U+{cp:04X} "{escape_char(chr(cp))}" */\n')
data = glyphs[cp]
f.write(" " + ", ".join(f"0x{b:X}" for b in data) + ",\n")
f.write("};\n\n")
f.write(f"const FixedWidthFont {struct_name} = {{\n")
f.write(f" .glyph_width = {width},\n")
f.write(f" .glyph_height = {height},\n")
f.write(f" .glyph_bytes_per_row = {bytes_per_row},\n")
f.write(f" .first_codepoint = 0x{GLYPH_FIRST:02X},\n")
f.write(f" .last_codepoint = 0x{GLYPH_LAST:02X},\n")
f.write(f" .glyph_bitmap = {bitmap_name},\n")
f.write("};\n\n")
f.write(f"#endif // {config_macro}\n")
def write_h_file(path: str, output: str):
name, size_digits = split_name(output)
struct_name = f"{name}_{size_digits}_font"
with open(path, "w") as f:
f.write("// SPDX-License-Identifier: Apache-2.0\n")
f.write("#pragma once\n\n")
f.write("#include <font/font.h>\n\n")
f.write("#ifdef __cplusplus\nextern \"C\" {\n#endif\n\n")
f.write(f"extern const FixedWidthFont {struct_name};\n\n")
f.write("#ifdef __cplusplus\n}\n#endif\n")
def generate(mono_url: str, size: int, output: str):
ttf_filename = os.path.basename(mono_url)
download_file(mono_url, ttf_filename)
face = freetype.Face(ttf_filename)
natural_width = measure_advance_width(face, size)
if natural_width <= 0:
raise RuntimeError("Font reported a non-positive advance width")
width = round(natural_width)
bytes_per_row = math.ceil(width / 8)
baseline_y = measure_baseline(face, size)
glyphs = {cp: render_glyph(face, cp, width, size, baseline_y, bytes_per_row) for cp in range(GLYPH_FIRST, GLYPH_LAST + 1)}
c_path = os.path.join(SOURCE_DIR, f"{output}.c")
h_path = os.path.join(INCLUDE_DIR, f"{output}.h")
print(f"Generating {h_path}")
write_h_file(h_path, output)
print(f"Generating {c_path}")
write_c_file(c_path, output, width, size, bytes_per_row, glyphs)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", required=True, help="URL of the monospace TTF to download")
parser.add_argument("--size", type=int, required=True, help="Glyph height in pixels")
parser.add_argument("--output", required=True, help="Output file base name (no extension)")
args = parser.parse_args()
generate(args.url, args.size, args.output)