"""Paint pass — write IR nodes into the CharGrid as characters. Depth-first traversal: each node writes its content at its assigned rect position. Containers recurse into children after drawing their own structure (borders, etc.). """ from __future__ import annotations import re import textwrap from uframe.chars import ( BOX_CHARS, DIVIDER_CHARS, sparkline_chars, ) from uframe.grid import CharGrid, CellStyle 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, BigTitle, ImageNode, HNav, VNav, HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight, ) from uframe.themes import ThemeDef, THEME_DEFAULT from uframe.fonts import render_big_text, get_text_width, FONT_HEIGHTS from uframe.imaging import convert_image def _align_text(text: str, width: int, align: Align) -> str: """Align text within a field of the given width.""" if len(text) >= width: return text[:width] if align == Align.CENTER: return text.center(width) elif align == Align.RIGHT: return text.rjust(width) return text.ljust(width) def _style_from_node(node: IRNode) -> CellStyle: """Create a CellStyle from a node's style attributes.""" return CellStyle( fg=node.style.fg, bg=node.style.bg, bold=node.style.bold, italic=node.style.italic, underline=node.style.underline, ) def paint(node: IRNode, grid: CharGrid, theme: ThemeDef | None = None) -> None: """Recursively paint an IR node and its children into the grid.""" th = theme or THEME_DEFAULT x, y, w = node.rect.x, node.rect.y, node.rect.w # Ensure grid is tall enough grid.grow_height(y + node.rect.h) if isinstance(node, Page): for child in node.children: paint(child, grid, th) elif isinstance(node, Box): # Draw the border with themed characters title_style = CellStyle(bold=True, fg=node.style.fg or th.palette.accent) grid.draw_border(x, y, w, node.rect.h, weight=node.weight, title=node.title, title_style=title_style, border_chars=th.border_dict(node.weight.name.lower()), title_caps=(th.title_caps.left, th.title_caps.right)) # 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, th) elif isinstance(node, Row): for child in node.children: paint(child, grid, th) elif isinstance(node, Col): for child in node.children: paint(child, grid, th) elif isinstance(node, Spacer): pass # Just empty space elif isinstance(node, Pad): for child in node.children: paint(child, grid, th) elif isinstance(node, Heading): style = CellStyle(bold=True) if node.level == HeadingLevel.H1: style.fg = th.palette.accent elif node.level == HeadingLevel.H2: style.fg = th.palette.accent2 elif node.level == HeadingLevel.H3: style.fg = th.palette.accent3 # Underline-style heading grid.put_text(x, y, node.text[:w], style=style) elif isinstance(node, Text): style = _style_from_node(node) if node.spans and any( s.bold or s.italic or s.underline or s.fg or s.bg for s in node.spans ): # Render with inline spans col = x row = y for span in node.spans: span_style = CellStyle( fg=span.fg or style.fg, bg=span.bg or style.bg, bold=span.bold or style.bold, italic=span.italic or style.italic, underline=span.underline or style.underline, ) for ch in span.text: if col >= x + w: col = x row += 1 if grid.in_bounds(col, row): grid.put(col, row, ch, style=span_style) col += 1 else: # Simple text with word wrapping wrapped = textwrap.wrap(node.content, width=w) if node.content else [""] for i, line in enumerate(wrapped): if y + i < grid.height: text = _align_text(line, w, node.style.align) grid.put_text(x, y + i, text, style=style) elif isinstance(node, Label): style = _style_from_node(node) key_style = CellStyle(bold=True, fg=style.fg, bg=style.bg) # Key: value layout with padding key_text = f"{node.key}:" pad = max(1, 16 - len(key_text)) grid.put_text(x, y, key_text, style=key_style) grid.put_text(x + len(key_text) + pad, y, node.value, style=style) elif isinstance(node, Divider): ds = node.divider_style char = getattr(th.dividers, ds.name.lower(), th.dividers.light) style = CellStyle(fg=th.palette.muted) for col in range(x, x + w): grid.put(col, y, char, style=style) elif isinstance(node, Link): style = CellStyle(fg=th.palette.info, underline=True) grid.put_text(x, y, node.display[:w], style=style, link=node.dest) elif isinstance(node, ListNode): for child in node.children: paint(child, grid, th) elif isinstance(node, ListItem): style = _style_from_node(node) # 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, f"{th.ornaments.bullet} ", style=CellStyle(fg=th.palette.label)) # Wrap content wrapped = textwrap.wrap(node.content, width=w) if node.content else [""] for i, line in enumerate(wrapped): if y + i < grid.height: grid.put_text(x, y + i, line, style=style) elif isinstance(node, Gauge): style = _style_from_node(node) # Label label_text = f"{node.label} " grid.put_text(x, y, label_text, style=CellStyle(bold=True)) bar_x = x + len(label_text) bar_w = min(node.bar_width, w - len(label_text) - 6) if bar_w > 0: pct = min(node.value / node.max_val, 1.0) if node.max_val > 0 else 0 filled = int(bar_w * pct) # Determine color based on thresholds fg = th.palette.success if node.crit is not None and node.value >= node.crit: fg = th.palette.danger elif node.warn is not None and node.value >= node.warn: fg = th.palette.warning for i in range(bar_w): if i < filled: grid.put(bar_x + i, y, th.gauge.filled, style=CellStyle(fg=fg)) else: grid.put(bar_x + i, y, th.gauge.empty, style=CellStyle(fg=th.palette.muted)) # Percentage pct_text = f" {int(pct * 100)}%" grid.put_text(bar_x + bar_w, y, pct_text, style=CellStyle(fg=fg)) elif isinstance(node, Sparkline): style = _style_from_node(node) label_text = f"{node.label} " grid.put_text(x, y, label_text, style=CellStyle(bold=True)) spark_x = x + len(label_text) chars = sparkline_chars(node.values, node.spark_width) spark_style = CellStyle(fg=th.palette.info) for i, ch in enumerate(chars): grid.put(spark_x + i, y, ch, style=spark_style) elif isinstance(node, Status): char = getattr(th.indicators, node.state, th.indicators.unknown) color_map = {"online": th.palette.success, "offline": th.palette.danger, "degraded": th.palette.warning, "unknown": th.palette.label, "alert": th.palette.danger} color = color_map.get(node.state, th.palette.label) 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: lines = convert_image(node.path, node.mode, node.img_width, node.dither, node.invert, node.use_color) # Center if aligned offset = 0 actual_w = len(lines[0]) if lines else 0 if node.style.align == Align.CENTER: offset = max(0, (w - actual_w) // 2) elif node.style.align == Align.RIGHT: offset = max(0, w - actual_w) for row_i, line in enumerate(lines): if y + row_i < grid.height: grid.put_text(x + offset, y + row_i, line, style=style) # Caption if node.caption and y + len(lines) < grid.height: cap_style = CellStyle(fg=th.palette.label, italic=True) grid.put_text(x + offset, y + len(lines), node.caption, style=cap_style) except (ImportError, FileNotFoundError) as e: # Render placeholder if image can't be loaded grid.put_text(x, y, f"[image: {node.path}]", style=CellStyle(fg=th.palette.muted)) 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) elif isinstance(node, Form): for child in node.children: paint(child, grid, th) elif isinstance(node, Field): label_style = CellStyle(fg=th.palette.label) field_style = CellStyle(fg=th.palette.form) label_text = f"{node.field_name}: " grid.put_text(x, y, label_text, style=label_style) fl = th.form.field_l fr = th.form.field_r fx = x + len(label_text) fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr)) grid.put_text(fx, y, fl, style=field_style) placeholder = node.placeholder or node.field_name inner = placeholder.ljust(fw)[:fw] grid.put_text(fx + len(fl), y, inner, style=CellStyle(fg=th.palette.muted)) grid.put_text(fx + len(fl) + fw, y, fr, style=field_style) elif isinstance(node, Password): label_style = CellStyle(fg=th.palette.label) field_style = CellStyle(fg=th.palette.form) label_text = f"{node.field_name}: " grid.put_text(x, y, label_text, style=label_style) fl = th.form.field_l fr = th.form.field_r fx = x + len(label_text) fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr)) grid.put_text(fx, y, fl, style=field_style) inner = "•" * fw grid.put_text(fx + len(fl), y, inner[:fw], style=CellStyle(fg=th.palette.muted)) grid.put_text(fx + len(fl) + fw, y, fr, style=field_style) elif isinstance(node, Radio): label_style = CellStyle(fg=th.palette.label) label_text = f"{node.group}: " grid.put_text(x, y, label_text, style=label_style) rx = x + len(label_text) for i, opt in enumerate(node.options): dot = th.form.radio_on if i == 0 else th.form.radio_off opt_style = CellStyle(fg=th.palette.form if i == 0 else th.palette.label) grid.put_text(rx, y, dot, style=opt_style) rx += len(dot) + 1 grid.put_text(rx, y, opt, style=CellStyle()) rx += len(opt) + 2 elif isinstance(node, Checkbox): check_style = CellStyle(fg=th.palette.form) box_char = th.form.check_on if node.checked else th.form.check_off grid.put_text(x, y, box_char, style=check_style) grid.put_text(x + len(box_char) + 1, y, node.checkbox_label) elif isinstance(node, FormButton): btn_style = CellStyle(bold=True, fg=th.palette.button) btn_text = f"[ {node.button_label} ]" grid.put_text(x, y, btn_text, style=btn_style, link=node.dest) else: # Generic: paint children for child in node.children: 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: return ch = BOX_CHARS[BorderWeight.LIGHT] border_style = CellStyle() header_style = CellStyle(bold=True) num_cols = len(node.columns) # Calculate column widths # If columns have explicit widths, use them. Otherwise distribute evenly. col_widths: list[int] = [] total_explicit = 0 auto_count = 0 for _, cw in node.columns: if cw > 0: col_widths.append(cw) total_explicit += cw else: col_widths.append(0) auto_count += 1 # Available inner width = total - borders (num_cols + 1 border chars) inner_w = w - (num_cols + 1) if auto_count > 0: auto_each = max(1, (inner_w - total_explicit) // auto_count) for i in range(len(col_widths)): if col_widths[i] == 0: col_widths[i] = auto_each # Compute column x positions (after each left border) col_x: list[int] = [] cx = x + 1 # after left border for cw in col_widths: col_x.append(cx) cx += cw + 1 # +1 for separator table_w = cx - x # total table width including right border row_y = y # ── Top border ── grid.put(x, row_y, ch["tl"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) grid.put(x + table_w - 1, row_y, ch["tr"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) for ci, cw in enumerate(col_widths): for j in range(cw): grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) # Column separator on top border if ci < num_cols - 1: grid.put(col_x[ci] + cw, row_y, ch["t_down"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) row_y += 1 # ── Header row ── grid.put(x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) for ci, (col_name, _) in enumerate(node.columns): text = col_name[:col_widths[ci]].ljust(col_widths[ci]) grid.put_text(col_x[ci], row_y, text, style=header_style) sep_x = col_x[ci] + col_widths[ci] grid.put(sep_x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) row_y += 1 # ── Header separator ── grid.put(x, row_y, ch["t_right"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) grid.put(x + table_w - 1, row_y, ch["t_left"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) for ci, cw in enumerate(col_widths): for j in range(cw): grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) if ci < num_cols - 1: grid.put(col_x[ci] + cw, row_y, ch["cross"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) row_y += 1 # ── Data rows ── cell_style = CellStyle() for row_data in node.rows: grid.put(x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) for ci in range(num_cols): cell_text = row_data[ci] if ci < len(row_data) else "" text = cell_text[:col_widths[ci]].ljust(col_widths[ci]) # Check for @color{hex}{text} modifiers in cell content if "@" in cell_text: 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): if m.start() > pos: styled_parts.append((cell_text[pos:m.start()], cell_style)) styled_parts.append((m.group(2), CellStyle(fg=m.group(1)))) pos = m.end() if pos < len(cell_text): styled_parts.append((cell_text[pos:], cell_style)) col_pos = col_x[ci] for part_text, part_style in styled_parts: for pch in part_text: if col_pos < col_x[ci] + col_widths[ci]: grid.put(col_pos, row_y, pch, style=part_style) col_pos += 1 # Pad remaining while col_pos < col_x[ci] + col_widths[ci]: grid.put(col_pos, row_y, " ") col_pos += 1 else: grid.put_text(col_x[ci], row_y, text, style=cell_style) sep_x = col_x[ci] + col_widths[ci] grid.put(sep_x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) row_y += 1 # ── Bottom border ── grid.put(x, row_y, ch["bl"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) grid.put(x + table_w - 1, row_y, ch["br"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) for ci, cw in enumerate(col_widths): for j in range(cw): grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT) if ci < num_cols - 1: grid.put(col_x[ci] + cw, row_y, ch["t_up"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)