feat: navigation

This commit is contained in:
2026-04-01 16:04:06 +02:00
parent a0a5f18128
commit a95594f446
7 changed files with 312 additions and 6 deletions

View File

@@ -361,6 +361,36 @@ class StateDecl(IRNode):
# Components (Phase 8) # Components (Phase 8)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Navigation nodes
# ---------------------------------------------------------------------------
@dataclass
class NavItem:
"""A single item in a navigation bar."""
kind: str = "item" # "item", "separator", "heading"
label: str = ""
dest: str = ""
active: bool = False
children: list["NavItem"] = field(default_factory=list)
@dataclass
class HNav(IRNode):
"""Horizontal navigation bar."""
nav_style: str = "bar" # bar, tabs, pills, breadcrumb, underline
items: list[NavItem] = field(default_factory=list)
compact: bool = False
@dataclass
class VNav(IRNode):
"""Vertical navigation panel."""
nav_style: str = "list" # list, boxed, tree, sidebar, minimal
nav_width: int = 0 # 0 = auto-fit
items: list[NavItem] = field(default_factory=list)
@dataclass @dataclass
class ImageNode(IRNode): class ImageNode(IRNode):
"""Image converted to character art.""" """Image converted to character art."""

View File

@@ -20,7 +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, ImageNode, BigTitle, ImageNode, HNav, VNav,
ComponentDef, ComponentUse, ComponentDef, ComponentUse,
) )
from uframe.themes import BUILTIN_THEMES from uframe.themes import BUILTIN_THEMES
@@ -120,6 +120,16 @@ register_keyword("columns", section="",
# Big Text # Big Text
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
register_keyword("hnav", node_class=HNav, section="Layout",
detail='hnav bar', snippet='hnav ${style:bar}\n item "${label}" "${dest}" active',
highlight_values=["bar", "tabs", "pills", "breadcrumb", "underline"],
is_container=True)
register_keyword("vnav", node_class=VNav, section="Layout",
detail='vnav list', snippet='vnav ${style:list}\n item "${label}" "${dest}"',
highlight_values=["list", "boxed", "tree", "sidebar", "minimal"],
is_container=True)
register_keyword("image", node_class=ImageNode, section="Content", register_keyword("image", node_class=ImageNode, section="Content",
detail='image "path.png" braille 30', detail='image "path.png" braille 30',
snippet='image "${path}" ${mode:braille} ${width:30}', snippet='image "${path}" ${mode:braille} ${width:30}',

View File

@@ -8,7 +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, ImageNode, BigTitle, ImageNode, HNav, VNav,
) )
@@ -137,7 +137,7 @@ 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, ImageNode)): BigTitle, ImageNode, HNav, VNav)):
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,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, ImageNode, BigTitle, ImageNode, HNav, VNav,
) )
from uframe.fonts import FONT_HEIGHTS, get_text_width from uframe.fonts import FONT_HEIGHTS, get_text_width
from uframe.imaging import get_image_height from uframe.imaging import get_image_height
@@ -246,6 +246,33 @@ 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, HNav):
# Height: 3 for bar/tabs/pills (top border + items + bottom border), 2 for underline/breadcrumb
if node.nav_style in ("underline",):
node.pref_height = 2
elif node.nav_style in ("breadcrumb",):
node.pref_height = 1
else:
node.pref_height = 3
node.pref_width = available_width
node.min_width = 10
node.min_height = 1
elif isinstance(node, VNav):
count = sum(1 for it in node.items if it.kind in ("item", "heading"))
sep_count = sum(1 for it in node.items if it.kind == "separator")
h = count + sep_count
if node.nav_style == "boxed":
h += 2 # top + bottom border
node.pref_height = max(h, 1)
if node.nav_width > 0:
node.pref_width = node.nav_width
else:
max_label = max((len(it.label) for it in node.items if it.label), default=8)
node.pref_width = max_label + 6 # marker + padding
node.min_width = 8
node.min_height = 1
elif isinstance(node, ImageNode): elif isinstance(node, ImageNode):
h = get_image_height(node.path, node.mode, node.img_width) h = get_image_height(node.path, node.mode, node.img_width)
if node.caption: if node.caption:

View File

