feat: big title

This commit is contained in:
2026-04-01 12:16:16 +02:00
parent 8d3245b7b1
commit 175de893aa
8 changed files with 317 additions and 1 deletions

209
backend/uframe/fonts.py Normal file
View File

@@ -0,0 +1,209 @@
"""µFrame Big Text Fonts — multi-line ASCII art letter definitions.
Each font is a dict mapping characters to a list of strings (one per line).
All characters in a font have the same height.
"""
from __future__ import annotations
# ---------------------------------------------------------------------------
# Block font — solid █ with box-drawing. Height: 6 lines.
# ---------------------------------------------------------------------------
_BLOCK: dict[str, list[str]] = {
"A": [" █████╗ ", "██╔══██╗", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
"B": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔══██╗", "██████╔╝", "╚═════╝ "],
"C": [" ██████╗", "██╔════╝", "██║ ", "██║ ", "╚██████╗", " ╚═════╝"],
"D": ["██████╗ ", "██╔══██╗", "██║ ██║", "██║ ██║", "██████╔╝", "╚═════╝ "],
"E": ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "███████╗", "╚══════╝"],
"F": ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "██║ ", "╚═╝ "],
"G": [" ██████╗ ", "██╔════╝ ", "██║ ███╗", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
"H": ["██╗ ██╗", "██║ ██║", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
"I": ["██╗", "██║", "██║", "██║", "██║", "╚═╝"],
"J": [" ██╗", " ██║", " ██║", "██ ██║", "╚█████╔╝", " ╚════╝ "],
"K": ["██╗ ██╗", "██║ ██╔╝", "█████╔╝ ", "██╔═██╗ ", "██║ ██╗", "╚═╝ ╚═╝"],
"L": ["██╗ ", "██║ ", "██║ ", "██║ ", "███████╗", "╚══════╝"],
"M": ["███╗ ███╗", "████╗ ████║", "██╔████╔██║", "██║╚██╔╝██║", "██║ ╚═╝ ██║", "╚═╝ ╚═╝"],
"N": ["███╗ ██╗", "████╗ ██║", "██╔██╗ ██║", "██║╚██╗██║", "██║ ╚████║", "╚═╝ ╚═══╝"],
"O": [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
"P": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔═══╝ ", "██║ ", "╚═╝ "],
"Q": [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║▄▄ ██║", "╚██████╔╝", " ╚══▀▀═╝ "],
"R": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔══██╗", "██║ ██║", "╚═╝ ╚═╝"],
"S": ["███████╗", "██╔════╝", "███████╗", "╚════██║", "███████║", "╚══════╝"],
"T": ["████████╗", "╚══██╔══╝", " ██║ ", " ██║ ", " ██║ ", " ╚═╝ "],
"U": ["██╗ ██╗", "██║ ██║", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
"V": ["██╗ ██╗", "██║ ██║", "██║ ██║", "╚██╗ ██╔╝", " ╚████╔╝ ", " ╚═══╝ "],
"W": ["██╗ ██╗", "██║ ██║", "██║ █╗ ██║", "██║███╗██║", "╚███╔███╔╝", " ╚══╝╚══╝ "],
"X": ["██╗ ██╗", "╚██╗██╔╝", " ╚███╔╝ ", " ██╔██╗ ", "██╔╝ ██╗", "╚═╝ ╚═╝"],
"Y": ["██╗ ██╗", "╚██╗ ██╔╝", " ╚████╔╝ ", " ╚██╔╝ ", " ██║ ", " ╚═╝ "],
"Z": ["███████╗", "╚══███╔╝", " ███╔╝ ", " ███╔╝ ", "███████╗", "╚══════╝"],
"0": [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
"1": [" ██╗", "███║", "╚██║", " ██║", " ██║", " ╚═╝"],
"2": ["██████╗ ", "╚════██╗", " █████╔╝", "██╔═══╝ ", "███████╗", "╚══════╝"],
"3": ["██████╗ ", "╚════██╗", " █████╔╝", " ╚═══██╗", "██████╔╝", "╚═════╝ "],
"4": ["██╗ ██╗", "██║ ██║", "███████║", "╚════██║", " ██║", " ╚═╝"],
"5": ["███████╗", "██╔════╝", "███████╗", "╚════██║", "███████║", "╚══════╝"],
"6": [" ██████╗", "██╔════╝", "██████╗ ", "██╔══██╗", "╚█████╔╝", " ╚════╝ "],
"7": ["███████╗", "╚════██║", " ██╔╝", " ██╔╝ ", " ██║ ", " ╚═╝ "],
"8": [" █████╗ ", "██╔══██╗", "╚█████╔╝", "██╔══██╗", "╚█████╔╝", " ╚════╝ "],
"9": [" █████╗ ", "██╔══██╗", "╚██████║", " ╚═══██║", " █████╔╝", " ╚════╝ "],
"-": [" ", " ", "██████╗ ", "╚═════╝ ", " ", " "],
" ": [" ", " ", " ", " ", " ", " "],
".": [" ", " ", " ", " ", "██╗", "╚═╝"],
"!": ["██╗", "██║", "██║", "╚═╝", "██╗", "╚═╝"],
}
# ---------------------------------------------------------------------------
# Thin font — light single-stroke. Height: 3 lines.
# ---------------------------------------------------------------------------
_THIN: dict[str, list[str]] = {
"A": ["┌─┐", "├─┤", "┘ └"],
"B": ["┬─┐", "├─┤", "┴─┘"],
"C": ["┌─ ", "", "└─ "],
"D": ["┬─┐", "│ │", "┴─┘"],
"E": ["┬──", "├─ ", "┴──"],
"F": ["┬──", "├─ ", ""],
"G": ["┌─ ", "│ ┐", "└─┘"],
"H": ["┐ ┌", "├─┤", "┘ └"],
"I": ["", "", ""],
"J": ["", "", "└─┘"],
"K": ["┐ ┌", "├┬┘", "┘└ "],
"L": ["", "", "└──"],
"M": ["┌┬┐", "│││", "┘ └"],
"N": ["┌┐ ", "│└┐", "┘ └"],
"O": ["┌─┐", "│ │", "└─┘"],
"P": ["┌─┐", "├─┘", ""],
"Q": ["┌─┐", "│ │", "└─┤"],
"R": ["┌─┐", "├─┤", "┘ └"],
"S": ["┌─ ", "└─┐", " ─┘"],
"T": ["┬─┬", "", ""],
"U": ["┐ ┌", "│ │", "└─┘"],
"V": ["┐ ┌", "│ │", "└┬┘"],
"W": ["┐ ┌", "│││", "└┴┘"],
"X": ["", " ", ""],
"Y": ["┐ ┌", "└┬┘", ""],
"Z": ["──┐", " ", "└──"],
"0": ["┌─┐", "│ │", "└─┘"],
"1": ["", "", ""],
"2": ["─┐", "┌┘", "└─"],
"3": ["─┐", "", "─┘"],
"4": ["┐ ┐", "└─┤", ""],
"5": ["┌─", "└┐", "─┘"],
"6": ["┌─", "├┐", "└┘"],
"7": ["──┐", "", ""],
"8": ["┌┐", "├┤", "└┘"],
"9": ["┌┐", "└┤", "─┘"],
"-": [" ", "── ", " "],
" ": [" ", " ", " "],
".": [" ", " ", "·"],
"!": ["", " ", "·"],
}
# ---------------------------------------------------------------------------
# Pixel font — retro bitmap. Height: 3 lines.
# ---------------------------------------------------------------------------
_PIXEL: dict[str, list[str]] = {
"A": ["▀▀▀▄", "█▀▀█", "▀ ▀"],
"B": ["▀▀▀▄", "█▀▀▄", "▀▀▀ "],
"C": ["▄▀▀▀", "", "▀▀▀ "],
"D": ["▀▀▀▄", "█ █", "▀▀▀ "],
"E": ["▀▀▀ ", "█▀ ", "▀▀▀ "],
"F": ["▀▀▀ ", "█▀ ", ""],
"G": ["▄▀▀ ", "█ ▀█", "▀▀▀ "],
"H": ["▀ ▀ ", "█▀▀█", "▀ ▀"],
"I": ["", "", ""],
"J": ["", "", "▀▀ "],
"K": ["▀ ▄ ", "█▀▄ ", "▀ ▀"],
"L": ["", "", "▀▀▀ "],
"M": ["▀▄▄▀", "█▀▀█", "▀ ▀"],
"N": ["▀▄ ▀", "█ ▀█", "▀ ▀"],
"O": ["▄▀▀▄", "█ █", "▀▀▀ "],
"P": ["▀▀▀▄", "█▀▀ ", ""],
"Q": ["▄▀▀▄", "█ █", "▀▀▀▄"],
"R": ["▀▀▀▄", "█▀▀▄", "▀ ▀"],
"S": ["▄▀▀ ", "▀▀▀▄", " ▀▀ "],
"T": ["▀▀▀▀", "", ""],
"U": ["▀ ▀", "█ █", "▀▀▀ "],
"V": ["▀ ▀", "█ █", " ▀▀ "],
"W": ["▀ ▀", "█▄▄█", "▀▀▀▀"],
"X": ["▀ ▀", " ▀▀ ", "▀ ▀"],
"Y": ["▀ ▀", " ▀▀ ", ""],
"Z": ["▀▀▀▀", " ▄▀ ", "▀▀▀▀"],
"0": ["▄▀▀▄", "█ █", "▀▀▀ "],
"1": ["", "", ""],
"2": ["▀▀▄", " ▄▀", "▀▀▀"],
"3": ["▀▀▄", " ▀▄", "▀▀ "],
"4": ["▀ ▀", "▀▀█", ""],
"5": ["▀▀▀", "▀▀▄", "▀▀ "],
"6": ["▄▀▀", "█▀▄", "▀▀ "],
"7": ["▀▀▀", "", ""],
"8": ["▄▀▄", "█▀█", "▀▀ "],
"9": ["▄▀▄", "▀▀█", "▀▀ "],
"-": [" ", " ▀▀ ", " "],
" ": [" ", " ", " "],
".": [" ", " ", ""],
"!": ["", " ", ""],
}
# ---------------------------------------------------------------------------
# Font registry
# ---------------------------------------------------------------------------
FONTS: dict[str, dict[str, list[str]]] = {
"block": _BLOCK,
"thin": _THIN,
"pixel": _PIXEL,
}
FONT_HEIGHTS: dict[str, int] = {
"block": 6,
"thin": 3,
"pixel": 3,
}
def render_big_text(text: str, font_name: str = "block", kerning: int = 1) -> list[str]:
"""Render text as multi-line ASCII art using the specified font.
Args:
text: the string to render (uppercase recommended for block/pixel)
font_name: "block", "thin", or "pixel"
kerning: space between characters (0=tight, 1=normal, 2=wide)
Returns:
List of strings, one per line of the rendered text.
"""
font = FONTS.get(font_name, _BLOCK)
height = FONT_HEIGHTS.get(font_name, 6)
text_upper = text.upper()
# Build each line by concatenating character columns
lines: list[str] = ["" for _ in range(height)]
spacer = " " * kerning
for i, ch in enumerate(text_upper):
glyph = font.get(ch, font.get("?", [" " * 3] * height))
for row in range(height):
if row < len(glyph):
lines[row] += glyph[row]
else:
lines[row] += " " * (len(glyph[0]) if glyph else 3)
if i < len(text_upper) - 1:
lines[row] += spacer
return lines
def get_text_width(text: str, font_name: str = "block", kerning: int = 1) -> int:
"""Calculate the rendered width of big text without rendering it."""
font = FONTS.get(font_name, _BLOCK)
text_upper = text.upper()
width = 0
for i, ch in enumerate(text_upper):
glyph = font.get(ch, font.get("?", [" " * 3]))
width += len(glyph[0]) if glyph else 3
if i < len(text_upper) - 1:
width += kerning
return width

View File

@@ -361,6 +361,13 @@ class StateDecl(IRNode):
# Components (Phase 8) # Components (Phase 8)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass
class BigTitle(IRNode):
"""Large multi-line ASCII art text."""
text: str = ""
font: str = "block" # block, thin, pixel
@dataclass @dataclass
class ComponentDef(IRNode): class ComponentDef(IRNode):
"""Component definition: component name(arg1, arg2).""" """Component definition: component name(arg1, arg2)."""

View File

@@ -20,6 +20,7 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle,
ComponentDef, ComponentUse, ComponentDef, ComponentUse,
) )
from uframe.themes import BUILTIN_THEMES from uframe.themes import BUILTIN_THEMES
@@ -115,6 +116,16 @@ register_keyword("table", node_class=Table, section="Data",
register_keyword("columns", section="", register_keyword("columns", section="",
detail="", snippet="") # child of table, not shown in slash commands detail="", snippet="") # child of table, not shown in slash commands
# ---------------------------------------------------------------------------
# Big Text
# ---------------------------------------------------------------------------
register_keyword("bigtitle", node_class=BigTitle, section="Content",
detail='bigtitle "TEXT" block',
snippet='bigtitle "${text}" ${font:block}',
highlight_values=["block", "thin", "pixel"],
is_leaf=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Style # Style
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -8,6 +8,7 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle,
) )
@@ -135,7 +136,8 @@ def layout(node: IRNode, x: int, y: int, w: int, h: int) -> int:
elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem, elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem,
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Field, Password, Radio, Checkbox, FormButton)): Field, Password, Radio, Checkbox, FormButton,
BigTitle)):
node.rect.h = node.pref_height node.rect.h = node.pref_height
return node.pref_height return node.pref_height

