feat(dev): issues 0100-0104 — dev_console binary + work_tab + DoD user-facing + frontmatter migration de 146 issues + taxonomia canonica
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: export_hub_icons
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "export_hub_icons(out_dir: str, *, size: int = 64, registry_root: str | None = None) -> dict"
|
||||
description: "Rasteriza iconos PNG para todas las apps C++/imgui del registry y los escribe en un directorio de salida. Consume registry.db para listar apps, lee icon.phosphor + icon.accent de cada app.md y usa la misma logica visual de generate_app_icon (fondo redondeado accent + glyph Phosphor blanco al 70%)."
|
||||
tags: ["hub", "launcher", "icons", "suite", "png", "phosphor", "imgui", "cpp-windows"]
|
||||
params:
|
||||
- name: out_dir
|
||||
desc: "Directorio de salida donde se escriben los <name>.png. Se crea si no existe. PNGs existentes se sobreescriben."
|
||||
- name: size
|
||||
desc: "Lado del cuadrado del PNG en pixels. Default 64. El hub launcher usa 64 para las tarjetas."
|
||||
- name: registry_root
|
||||
desc: "Ruta a la raiz del fn_registry. Si es None usa FN_REGISTRY_ROOT env o /home/lucas/fn_registry."
|
||||
output: "dict con ok=True, count=N (PNGs escritos), out_dir (ruta absoluta), skipped (lista de {name, reason} para apps omitidas)."
|
||||
uses_functions:
|
||||
- generate_app_icon_py_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- cairosvg
|
||||
- Pillow
|
||||
- PyYAML
|
||||
- sqlite3
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/export_hub_icons.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Exportar iconos 64px al directorio local_files/icons del hub launcher (Windows via WSL)
|
||||
python/.venv/bin/python3 -c "
|
||||
import sys; sys.path.insert(0, 'python/functions')
|
||||
from infra.export_hub_icons import export_hub_icons
|
||||
print(export_hub_icons('/mnt/c/Users/lucas/Desktop/apps/app_hub_launcher/local_files/icons'))
|
||||
"
|
||||
|
||||
# Exportar iconos a /tmp para smoke test
|
||||
python/.venv/bin/python3 -c "
|
||||
import sys; sys.path.insert(0, 'python/functions')
|
||||
from infra.export_hub_icons import export_hub_icons
|
||||
import json
|
||||
print(json.dumps(export_hub_icons('/tmp/hub_icons_test'), indent=2))
|
||||
"
|
||||
|
||||
# Via CLI directo con tamaño personalizado
|
||||
cd /home/lucas/fn_registry
|
||||
python/.venv/bin/python3 python/functions/infra/export_hub_icons.py /tmp/hub_icons --size 128
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Despues de compilar y desplegar `app_hub_launcher` a Windows Desktop, antes de lanzar la app: ejecutar esta funcion para generar los PNGs que el hub carga via `fn::gl_texture_load` al arrancar. Tambien util para regenerar los iconos tras cambiar el campo `icon:` en algun `app.md` o tras añadir una nueva app imgui al registry.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere que `sources/phosphor-core/` este clonado en la raiz del registry (`git clone --depth=1 https://github.com/phosphor-icons/core.git sources/phosphor-core`). Si falta, todas las apps se omiten con "Phosphor SVG not found".
|
||||
- Las apps sin campo `icon:` en su `app.md` usan el icono por defecto (`app-window`, accent `#64748b`) — no se omiten.
|
||||
- `cairosvg` requiere `libcairo2` instalado en el sistema (`apt install libcairo2-dev` en Debian/Ubuntu). En WSL suele estar disponible si se uso anteriormente `generate_app_icon`.
|
||||
- El campo `icon.phosphor` debe ser el nombre del glyph Phosphor **sin sufijo de weight** (ej. `"chart-bar"`, no `"chart-bar-fill"`). El sufijo `-fill.svg` se añade internamente.
|
||||
- `out_dir` en rutas Windows desde WSL debe usar `/mnt/c/...` — no rutas Win32 directas.
|
||||
- Apps con `dir_path` que apuntan a rutas fuera del registry (ej. `cpp/apps/shaders_lab`) se tratan igual: `root / dir_path / "app.md"`. Si la app no esta clonada localmente, su `app.md` falta y se omite.
|
||||
@@ -0,0 +1,185 @@
|
||||
"""export_hub_icons — rasteriza iconos PNG para todas las apps C++/imgui del registry.
|
||||
|
||||
Para cada app cpp/imgui registrada en registry.db lee el bloque `icon:` de su
|
||||
app.md (phosphor + accent), rasteriza un PNG cuadrado usando la misma logica
|
||||
visual que generate_app_icon y lo escribe en out_dir/<name>.png.
|
||||
|
||||
El directorio de salida lo consume app_hub_launcher via fn::gl_texture_load.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from infra.generate_app_icon import _find_registry_root, _make_icon_image
|
||||
|
||||
DEFAULT_PHOSPHOR = "app-window"
|
||||
DEFAULT_ACCENT = "#64748b"
|
||||
DEFAULT_SIZE = 64
|
||||
|
||||
|
||||
def _read_frontmatter(md_path: Path) -> dict[str, Any]:
|
||||
"""Parse YAML frontmatter from a .md file. Returns {} on any error."""
|
||||
try:
|
||||
import yaml
|
||||
|
||||
text = md_path.read_text(encoding="utf-8")
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
end = text.find("\n---", 3)
|
||||
if end == -1:
|
||||
return {}
|
||||
yaml_block = text[3:end].strip()
|
||||
data = yaml.safe_load(yaml_block)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as exc:
|
||||
print(f"[export_hub_icons] WARN: could not parse {md_path}: {exc}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
|
||||
def export_hub_icons(
|
||||
out_dir: str,
|
||||
*,
|
||||
size: int = DEFAULT_SIZE,
|
||||
registry_root: str | None = None,
|
||||
) -> dict:
|
||||
"""Rasteriza iconos PNG para todas las apps C++/imgui del registry.
|
||||
|
||||
Consulta registry.db buscando apps con lang='cpp' AND framework='imgui',
|
||||
lee el bloque icon: de cada app.md (phosphor + accent), y escribe un PNG
|
||||
cuadrado de size x size pixels en out_dir/<name>.png usando el mismo estilo
|
||||
visual de generate_app_icon: fondo redondeado del color accent con glyph
|
||||
Phosphor blanco centrado al 70%.
|
||||
|
||||
Si una app.md no existe, no tiene icono declarado o el SVG Phosphor no se
|
||||
encuentra, el app se omite (skipped) y el proceso continua con las demas.
|
||||
|
||||
Args:
|
||||
out_dir: Directorio de salida. Se crea si no existe. Los PNGs existentes
|
||||
se sobreescriben.
|
||||
size: Lado del cuadrado del PNG en pixels. Default 64.
|
||||
registry_root: Ruta a la raiz del fn_registry. Si es None, usa la variable
|
||||
de entorno FN_REGISTRY_ROOT o /home/lucas/fn_registry como fallback.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"ok": True,
|
||||
"count": N, # PNGs escritos correctamente
|
||||
"out_dir": "<abs>", # ruta absoluta del directorio de salida
|
||||
"skipped": [ # apps omitidas con razon
|
||||
{"name": "...", "reason": "..."},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
root = _find_registry_root(registry_root)
|
||||
|
||||
db_path = root / "registry.db"
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"registry.db not found at {db_path}")
|
||||
|
||||
con = sqlite3.connect(str(db_path))
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = con.execute(
|
||||
"SELECT id, name, dir_path FROM apps"
|
||||
" WHERE lang='cpp' AND framework='imgui'"
|
||||
" ORDER BY name"
|
||||
).fetchall()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
phosphor_assets = root / "sources" / "phosphor-core" / "assets"
|
||||
|
||||
out_path = Path(out_dir).resolve()
|
||||
out_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
count = 0
|
||||
skipped: list[dict[str, str]] = []
|
||||
|
||||
for row in rows:
|
||||
app_name: str = row["name"]
|
||||
dir_path: str = row["dir_path"]
|
||||
|
||||
# --- Leer app.md ---
|
||||
md_path = root / dir_path / "app.md"
|
||||
if not md_path.exists():
|
||||
msg = f"app.md missing at {md_path}"
|
||||
print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr)
|
||||
skipped.append({"name": app_name, "reason": msg})
|
||||
continue
|
||||
|
||||
fm = _read_frontmatter(md_path)
|
||||
if not fm:
|
||||
msg = "app.md missing or malformed frontmatter"
|
||||
print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr)
|
||||
skipped.append({"name": app_name, "reason": msg})
|
||||
continue
|
||||
|
||||
icon_block = fm.get("icon")
|
||||
if isinstance(icon_block, dict):
|
||||
phosphor_name = icon_block.get("phosphor") or DEFAULT_PHOSPHOR
|
||||
accent_hex = icon_block.get("accent") or DEFAULT_ACCENT
|
||||
else:
|
||||
phosphor_name = DEFAULT_PHOSPHOR
|
||||
accent_hex = DEFAULT_ACCENT
|
||||
|
||||
# --- Localizar SVG Phosphor ---
|
||||
svg_path = phosphor_assets / "fill" / f"{phosphor_name}-fill.svg"
|
||||
if not svg_path.exists():
|
||||
msg = f"Phosphor SVG not found: {svg_path}"
|
||||
print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr)
|
||||
skipped.append({"name": app_name, "reason": msg})
|
||||
continue
|
||||
|
||||
# --- Rasterizar y guardar PNG ---
|
||||
try:
|
||||
img = _make_icon_image(svg_path, accent_hex, size)
|
||||
png_out = out_path / f"{app_name}.png"
|
||||
img.save(str(png_out), format="PNG")
|
||||
count += 1
|
||||
except Exception as exc:
|
||||
msg = f"render error: {exc}"
|
||||
print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr)
|
||||
skipped.append({"name": app_name, "reason": msg})
|
||||
continue
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"count": count,
|
||||
"out_dir": str(out_path),
|
||||
"skipped": skipped,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import json
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Export PNG icons for all cpp/imgui apps to a directory."
|
||||
)
|
||||
parser.add_argument("out_dir", help="Destination directory for .png files")
|
||||
parser.add_argument(
|
||||
"--size",
|
||||
type=int,
|
||||
default=DEFAULT_SIZE,
|
||||
help=f"Icon side in pixels (default {DEFAULT_SIZE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--registry-root",
|
||||
default=None,
|
||||
help="Path to fn_registry root (default: FN_REGISTRY_ROOT env or /home/lucas/fn_registry)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
result = export_hub_icons(
|
||||
args.out_dir,
|
||||
size=args.size,
|
||||
registry_root=args.registry_root,
|
||||
)
|
||||
print(json.dumps(result, indent=2))
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Rasteriza un icono Phosphor SVG sobre un fondo redondeado del color accent
|
||||
y exporta un .ico con multiples resoluciones (16, 24, 32, 48, 64, 128, 256).
|
||||
|
||||
Funciones publicas reutilizables:
|
||||
_find_registry_root — localiza la raiz del registry
|
||||
_render_glyph_white — rasteriza un SVG Phosphor como glyph blanco RGBA
|
||||
_make_icon_image — compone fondo redondeado + glyph en un PIL.Image
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
@@ -14,8 +19,23 @@ from PIL import Image, ImageDraw
|
||||
DEFAULT_SIZES = [16, 24, 32, 48, 64, 128, 256]
|
||||
|
||||
|
||||
def _find_registry_root() -> Path:
|
||||
"""Descubre la raiz del registry caminando hacia arriba hasta encontrar registry.db."""
|
||||
def _find_registry_root(registry_root: str | None = None) -> Path:
|
||||
"""Descubre la raiz del registry.
|
||||
|
||||
Prioridad: argumento explicito > FN_REGISTRY_ROOT env > caminar hacia arriba
|
||||
desde el archivo hasta encontrar registry.db.
|
||||
|
||||
Args:
|
||||
registry_root: Ruta absoluta opcional. Si se pasa, se devuelve directamente.
|
||||
|
||||
Returns:
|
||||
Path resuelto a la raiz del registry.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Si no se puede localizar registry.db.
|
||||
"""
|
||||
if registry_root is not None:
|
||||
return Path(registry_root).resolve()
|
||||
env_root = os.environ.get("FN_REGISTRY_ROOT")
|
||||
if env_root:
|
||||
return Path(env_root).resolve()
|
||||
@@ -35,7 +55,15 @@ def _hex_to_rgb(h: str) -> tuple[int, int, int]:
|
||||
|
||||
|
||||
def _render_glyph_white(svg_path: Path, size: int) -> Image.Image:
|
||||
"""Renderiza el SVG Phosphor como glyph blanco sobre fondo transparente."""
|
||||
"""Renderiza el SVG Phosphor como glyph blanco sobre fondo transparente.
|
||||
|
||||
Args:
|
||||
svg_path: Ruta al archivo .svg de Phosphor.
|
||||
size: Ancho/alto en pixels del output.
|
||||
|
||||
Returns:
|
||||
Imagen RGBA con el glyph en blanco sobre fondo transparente.
|
||||
"""
|
||||
svg = svg_path.read_text(encoding="utf-8")
|
||||
# Phosphor usa fill="currentColor" — forzar blanco.
|
||||
svg = svg.replace('fill="currentColor"', 'fill="#ffffff"')
|
||||
@@ -48,7 +76,16 @@ def _render_glyph_white(svg_path: Path, size: int) -> Image.Image:
|
||||
|
||||
|
||||
def _make_icon_image(svg_path: Path, accent_hex: str, size: int) -> Image.Image:
|
||||
"""Compone fondo redondeado con color accent + glyph blanco centrado al 70%."""
|
||||
"""Compone fondo redondeado con color accent + glyph blanco centrado al 70%.
|
||||
|
||||
Args:
|
||||
svg_path: Ruta al archivo .svg de Phosphor.
|
||||
accent_hex: Color de fondo en formato "#RRGGBB".
|
||||
size: Ancho/alto en pixels del canvas de salida.
|
||||
|
||||
Returns:
|
||||
Imagen RGBA lista para guardar como PNG o ICO.
|
||||
"""
|
||||
bg_color = _hex_to_rgb(accent_hex) + (255,)
|
||||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
@@ -113,8 +150,8 @@ def generate_app_icon(
|
||||
|
||||
# Resolver raiz de phosphor
|
||||
if phosphor_root is None:
|
||||
registry_root = _find_registry_root()
|
||||
phosphor_assets = registry_root / "sources" / "phosphor-core" / "assets"
|
||||
_root = _find_registry_root()
|
||||
phosphor_assets = _root / "sources" / "phosphor-core" / "assets"
|
||||
else:
|
||||
phosphor_assets = Path(phosphor_root)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user