From 175de893aa9170eab715ea76c3b22d2f3b8988a5 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 1 Apr 2026 12:16:16 +0200 Subject: [PATCH] feat: big title --- backend/uframe/fonts.py | 209 +++++++++++++++++++++ backend/uframe/ir.py | 7 + backend/uframe/keywords.py | 11 ++ backend/uframe/layout.py | 4 +- backend/uframe/measure.py | 18 ++ backend/uframe/paint.py | 34 ++++ backend/uframe/parser.py | 6 + frontend/src/components/editor/examples.ts | 29 +++ 8 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 backend/uframe/fonts.py diff --git a/backend/uframe/fonts.py b/backend/uframe/fonts.py new file mode 100644 index 0000000..4471632 --- /dev/null +++ b/backend/uframe/fonts.py @@ -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 diff --git a/backend/uframe/ir.py b/backend/uframe/ir.py index c0ff10c..6defb70 100644 --- a/backend/uframe/ir.py +++ b/backend/uframe/ir.py @@ -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).""" diff --git a/backend/uframe/keywords.py b/backend/uframe/keywords.py index 71dc0ce..d719db1 100644 --- a/backend/uframe/keywords.py +++ b/backend/uframe/keywords.py @@ -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 # --------------------------------------------------------------------------- diff --git a/backend/uframe/layout.py b/backend/uframe/layout.py index f575246..697824e 100644 --- a/backend/uframe/layout.py +++ b/backend/uframe/layout.py @@ -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 diff --git a/backend/uframe/measure.py b/backend/uframe/measure.py index 1701d1c..4bc125e 100644 --- a/backend/uframe/measure.py +++ b/backend/uframe/measure.py @@ -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 diff --git a/backend/uframe/paint.py b/backend/uframe/paint.py index 6c04914..38e2fa8 100644 --- a/backend/uframe/paint.py +++ b/backend/uframe/paint.py @@ -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) diff --git a/backend/uframe/parser.py b/backend/uframe/parser.py index b975c9a..330d7ef 100644 --- a/backend/uframe/parser.py +++ b/backend/uframe/parser.py @@ -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" diff --git a/frontend/src/components/editor/examples.ts b/frontend/src/components/editor/examples.ts index a9079ad..38a3fa0 100644 --- a/frontend/src/components/editor/examples.ts +++ b/frontend/src/components/editor/examples.ts @@ -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)",