View File

@@ -8,7 +8,9 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle,
) )
from uframe.fonts import FONT_HEIGHTS, get_text_width
def _text_height(text: str, width: int) -> int: def _text_height(text: str, width: int) -> int:
@@ -243,6 +245,22 @@ def measure(node: IRNode, available_width: int) -> None:
node.pref_height = 1 node.pref_height = 1
node.min_height = 1 node.min_height = 1
elif isinstance(node, BigTitle):
# Try the requested font, fall back to smaller if too wide
tw = get_text_width(node.text, node.font)
if tw <= available_width:
h = FONT_HEIGHTS.get(node.font, 6)
elif get_text_width(node.text, "pixel") <= available_width:
h = FONT_HEIGHTS.get("pixel", 3)
elif get_text_width(node.text, "thin") <= available_width:
h = FONT_HEIGHTS.get("thin", 3)
else:
h = 1 # fallback to single styled line
node.pref_width = available_width
node.min_width = 1
node.pref_height = h
node.min_height = 1
elif isinstance(node, (Let, Source, CacheControl, StateDecl)): elif isinstance(node, (Let, Source, CacheControl, StateDecl)):
# Zero-height metadata nodes — no visual output # Zero-height metadata nodes — no visual output
node.pref_width = 0 node.pref_width = 0

