feat: big title
This commit is contained in:
209
backend/uframe/fonts.py
Normal file
209
backend/uframe/fonts.py
Normal 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
|
||||
@@ -361,6 +361,13 @@ class StateDecl(IRNode):
|
||||
# Components (Phase 8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class BigTitle(IRNode):
|
||||
"""Large multi-line ASCII art text."""
|
||||
text: str = ""
|
||||
font: str = "block" # block, thin, pixel
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentDef(IRNode):
|
||||
"""Component definition: component name(arg1, arg2)."""
|
||||
|
||||
@@ -20,6 +20,7 @@ from uframe.ir import (
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle,
|
||||
ComponentDef, ComponentUse,
|
||||
)
|
||||
from uframe.themes import BUILTIN_THEMES
|
||||
@@ -115,6 +116,16 @@ register_keyword("table", node_class=Table, section="Data",
|
||||
register_keyword("columns", section="",
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,6 +8,7 @@ from uframe.ir import (
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
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,
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Field, Password, Radio, Checkbox, FormButton)):
|
||||
Field, Password, Radio, Checkbox, FormButton,
|
||||
BigTitle)):
|
||||
node.rect.h = node.pref_height
|
||||
return node.pref_height
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ from uframe.ir import (
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
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:
|
||||
@@ -243,6 +245,22 @@ def measure(node: IRNode, available_width: int) -> None:
|
||||
node.pref_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)):
|
||||
# Zero-height metadata nodes — no visual output
|
||||
node.pref_width = 0
|
||||
|
||||
@@ -19,9 +19,11 @@ from uframe.ir import (
|
||||
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
BigTitle,
|
||||
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
|
||||
)
|
||||
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:
|
||||
@@ -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_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):
|
||||
_paint_table(node, grid, x, y, w)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from uframe.ir import (
|
||||
Gauge, Sparkline, Status, Table, TextSpan,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle,
|
||||
ComponentDef, ComponentUse,
|
||||
SourceType,
|
||||
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)
|
||||
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":
|
||||
# theme "name" — sets the page theme (handled as _StyleDirective on Page)
|
||||
theme_name = args[0] if args else "default"
|
||||
|
||||
@@ -280,6 +280,35 @@ export const EXAMPLES: Example[] = [
|
||||
divider heavy
|
||||
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",
|
||||
description: "Same layout with different visual themes (try: nouveau, gothic, bamboo, circuit, brutalist)",
|
||||
|
||||
Reference in New Issue
Block a user