feat: preview
This commit is contained in:
@@ -1,148 +1,19 @@
|
||||
"""Border merging post-pass — fix junction characters where borders meet.
|
||||
"""Border merging post-pass (currently no-op).
|
||||
|
||||
Scans the CharGrid for adjacent border cells and replaces with the
|
||||
correct junction character (T-junctions, crosses, corners) from the
|
||||
Unicode box-drawing set.
|
||||
The draw_border and _paint_table functions produce correct border characters
|
||||
directly. The original merge pass caused garbled junctions when borders from
|
||||
different boxes were adjacent, so it was disabled.
|
||||
|
||||
If future features need cross-box junction merging (e.g. tables sharing
|
||||
edges with parent boxes), add targeted logic here using border_id from
|
||||
the grid cells to only merge within the same border group.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uframe.grid import CharGrid
|
||||
from uframe.ir import BorderWeight
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# For each border cell, check which directions have adjacent borders.
|
||||
# Direction flags:
|
||||
UP = 1
|
||||
DOWN = 2
|
||||
LEFT = 4
|
||||
RIGHT = 8
|
||||
|
||||
# Junction lookup: connections bitmask → character
|
||||
# Only light weight for now (most common case)
|
||||
_LIGHT_JUNCTIONS: dict[int, str] = {
|
||||
UP | DOWN: "│",
|
||||
LEFT | RIGHT: "─",
|
||||
DOWN | RIGHT: "┌",
|
||||
DOWN | LEFT: "┐",
|
||||
UP | RIGHT: "└",
|
||||
UP | LEFT: "┘",
|
||||
UP | DOWN | RIGHT: "├",
|
||||
UP | DOWN | LEFT: "┤",
|
||||
LEFT | RIGHT | DOWN: "┬",
|
||||
LEFT | RIGHT | UP: "┴",
|
||||
UP | DOWN | LEFT | RIGHT: "┼",
|
||||
RIGHT: "─",
|
||||
LEFT: "─",
|
||||
UP: "│",
|
||||
DOWN: "│",
|
||||
}
|
||||
|
||||
_HEAVY_JUNCTIONS: dict[int, str] = {
|
||||
UP | DOWN: "┃",
|
||||
LEFT | RIGHT: "━",
|
||||
DOWN | RIGHT: "┏",
|
||||
DOWN | LEFT: "┓",
|
||||
UP | RIGHT: "┗",
|
||||
UP | LEFT: "┛",
|
||||
UP | DOWN | RIGHT: "┣",
|
||||
UP | DOWN | LEFT: "┫",
|
||||
LEFT | RIGHT | DOWN: "┳",
|
||||
LEFT | RIGHT | UP: "┻",
|
||||
UP | DOWN | LEFT | RIGHT: "╋",
|
||||
RIGHT: "━",
|
||||
LEFT: "━",
|
||||
UP: "┃",
|
||||
DOWN: "┃",
|
||||
}
|
||||
|
||||
_DOUBLE_JUNCTIONS: dict[int, str] = {
|
||||
UP | DOWN: "║",
|
||||
LEFT | RIGHT: "═",
|
||||
DOWN | RIGHT: "╔",
|
||||
DOWN | LEFT: "╗",
|
||||
UP | RIGHT: "╚",
|
||||
UP | LEFT: "╝",
|
||||
UP | DOWN | RIGHT: "╠",
|
||||
UP | DOWN | LEFT: "╣",
|
||||
LEFT | RIGHT | DOWN: "╦",
|
||||
LEFT | RIGHT | UP: "╩",
|
||||
UP | DOWN | LEFT | RIGHT: "╬",
|
||||
RIGHT: "═",
|
||||
LEFT: "═",
|
||||
UP: "║",
|
||||
DOWN: "║",
|
||||
}
|
||||
|
||||
_JUNCTION_TABLES = {
|
||||
BorderWeight.LIGHT: _LIGHT_JUNCTIONS,
|
||||
BorderWeight.HEAVY: _HEAVY_JUNCTIONS,
|
||||
BorderWeight.DOUBLE: _DOUBLE_JUNCTIONS,
|
||||
BorderWeight.ROUNDED: _LIGHT_JUNCTIONS, # rounded uses light junctions
|
||||
}
|
||||
|
||||
# Weight priority for mixed-weight junctions
|
||||
_WEIGHT_PRIORITY = {
|
||||
BorderWeight.DOUBLE: 3,
|
||||
BorderWeight.HEAVY: 2,
|
||||
BorderWeight.LIGHT: 1,
|
||||
BorderWeight.ROUNDED: 0,
|
||||
}
|
||||
|
||||
|
||||
def merge_borders(grid: CharGrid) -> None:
|
||||
"""Scan the grid for adjacent border cells and fix junction characters.
|
||||
|
||||
This pass resolves cases where two boxes share an edge or corner,
|
||||
replacing the overlapping border characters with proper junctions.
|
||||
"""
|
||||
for row in range(grid.height):
|
||||
for col in range(grid.width):
|
||||
cell = grid.cells[row][col]
|
||||
if not cell.is_border:
|
||||
continue
|
||||
|
||||
# Detect connections in 4 directions
|
||||
connections = 0
|
||||
max_weight = cell.border_weight or BorderWeight.LIGHT
|
||||
|
||||
# Check each neighbor
|
||||
if row > 0 and grid.cells[row - 1][col].is_border:
|
||||
connections |= UP
|
||||
nw = grid.cells[row - 1][col].border_weight or BorderWeight.LIGHT
|
||||
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
||||
max_weight = nw
|
||||
|
||||
if row < grid.height - 1 and grid.cells[row + 1][col].is_border:
|
||||
connections |= DOWN
|
||||
nw = grid.cells[row + 1][col].border_weight or BorderWeight.LIGHT
|
||||
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
||||
max_weight = nw
|
||||
|
||||
if col > 0 and grid.cells[row][col - 1].is_border:
|
||||
connections |= LEFT
|
||||
nw = grid.cells[row][col - 1].border_weight or BorderWeight.LIGHT
|
||||
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
||||
max_weight = nw
|
||||
|
||||
if col < grid.width - 1 and grid.cells[row][col + 1].is_border:
|
||||
connections |= RIGHT
|
||||
nw = grid.cells[row][col + 1].border_weight or BorderWeight.LIGHT
|
||||
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
||||
max_weight = nw
|
||||
|
||||
# Skip rounded corners — they should preserve ╭╮╰╯
|
||||
if cell.border_weight == BorderWeight.ROUNDED and connections in (
|
||||
DOWN | RIGHT, DOWN | LEFT, UP | RIGHT, UP | LEFT
|
||||
):
|
||||
continue
|
||||
|
||||
# Look up the junction character
|
||||
if connections:
|
||||
table = _JUNCTION_TABLES.get(max_weight, _LIGHT_JUNCTIONS)
|
||||
junction = table.get(connections)
|
||||
if junction:
|
||||
cell.char = junction
|
||||
"""No-op — borders are correctly painted by draw_border and _paint_table."""
|
||||
pass
|
||||
|
||||
@@ -11,7 +11,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -12,16 +12,13 @@ state) and generates a self-contained Python script that:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from uframe.ir import (
|
||||
IRNode, Page, Box, Row, Col, Spacer, Pad,
|
||||
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
IRNode, Page, Box, Spacer,
|
||||
Heading, Text, Label, Divider, Link,
|
||||
Gauge, Status,
|
||||
Form, Field, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
SourceType, BorderWeight, HeadingLevel, DividerStyle, ListStyle,
|
||||
SourceType,
|
||||
)
|
||||
|
||||
|
||||
@@ -70,11 +67,14 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
|
||||
elif node.source_type == SourceType.JSON:
|
||||
lines.append(f"{ind}{var} = _read_json({node.command!r})")
|
||||
elif node.source_type == SourceType.PYTHON:
|
||||
lines.append(f"{ind}{var} = eval({node.command!r})")
|
||||
# Restricted eval — only datetime/secrets modules available
|
||||
lines.append(f"{ind}{var} = eval({node.command!r}, {{'datetime': datetime, 'secrets': secrets}})")
|
||||
elif node.source_type == SourceType.PARAM:
|
||||
lines.append(f"{ind}{var} = _get_param({node.command!r})")
|
||||
elif node.source_type == SourceType.RNS:
|
||||
lines.append(f"{ind}{var} = _shell('rnstatus {node.command}', timeout={node.timeout})")
|
||||
import shlex as _shlex
|
||||
safe_cmd = _shlex.quote(node.command)
|
||||
lines.append(f"{ind}{var} = _shell('rnstatus ' + shlex.quote({safe_cmd!r}), timeout={node.timeout})")
|
||||
|
||||
elif isinstance(node, CacheControl):
|
||||
lines.append(f"{ind}_cache_seconds = {node.seconds}")
|
||||
|
||||
@@ -54,15 +54,7 @@ _EMPTY_STYLE = CellStyle()
|
||||
|
||||
|
||||
def emit_micron(grid: CharGrid, page_title: str = "") -> str:
|
||||
"""Emit the CharGrid as Micron markup.
|
||||
|
||||
Args:
|
||||
grid: the rendered character grid
|
||||
page_title: optional page title for a leading >Title line
|
||||
|
||||
Returns:
|
||||
Micron source string
|
||||
"""
|
||||
"""Emit the CharGrid as Micron markup."""
|
||||
lines: list[str] = []
|
||||
|
||||
for row in range(grid.height):
|
||||
|
||||
@@ -39,17 +39,19 @@ class Cell:
|
||||
style: CellStyle = field(default_factory=CellStyle)
|
||||
is_border: bool = False # True for box-drawing characters (for merge pass)
|
||||
border_weight: BorderWeight | None = None
|
||||
border_id: int = 0 # Identifies which box this border belongs to
|
||||
link: str | None = None # Micron link destination
|
||||
|
||||
|
||||
class CharGrid:
|
||||
"""2D buffer of cells. Origin (0,0) is top-left."""
|
||||
|
||||
__slots__ = ("width", "height", "cells")
|
||||
__slots__ = ("width", "height", "cells", "_border_counter")
|
||||
|
||||
def __init__(self, width: int, height: int):
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._border_counter = 0
|
||||
self.cells: list[list[Cell]] = [
|
||||
[Cell() for _ in range(width)]
|
||||
for _ in range(height)
|
||||
@@ -62,6 +64,7 @@ class CharGrid:
|
||||
style: CellStyle | None = None,
|
||||
is_border: bool = False,
|
||||
border_weight: BorderWeight | None = None,
|
||||
border_id: int = 0,
|
||||
link: str | None = None) -> None:
|
||||
"""Write a single character to the grid."""
|
||||
if not self.in_bounds(x, y):
|
||||
@@ -72,6 +75,8 @@ class CharGrid:
|
||||
cell.style = style
|
||||
cell.is_border = is_border
|
||||
cell.border_weight = border_weight
|
||||
if border_id:
|
||||
cell.border_id = border_id
|
||||
if link is not None:
|
||||
cell.link = link
|
||||
|
||||
@@ -114,24 +119,27 @@ class CharGrid:
|
||||
if w < 2 or h < 2:
|
||||
return
|
||||
|
||||
self._border_counter += 1
|
||||
bid = self._border_counter
|
||||
|
||||
ch = BOX_CHARS[weight]
|
||||
border_style = CellStyle()
|
||||
|
||||
# Corners
|
||||
self.put(x, y, ch["tl"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(x + w - 1, y, ch["tr"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(x, y + h - 1, ch["bl"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(x + w - 1, y + h - 1, ch["br"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(x, y, ch["tl"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
self.put(x + w - 1, y, ch["tr"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
self.put(x, y + h - 1, ch["bl"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
self.put(x + w - 1, y + h - 1, ch["br"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
|
||||
# Top and bottom edges
|
||||
for col in range(x + 1, x + w - 1):
|
||||
self.put(col, y, ch["h"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(col, y + h - 1, ch["h"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(col, y, ch["h"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
self.put(col, y + h - 1, ch["h"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
|
||||
# Left and right edges
|
||||
for row in range(y + 1, y + h - 1):
|
||||
self.put(x, row, ch["v"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(x + w - 1, row, ch["v"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(x, row, ch["v"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
self.put(x + w - 1, row, ch["v"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
|
||||
# Title in top border
|
||||
if title and w > 4:
|
||||
|
||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,6 +7,7 @@ own structure (borders, etc.).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
from uframe.chars import (
|
||||
@@ -63,8 +64,12 @@ def paint(node: IRNode, grid: CharGrid) -> None:
|
||||
weight=node.weight,
|
||||
title=node.title,
|
||||
title_style=title_style)
|
||||
# Paint children inside the border
|
||||
# Propagate box alignment/color to children that don't have their own
|
||||
for child in node.children:
|
||||
if node.style.align != Align.LEFT and child.style.align == Align.LEFT:
|
||||
child.style.align = node.style.align
|
||||
if node.style.fg and not child.style.fg:
|
||||
child.style.fg = node.style.fg
|
||||
paint(child, grid)
|
||||
|
||||
elif isinstance(node, Row):
|
||||
@@ -155,9 +160,9 @@ def paint(node: IRNode, grid: CharGrid) -> None:
|
||||
|
||||
elif isinstance(node, ListItem):
|
||||
style = _style_from_node(node)
|
||||
# Parent determines bullet style — use a simple bullet for now
|
||||
bullet = "• "
|
||||
grid.put_text(x - 2, y, bullet, style=CellStyle(fg="888"))
|
||||
# Bullet to the left of the content (safe: put_text clips to bounds)
|
||||
bullet_x = max(0, x - 2)
|
||||
grid.put_text(bullet_x, y, "• ", style=CellStyle(fg="888"))
|
||||
# Wrap content
|
||||
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
|
||||
for i, line in enumerate(wrapped):
|
||||
@@ -357,9 +362,7 @@ def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
|
||||
|
||||
# Check for @color{hex}{text} modifiers in cell content
|
||||
if "@" in cell_text:
|
||||
spans = []
|
||||
import re as _re
|
||||
pattern = _re.compile(r"@color\{([0-9a-fA-F]{3})\}\{([^}]*)\}")
|
||||
pattern = re.compile(r"@color\{([0-9a-fA-F]{3})\}\{([^}]*)\}")
|
||||
pos = 0
|
||||
styled_parts: list[tuple[str, CellStyle]] = []
|
||||
for m in pattern.finditer(cell_text):
|
||||
|
||||
@@ -11,7 +11,6 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
from typing import Sequence
|
||||
|
||||
from uframe.errors import ParseError
|
||||
from uframe.ir import (
|
||||
@@ -594,6 +593,18 @@ component resources(cpu, mem)
|
||||
|
||||
component peer_status(name, state)
|
||||
status "$name" $state
|
||||
|
||||
component info_box(title, content)
|
||||
box light "$title"
|
||||
text "$content"
|
||||
|
||||
component alert_box(title, content)
|
||||
box heavy "$title"
|
||||
color f00
|
||||
text "$content"
|
||||
|
||||
component metric(label, value, max, width)
|
||||
gauge "$label" $value $max $width
|
||||
''',
|
||||
"std/status-bar": '''\
|
||||
component status_bar(label, value, max)
|
||||
@@ -601,6 +612,13 @@ component status_bar(label, value, max)
|
||||
|
||||
component status_item(name, state)
|
||||
status "$name" $state
|
||||
|
||||
component status_row(name1, state1, name2, state2)
|
||||
row 2
|
||||
col 28
|
||||
status "$name1" $state1
|
||||
col 28
|
||||
status "$name2" $state2
|
||||
''',
|
||||
"std/nav": '''\
|
||||
component nav_link(label, dest)
|
||||
@@ -608,6 +626,36 @@ component nav_link(label, dest)
|
||||
|
||||
component nav_divider()
|
||||
divider light
|
||||
|
||||
component nav_bar(label1, dest1, label2, dest2)
|
||||
row 2
|
||||
col 28
|
||||
link "$label1" "$dest1"
|
||||
col 28
|
||||
link "$label2" "$dest2"
|
||||
''',
|
||||
"std/network": '''\
|
||||
component route_table(title)
|
||||
table "$title"
|
||||
|
||||
component peer_list(title)
|
||||
heading 2 "$title"
|
||||
|
||||
component traffic(label_in, vals_in, label_out, vals_out)
|
||||
sparkline "$label_in" "$vals_in" 20
|
||||
sparkline "$label_out" "$vals_out" 20
|
||||
''',
|
||||
"std/form": '''\
|
||||
component search_form(name, action)
|
||||
form "$name"
|
||||
field "query" 30 "Search..."
|
||||
button "Search" "$action"
|
||||
|
||||
component login_form(action)
|
||||
form "login"
|
||||
field "username" 24 "Username"
|
||||
password "password" 24 "Password"
|
||||
button "Login" "$action"
|
||||
''',
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user