@@ -19,7 +19,7 @@ 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, ImageNode, BigTitle, ImageNode, HNav, VNav,
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
) )
from uframe.themes import ThemeDef, THEME_DEFAULT from uframe.themes import ThemeDef, THEME_DEFAULT
@@ -223,6 +223,12 @@ 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, HNav):
_paint_hnav(node, grid, x, y, w, th)
elif isinstance(node, VNav):
_paint_vnav(node, grid, x, y, w, th)
elif isinstance(node, ImageNode): elif isinstance(node, ImageNode):
style = CellStyle(fg=node.style.fg or th.palette.accent) style = CellStyle(fg=node.style.fg or th.palette.accent)
try: try:
@@ -346,6 +352,153 @@ def paint(node: IRNode, grid: CharGrid, theme: ThemeDef | None = None) -> None:
paint(child, grid, th) paint(child, grid, th)
def _paint_hnav(node: HNav, grid: CharGrid, x: int, y: int, w: int, th: ThemeDef) -> None:
"""Paint a horizontal navigation bar."""
active_style = CellStyle(bold=True, fg=th.palette.accent)
link_style = CellStyle(fg=th.palette.info)
sep_style = CellStyle(fg=th.palette.muted)
border_style = CellStyle()
items = [it for it in node.items if it.kind in ("item", "separator")]
marker = ""
if node.nav_style in ("bar", "tabs", "pills"):
# Bordered bar
bc = th.border_dict("light")
grid.draw_border(x, y, w, 3, border_chars=bc,
title_caps=(th.title_caps.left, th.title_caps.right))
col = x + 2
for it in items:
if it.kind == "separator":
grid.put(col, y + 1, "", style=sep_style)
col += 2
continue
if it.active:
grid.put_text(col, y + 1, marker, style=active_style)
col += len(marker)
grid.put_text(col, y + 1, it.label, style=active_style)
else:
grid.put_text(col, y + 1, it.label, style=link_style, link=it.dest)
col += len(it.label) + 2
if col < x + w - 2:
grid.put(col, y + 1, "", style=sep_style)
col += 2
elif node.nav_style == "breadcrumb":
col = x + 2
sep = ""
for i, it in enumerate(items):
if it.kind == "separator":
continue
if i > 0:
grid.put_text(col, y, sep, style=sep_style)
col += len(sep)
if it.active:
grid.put_text(col, y, it.label, style=active_style)
else:
grid.put_text(col, y, it.label, style=link_style, link=it.dest)
col += len(it.label)
elif node.nav_style == "underline":
col = x + 2
active_start = 0
active_len = 0
for i, it in enumerate(items):
if it.kind == "separator":
continue
if it.active:
active_start = col
active_len = len(it.label)
grid.put_text(col, y, it.label, style=active_style)
else:
grid.put_text(col, y, it.label, style=link_style, link=it.dest)
col += len(it.label) + 5
# Underline beneath active
if active_len > 0:
for c in range(active_start, active_start + active_len):
grid.put(c, y + 1, "", style=CellStyle(fg=th.palette.accent))
def _paint_vnav(node: VNav, grid: CharGrid, x: int, y: int, w: int, th: ThemeDef) -> None:
"""Paint a vertical navigation panel."""
active_style = CellStyle(bold=True, fg=th.palette.accent)
link_style = CellStyle(fg=th.palette.info)
heading_style = CellStyle(bold=True, fg=th.palette.muted)
sep_style = CellStyle(fg=th.palette.muted)
marker = ""
items = node.items
row_y = y
if node.nav_style == "boxed":
# Draw a box and render items inside
bc = th.border_dict("light")
h = node.rect.h
grid.draw_border(x, y, w, h, border_chars=bc,
title_caps=(th.title_caps.left, th.title_caps.right))
row_y = y + 1
for it in items:
if it.kind == "separator":
# Draw internal separator
for c in range(x + 1, x + w - 1):
grid.put(c, row_y, bc.get("h", ""), style=sep_style,
is_border=True)
grid.put(x, row_y, "", style=sep_style, is_border=True)
grid.put(x + w - 1, row_y, "", style=sep_style, is_border=True)
row_y += 1
elif it.kind == "heading":
grid.put_text(x + 2, row_y, it.label.upper(), style=heading_style)
row_y += 1
elif it.kind == "item":
if it.active:
grid.put_text(x + 2, row_y, marker, style=active_style)
grid.put_text(x + 2 + len(marker), row_y, it.label, style=active_style)
else:
grid.put_text(x + 4, row_y, it.label, style=link_style, link=it.dest)
row_y += 1
elif node.nav_style == "tree":
for idx, it in enumerate(items):
if it.kind == "separator":
for c in range(x, x + w):
grid.put(c, row_y, "", style=sep_style)
row_y += 1
elif it.kind == "heading":
grid.put_text(x, row_y, it.label, style=heading_style)
row_y += 1
elif it.kind == "item":
# Determine connector
remaining = [i for i in items[idx+1:] if i.kind == "item"]
connector = "└── " if not remaining else "├── "
grid.put_text(x, row_y, connector, style=sep_style)
if it.active:
grid.put_text(x + len(connector), row_y, it.label, style=active_style)
grid.put_text(x + len(connector) + len(it.label) + 2, row_y, "",
style=CellStyle(fg=th.palette.accent))
else:
grid.put_text(x + len(connector), row_y, it.label,
style=link_style, link=it.dest)
row_y += 1
else:
# list, sidebar, minimal
for it in items:
if it.kind == "separator":
for c in range(x, min(x + w, x + 16)):
grid.put(c, row_y, "", style=sep_style)
row_y += 1
elif it.kind == "heading":
grid.put_text(x, row_y, it.label.upper(), style=heading_style)
row_y += 1
elif it.kind == "item":
if it.active:
grid.put_text(x, row_y, marker, style=active_style)
grid.put_text(x + len(marker), row_y, it.label, style=active_style)
else:
grid.put_text(x + 2, row_y, it.label, style=link_style, link=it.dest)
row_y += 1
def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None: def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
"""Paint a box-drawn table with header and data rows.""" """Paint a box-drawn table with header and data rows."""
if not node.columns: if not node.columns:

View File

@@ -19,7 +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, ImageNode, BigTitle, ImageNode, HNav, VNav, NavItem,
ComponentDef, ComponentUse, ComponentDef, ComponentUse,
SourceType, SourceType,
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
@@ -222,9 +222,19 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
return ListNode(list_style=style, source_line=line_num) return ListNode(list_style=style, source_line=line_num)
elif keyword == "item": elif keyword == "item":
# Could be a list item or a nav item — disambiguated by parent in tree builder
if len(args) >= 2 and ("/" in args[1] or ":" in args[1]):
# Nav item: item "Label" "/dest.mu" [active]
label = args[0]
dest = args[1]
active = "active" in args[2:] if len(args) > 2 else False
return _NavItemNode("item", label, dest, active, line_num)
content = args[0] if args else "" content = args[0] if args else ""
return ListItem(content=content, source_line=line_num) return ListItem(content=content, source_line=line_num)
elif keyword == "separator":
return _NavItemNode("separator", "", "", False, line_num)
# Style modifiers (applied to parent) # Style modifiers (applied to parent)
elif keyword == "align": elif keyword == "align":
val = args[0].lower() if args else "left" val = args[0].lower() if args else "left"
@@ -427,6 +437,15 @@ 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 == "hnav":
nav_style = args[0] if args else "bar"
return HNav(nav_style=nav_style, source_line=line_num)
elif keyword == "vnav":
nav_style = args[0] if args else "list"
nav_width = int(args[1]) if len(args) > 1 and args[1].isdigit() else 0
return VNav(nav_style=nav_style, nav_width=nav_width, source_line=line_num)
elif keyword == "image": elif keyword == "image":
path = args[0] if args else "" path = args[0] if args else ""
mode = args[1] if len(args) > 1 else "braille" mode = args[1] if len(args) > 1 else "braille"
@@ -477,6 +496,16 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
return ComponentUse(comp_name=keyword, args=args, source_line=line_num) return ComponentUse(comp_name=keyword, args=args, source_line=line_num)
class _NavItemNode(IRNode):
"""Temporary node — absorbed by parent HNav/VNav during tree building."""
def __init__(self, kind: str, label: str, dest: str, active: bool, line_num: int):
super().__init__(source_line=line_num)
self.kind = kind
self.label = label
self.dest = dest
self.active = active
class _ThemeDirective(IRNode): class _ThemeDirective(IRNode):
"""Temporary node — sets theme_name on the Page during tree building.""" """Temporary node — sets theme_name on the Page during tree building."""
def __init__(self, theme_name: str, line_num: int): def __init__(self, theme_name: str, line_num: int):
@@ -840,6 +869,30 @@ def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Pag
break break
continue continue
# Nav items — absorbed by parent HNav/VNav
if isinstance(node, _NavItemNode):
for si in range(len(stack) - 1, -1, -1):
parent = stack[si][1]
if isinstance(parent, (HNav, VNav)):
parent.items.append(NavItem(
kind=node.kind, label=node.label,
dest=node.dest, active=node.active))
break
continue
# Headings inside vnav become nav headings
if isinstance(node, Heading):
for si in range(len(stack) - 1, -1, -1):
if isinstance(stack[si][1], VNav):
stack[si][1].items.append(NavItem(
kind="heading", label=node.text))
break
else:
# Not inside a vnav — proceed as normal heading
pass
if any(isinstance(stack[si][1], VNav) for si in range(len(stack))):
continue
# Use directive — load standard library components # Use directive — load standard library components
if isinstance(node, _UseDirective): if isinstance(node, _UseDirective):
lib_comps = _load_library(node.lib_path) lib_comps = _load_library(node.lib_path)

View File

@@ -280,6 +280,39 @@ export const EXAMPLES: Example[] = [
divider heavy divider heavy
link "Home" "/page/index.mu"`, link "Home" "/page/index.mu"`,
}, },
{
name: "Navigation",
description: "Horizontal bar + vertical sidebar navigation",
source: `page "Dashboard" 64
hnav bar
item "Status" "/page/status.mu" active
item "Peers" "/page/peers.mu"
item "Files" "/page/files.mu"
item "Config" "/page/config.mu"
spacer
row 1
col 18
vnav boxed
heading "Network"
item "Overview" "/page/overview.mu" active
item "Peers" "/page/peers.mu"
item "Routes" "/page/routes.mu"
separator
heading "Tools"
item "Ping" "/page/ping.mu"
item "Trace" "/page/trace.mu"
col
heading 1 "Overview"
gauge "CPU" 62 100 28 warn=75 crit=90
gauge "MEM" 84 100 28 warn=80 crit=95
spacer
status "East Relay" online
status "South Bridge" online
status "Node Gamma" degraded`,
},
{ {
name: "Image Art", name: "Image Art",
description: "Convert images to braille/block/ascii character art", description: "Convert images to braille/block/ascii character art",