From a95594f446ac391d17de5f95cdb9ec6e70fdfccf Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 1 Apr 2026 16:04:06 +0200 Subject: [PATCH] feat: navigation --- backend/uframe/ir.py | 30 ++++ backend/uframe/keywords.py | 12 +- backend/uframe/layout.py | 4 +- backend/uframe/measure.py | 29 +++- backend/uframe/paint.py | 155 ++++++++++++++++++++- backend/uframe/parser.py | 55 +++++++- frontend/src/components/editor/examples.ts | 33 +++++ 7 files changed, 312 insertions(+), 6 deletions(-) diff --git a/backend/uframe/ir.py b/backend/uframe/ir.py index 90309e8..b2509d7 100644 --- a/backend/uframe/ir.py +++ b/backend/uframe/ir.py @@ -361,6 +361,36 @@ class StateDecl(IRNode): # 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 class ImageNode(IRNode): """Image converted to character art.""" diff --git a/backend/uframe/keywords.py b/backend/uframe/keywords.py index 902c38a..b7bbcec 100644 --- a/backend/uframe/keywords.py +++ b/backend/uframe/keywords.py @@ -20,7 +20,7 @@ from uframe.ir import ( Gauge, Sparkline, Status, Table, Form, Field, Password, Radio, Checkbox, FormButton, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, - BigTitle, ImageNode, + BigTitle, ImageNode, HNav, VNav, ComponentDef, ComponentUse, ) from uframe.themes import BUILTIN_THEMES @@ -120,6 +120,16 @@ register_keyword("columns", section="", # 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", detail='image "path.png" braille 30', snippet='image "${path}" ${mode:braille} ${width:30}', diff --git a/backend/uframe/layout.py b/backend/uframe/layout.py index bc3bf59..1b05db2 100644 --- a/backend/uframe/layout.py +++ b/backend/uframe/layout.py @@ -8,7 +8,7 @@ from uframe.ir import ( Gauge, Sparkline, Status, Table, Form, Field, Password, Radio, Checkbox, FormButton, 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, Gauge, Sparkline, Status, Table, Field, Password, Radio, Checkbox, FormButton, - BigTitle, ImageNode)): + BigTitle, ImageNode, HNav, VNav)): node.rect.h = node.pref_height return node.pref_height diff --git a/backend/uframe/measure.py b/backend/uframe/measure.py index 8052a22..2f0a8f6 100644 --- a/backend/uframe/measure.py +++ b/backend/uframe/measure.py @@ -8,7 +8,7 @@ from uframe.ir import ( Gauge, Sparkline, Status, Table, Form, Field, Password, Radio, Checkbox, FormButton, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, - BigTitle, ImageNode, + BigTitle, ImageNode, HNav, VNav, ) from uframe.fonts import FONT_HEIGHTS, get_text_width from uframe.imaging import get_image_height @@ -246,6 +246,33 @@ def measure(node: IRNode, available_width: int) -> None: node.pref_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): h = get_image_height(node.path, node.mode, node.img_width) if node.caption: diff --git a/backend/uframe/paint.py b/backend/uframe/paint.py index 19780d8..62bd70f 100644 --- a/backend/uframe/paint.py +++ b/backend/uframe/paint.py @@ -19,7 +19,7 @@ from uframe.ir import ( Heading, Text, Label, Divider, Link, ListNode, ListItem, Gauge, Sparkline, Status, Table, Form, Field, Password, Radio, Checkbox, FormButton, - BigTitle, ImageNode, + BigTitle, ImageNode, HNav, VNav, HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight, ) 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_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): style = CellStyle(fg=node.style.fg or th.palette.accent) try: @@ -346,6 +352,153 @@ def paint(node: IRNode, grid: CharGrid, theme: ThemeDef | None = None) -> None: 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: """Paint a box-drawn table with header and data rows.""" if not node.columns: diff --git a/backend/uframe/parser.py b/backend/uframe/parser.py index cef1ab1..837e24a 100644 --- a/backend/uframe/parser.py +++ b/backend/uframe/parser.py @@ -19,7 +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, ImageNode, + BigTitle, ImageNode, HNav, VNav, NavItem, ComponentDef, ComponentUse, SourceType, 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) 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 "" return ListItem(content=content, source_line=line_num) + elif keyword == "separator": + return _NavItemNode("separator", "", "", False, line_num) + # Style modifiers (applied to parent) elif keyword == "align": 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) 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": path = args[0] if args else "" 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) +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): """Temporary node — sets theme_name on the Page during tree building.""" 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 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 if isinstance(node, _UseDirective): lib_comps = _load_library(node.lib_path) diff --git a/frontend/src/components/editor/examples.ts b/frontend/src/components/editor/examples.ts index 523f2e9..37d8f20 100644 --- a/frontend/src/components/editor/examples.ts +++ b/frontend/src/components/editor/examples.ts @@ -280,6 +280,39 @@ export const EXAMPLES: Example[] = [ divider heavy 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", description: "Convert images to braille/block/ascii character art",