View File

@@ -19,9 +19,11 @@ from uframe.ir import (
Heading, Text, Label, Divider, Link, ListNode, ListItem, Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
BigTitle,
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
) )
from uframe.themes import ThemeDef, THEME_DEFAULT from uframe.themes import ThemeDef, THEME_DEFAULT
from uframe.fonts import render_big_text, get_text_width, FONT_HEIGHTS
def _align_text(text: str, width: int, align: Align) -> str: def _align_text(text: str, width: int, align: Align) -> str:
@@ -220,6 +222,38 @@ def paint(node: IRNode, grid: CharGrid, theme: ThemeDef | None = None) -> None:
grid.put(x, y, char, style=CellStyle(fg=color)) grid.put(x, y, char, style=CellStyle(fg=color))
grid.put_text(x + 2, y, node.label) grid.put_text(x + 2, y, node.label)
elif isinstance(node, BigTitle):
style = CellStyle(fg=node.style.fg or th.palette.accent, bold=True)
# Determine which font fits
font = node.font
tw = get_text_width(node.text, font)
if tw > w:
# Try fallback cascade
for fallback in ["pixel", "thin"]:
if get_text_width(node.text, fallback) <= w:
font = fallback
tw = get_text_width(node.text, fallback)
break
else:
# Final fallback: styled single line
styled = f"═══ {node.text.upper()} ═══"
grid.put_text(x, y, styled[:w], style=style)
return
lines = render_big_text(node.text, font)
# Center if align is set
offset = 0
if node.style.align == Align.CENTER:
offset = max(0, (w - tw) // 2)
elif node.style.align == Align.RIGHT:
offset = max(0, w - tw)
for row_i, line in enumerate(lines):
if y + row_i < grid.height:
grid.put_text(x + offset, y + row_i, line, style=style)
elif isinstance(node, Table): elif isinstance(node, Table):
_paint_table(node, grid, x, y, w) _paint_table(node, grid, x, y, w)

View File

@@ -19,6 +19,7 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, TextSpan, Gauge, Sparkline, Status, Table, TextSpan,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle,
ComponentDef, ComponentUse, ComponentDef, ComponentUse,
SourceType, SourceType,
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
@@ -426,6 +427,11 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
content = " ".join([keyword] + args) content = " ".join([keyword] + args)
return Text(content=content, source_line=line_num) return Text(content=content, source_line=line_num)
elif keyword == "bigtitle":
text = args[0] if args else ""
font = args[1] if len(args) > 1 else "block"
return BigTitle(text=text, font=font, source_line=line_num)
elif keyword == "theme": elif keyword == "theme":
# theme "name" — sets the page theme (handled as _StyleDirective on Page) # theme "name" — sets the page theme (handled as _StyleDirective on Page)
theme_name = args[0] if args else "default" theme_name = args[0] if args else "default"

View File

@@ -280,6 +280,35 @@ export const EXAMPLES: Example[] = [
divider heavy divider heavy
link "Home" "/page/index.mu"`, link "Home" "/page/index.mu"`,
}, },
{
name: "Big Title",
description: "Large ASCII art text in block, thin, and pixel fonts",
source: `page "Banner" 64
bigtitle "RELAY" block
align center
color 0cf
spacer
text "@center{Alpha-7 — Reticulum Network Node}"
spacer
bigtitle "STATUS" thin
align center
spacer
gauge "CPU" 62 100 28 warn=75 crit=90
gauge "MEM" 84 100 28 warn=80 crit=95
divider heavy
bigtitle "OK" pixel
align center
color 0f0`,
},
{ {
name: "Themed Page", name: "Themed Page",
description: "Same layout with different visual themes (try: nouveau, gothic, bamboo, circuit, brutalist)", description: "Same layout with different visual themes (try: nouveau, gothic, bamboo, circuit, brutalist)",