Files
micronomicon/backend/uframe/measure.py
2026-04-01 16:04:06 +02:00

329 lines
11 KiB
Python

"""Bottom-up measure pass — compute min/preferred width and height for each node."""
from __future__ import annotations
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,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle, ImageNode, HNav, VNav,
)
from uframe.fonts import FONT_HEIGHTS, get_text_width
from uframe.imaging import get_image_height
def _text_height(text: str, width: int) -> int:
"""Compute how many lines a text string needs at a given width."""
if not text or width <= 0:
return 1
words = text.split()
lines = 1
col = 0
for word in words:
wlen = len(word)
if col > 0 and col + 1 + wlen > width:
lines += 1
col = wlen
elif col == 0:
col = wlen
else:
col += 1 + wlen
return max(lines, 1)
def measure(node: IRNode, available_width: int) -> None:
"""Recursively compute min/preferred sizes for an IR subtree.
This is a bottom-up pass: children are measured before their parent.
"""
# Dispatch to node type
if isinstance(node, Page):
w = node.width
node.pref_width = w
node.min_width = w
# Measure children with full page width
total_h = 0
for child in node.children:
measure(child, w)
total_h += child.pref_height
node.pref_height = total_h
node.min_height = total_h
elif isinstance(node, Box):
# Box adds 2 chars for borders on each axis (left+right, top+bottom)
inner_w = available_width - 2
total_h = 0
for child in node.children:
measure(child, inner_w)
total_h += child.pref_height
node.pref_width = available_width
node.min_width = 4 # minimum: border + 2 chars + border
node.pref_height = total_h + 2 # +2 for top/bottom border
node.min_height = 3 # top border + 1 line + bottom border
elif isinstance(node, Row):
# Children laid out horizontally, split available width
n = len(node.children)
if n == 0:
node.pref_width = available_width
node.pref_height = 0
node.min_width = 0
node.min_height = 0
return
gap_total = node.gap * (n - 1)
usable = available_width - gap_total
# First pass: measure children to get their preferred sizes
# Distribute width proportionally or equally
fixed_width_children = []
flex_children = []
fixed_total = 0
for child in node.children:
if isinstance(child, Col) and child.col_width is not None:
fixed_width_children.append(child)
fixed_total += child.col_width
else:
flex_children.append(child)
flex_each = 0
if flex_children:
flex_each = max(1, (usable - fixed_total) // len(flex_children))
max_h = 0
for child in node.children:
if isinstance(child, Col) and child.col_width is not None:
child_w = child.col_width
else:
child_w = flex_each
measure(child, child_w)
max_h = max(max_h, child.pref_height)
node.pref_width = available_width
node.min_width = n # at minimum 1 char per child
node.pref_height = max_h
node.min_height = max_h
elif isinstance(node, Col):
w = node.col_width if node.col_width is not None else available_width
total_h = 0
for child in node.children:
measure(child, w)
total_h += child.pref_height
node.pref_width = w
node.min_width = min(w, 1)
node.pref_height = total_h
node.min_height = total_h
elif isinstance(node, Spacer):
node.pref_width = available_width
node.min_width = 0
node.pref_height = node.lines
node.min_height = node.lines
elif isinstance(node, Pad):
inner_w = available_width - node.left - node.right
total_h = 0
for child in node.children:
measure(child, inner_w)
total_h += child.pref_height
node.pref_width = available_width
node.min_width = node.left + node.right + 1
node.pref_height = total_h + node.top + node.bottom
node.min_height = node.top + node.bottom
elif isinstance(node, Heading):
node.pref_width = available_width
node.min_width = len(node.text) + 1
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Text):
node.pref_width = available_width
node.min_width = 1
node.pref_height = _text_height(node.content, available_width)
node.min_height = 1
elif isinstance(node, Label):
node.pref_width = available_width
node.min_width = len(node.key) + 2 + len(node.value)
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Divider):
node.pref_width = available_width
node.min_width = 1
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Link):
node.pref_width = available_width
node.min_width = len(node.display) + 2
node.pref_height = 1
node.min_height = 1
elif isinstance(node, ListNode):
total_h = 0
for child in node.children:
measure(child, available_width - 2) # indent for bullet
total_h += child.pref_height
node.pref_width = available_width
node.min_width = 4
node.pref_height = total_h
node.min_height = total_h
elif isinstance(node, ListItem):
node.pref_width = available_width
node.min_width = len(node.content) + 1
node.pref_height = _text_height(node.content, available_width)
node.min_height = 1
elif isinstance(node, Gauge):
node.pref_width = available_width
node.min_width = node.bar_width + len(node.label) + 6
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Sparkline):
node.pref_width = available_width
node.min_width = node.spark_width + len(node.label) + 4
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Status):
node.pref_width = available_width
node.min_width = len(node.label) + 4
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Table):
num_rows = len(node.rows)
node.pref_width = available_width
node.min_width = len(node.columns) * 3 + 1
node.pref_height = num_rows + 4
node.min_height = 4
elif isinstance(node, Form):
total_h = 0
for child in node.children:
measure(child, available_width)
total_h += child.pref_height
node.pref_width = available_width
node.min_width = 10
node.pref_height = total_h
node.min_height = total_h
elif isinstance(node, Field):
node.pref_width = available_width
node.min_width = len(node.field_name) + node.field_width + 6
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Password):
node.pref_width = available_width
node.min_width = len(node.field_name) + node.field_width + 6
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Radio):
node.pref_width = available_width
node.min_width = len(node.group) + sum(len(o) + 6 for o in node.options)
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Checkbox):
node.pref_width = available_width
node.min_width = len(node.checkbox_label) + 6
node.pref_height = 1
node.min_height = 1
elif isinstance(node, FormButton):
node.pref_width = available_width
node.min_width = len(node.button_label) + 6
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:
h += 1 # extra line for caption
node.pref_width = available_width
node.min_width = node.img_width
node.pref_height = h
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
node.min_width = 0
node.pref_height = 0
node.min_height = 0
elif isinstance(node, (IfBlock, ForLoop, OnSubmit)):
# Container nodes — height = sum of children
total_h = 0
for child in node.children:
measure(child, available_width)
total_h += child.pref_height
node.pref_width = available_width
node.min_width = 1
node.pref_height = total_h
node.min_height = 0
else:
# Generic: just measure children
total_h = 0
for child in node.children:
measure(child, available_width)
total_h += child.pref_height
node.pref_width = available_width
node.min_width = 1
node.pref_height = max(total_h, 1)
node.min_height = 1