diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py new file mode 100644 index 00000000..9b3461f6 --- /dev/null +++ b/web_interface/blueprints/composer.py @@ -0,0 +1,847 @@ +""" +Plugin Composer blueprint — drag-and-drop plugin builder for LEDMatrix. + +Routes: + GET /composer/ — Composer page + POST /composer/api/generate — Generate and return plugin ZIP + POST /composer/api/install — Write plugin directly to plugins_dir + GET /composer/api/fonts/ — Serve TTF font files for canvas rendering + GET /composer/api/validate-id/ — Check if a plugin ID is already taken +""" +import ast +import io +import json +import logging +import re +import zipfile +from datetime import datetime +from pathlib import Path + +import jinja2 +import jsonschema +from flask import Blueprint, jsonify, render_template, request, send_file + +logger = logging.getLogger(__name__) + +composer_bp = Blueprint('composer', __name__) + +# Module-level attributes injected by app.py at registration time +composer_bp.config_manager = None +composer_bp.plugin_manager = None +composer_bp.plugins_dir = None +composer_bp.project_root = None + +# Fonts safe to serve to the browser for canvas rendering +_ALLOWED_FONTS = frozenset({'PressStart2P-Regular.ttf', '4x6-font.ttf', '5by7.regular.ttf'}) + +# Map composer font keys → DisplayManager attribute names +_FONT_ATTR_MAP = { + 'press_start': 'regular_font', + 'four_by_six': 'extra_small_font', + 'five_by_seven': 'bdf_5x7_font', +} + +# Font sizes in LED pixels (used to compute second-line Y offsets) +_FONT_SIZE_MAP = { + 'press_start': 8, + 'four_by_six': 6, + 'five_by_seven': 7, +} + +_PLUGIN_ID_RE = re.compile(r'^[a-z][a-z0-9-]{0,62}$') +_PYTHON_IDENT_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$') + +# ── Jinja2 environment (separate from Flask's; autoescape=False for code gen) ── + +_jinja_env: jinja2.Environment | None = None + + +def _get_jinja_env() -> jinja2.Environment: + global _jinja_env + if _jinja_env is None: + template_dir = Path(__file__).parent.parent / 'templates' / 'v3' / 'composer' + _jinja_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(str(template_dir)), + autoescape=False, + trim_blocks=True, + lstrip_blocks=True, + ) + _jinja_env.filters['as_rgb'] = _as_rgb_filter + _jinja_env.filters['as_fill'] = _as_fill_filter + return _jinja_env + + +def _as_rgb_filter(val) -> str: + """[r, g, b] → '(r, g, b)'""" + if val is None: + return 'None' + return f'({int(val[0])}, {int(val[1])}, {int(val[2])})' + + +def _as_fill_filter(val) -> str: + """[r, g, b] or None → '(r, g, b)' or 'None'""" + if val is None: + return 'None' + return _as_rgb_filter(val) + + +# ── Helper functions ────────────────────────────────────────────────────────── + +def _to_class_name(name: str) -> str: + """'My Clock' → 'MyClockPlugin' (avoids double-suffix if name already ends with Plugin)""" + words = re.sub(r'[^a-zA-Z0-9]', ' ', name).split() + base = ''.join(w.capitalize() for w in words) + return base if base.endswith('Plugin') else base + 'Plugin' + + +def _compute_pos_expr(val: int, anchor: str | None, dim_var: str) -> str: + """Produce a Python expression string for an anchored or fixed position. + + anchor=None/'left'/'top' → fixed pixel value + anchor='center' → dim_var // 2 ± offset + anchor='right'/'bottom' → dim_var - offset + """ + if not anchor or anchor in ('left', 'top'): + return str(val) + if anchor in ('center', 'middle'): + if val == 0: + return f"{dim_var} // 2" + return f"{dim_var} // 2 + {val}" if val > 0 else f"{dim_var} // 2 - {abs(val)}" + if anchor in ('right', 'bottom'): + return dim_var if val == 0 else f"{dim_var} - {val}" + return str(val) + + +# Character widths in LED pixels per font (for text-alignment x offset math) +_FONT_CHAR_W = { + 'press_start': 8, + 'four_by_six': 4, + 'five_by_seven': 5, +} + + +def _aligned_x_expr(x_base_expr: str, text_align: str, char_count: int, char_w: int) -> str: + """Return Python x expression for text alignment. + + left → x_base_expr (no change) + center → x_base_expr - half_text_width + right → x_base_expr - text_width + """ + if text_align == 'left' or not text_align: + return x_base_expr + text_px = char_count * char_w + if text_align == 'center': + offset = text_px // 2 + return f"({x_base_expr}) - {offset}" if offset else x_base_expr + if text_align == 'right': + return f"({x_base_expr}) - {text_px}" if text_px else x_base_expr + return x_base_expr + + +def _preprocess_elements(elements: list) -> list: + """Expand raw element dicts into template-ready dicts with anchor expressions. + + Invisible elements (visible=False) are excluded from generated code entirely. + """ + result = [] + for el in elements: + # Skip hidden elements — they exist only in the preview + if el.get('visible') is False: + continue + + p = dict(el) + t = el.get('type', '') + + # Section elements are layer-list annotations only — no canvas output + if t == 'section': + continue + + x_anchor = el.get('xAnchor') or None + y_anchor = el.get('yAnchor') or None + p['min_width'] = int(el.get('minWidth', 0) or 0) + + if t in ('text', 'clock'): + font_key = el.get('font', 'press_start') + p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font') + p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})" + text_align = el.get('textAlign', 'left') + raw_x = el.get('x', 0) + x_base_expr = _compute_pos_expr(raw_x, x_anchor, 'width') + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + font_size = _FONT_SIZE_MAP.get(font_key, 8) + char_w = _FONT_CHAR_W.get(font_key, 8) + line_spacing = int(el.get('lineSpacing', 2)) + y_expr = p['y_expr'] + p['y2_expr'] = f"({y_expr}) + {font_size + line_spacing}" + if t == 'text': + t1 = el.get('text', '') or '' + t2 = el.get('text2', '') or '' + p['text2'] = t2 + # Detect {variable} tokens — generate format_map() call instead of literal + _var_re = re.compile(r'\{([a-zA-Z_]\w*)\}') + p['text_is_template'] = bool(_var_re.search(t1) or _var_re.search(t2)) + ref_len = max(len(t1), len(t2)) if t2 else len(t1) + p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w) + p['x2_expr'] = p['x_expr'] # second line uses same x + else: # clock + fmt1 = el.get('format', '%H:%M') or '%H:%M' + fmt2 = el.get('format2', '') or '' + p['format2'] = fmt2 + ref_len = max(len(fmt1), len(fmt2)) if fmt2 else len(fmt1) + p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w) + p['x2_expr'] = p['x_expr'] + p['blink'] = bool(el.get('blink', False)) + + elif t == 'dynamic_text': + binding = el.get('binding', {}) + p['binding_source'] = binding.get('source', 'config') + p['binding_key'] = binding.get('key', '') + p['binding_format'] = binding.get('format') + font_key = el.get('font', 'press_start') + p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font') + p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 100)})" + x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + p['x_expr'] = x_base_expr # dynamic text: runtime content determines width; use raw pos + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['blink'] = bool(el.get('blink', False)) + + elif t == 'rectangle': + x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + w = el.get('width', 10) + h = el.get('height', 8) + p['x_expr'] = x_expr + p['y_expr'] = y_expr + # x2/y2 as runtime expressions to support anchored positions + p['x2_expr'] = f"({x_expr}) + {w}" + p['y2_expr'] = f"({y_expr}) + {h}" + fill = ( + [el.get('fillR', 0), el.get('fillG', 0), el.get('fillB', 128)] + if el.get('hasFill', True) else None + ) + outline = ( + [el.get('outR', 255), el.get('outG', 255), el.get('outB', 255)] + if el.get('hasOutline', True) else None + ) + p['fill_tuple'] = _as_fill_filter(fill) + p['outline_tuple'] = _as_fill_filter(outline) + p['blink'] = bool(el.get('blink', False)) + + elif t in ('line', 'divider'): + if t == 'divider': + orient = el.get('orientation', 'horizontal') + if orient == 'horizontal': + y_val = el.get('y', 16) + y_expr = _compute_pos_expr(y_val, y_anchor, 'height') + p.update(x0_expr='0', y0_expr=y_expr, x1_expr='width - 1', y1_expr=y_expr) + else: + x_val = el.get('x', 64) + x_expr = _compute_pos_expr(x_val, x_anchor, 'width') + p.update(x0_expr=x_expr, y0_expr='0', x1_expr=x_expr, y1_expr='height - 1') + else: + p['x0_expr'] = _compute_pos_expr(el.get('x0', 0), x_anchor, 'width') + p['y0_expr'] = _compute_pos_expr(el.get('y0', 0), y_anchor, 'height') + p['x1_expr'] = str(el.get('x1', 127)) + p['y1_expr'] = str(el.get('y1', 0)) + p['rgb_tuple'] = f"({el.get('r', 180)}, {el.get('g', 180)}, {el.get('b', 180)})" + p['line_width'] = el.get('lineWidth', 1) + p['blink'] = bool(el.get('blink', False)) + + elif t == 'progress_bar': + p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['bar_width'] = int(el.get('barWidth', 40)) + p['bar_height'] = int(el.get('barHeight', 6)) + binding = el.get('binding', {}) + p['binding_key'] = binding.get('key', '') + p['fill_tuple'] = f"({el.get('r', 100)}, {el.get('g', 200)}, {el.get('b', 100)})" + bg = ( + [el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)] + if el.get('hasBg', True) else None + ) + outline = ( + [el.get('outR', 100), el.get('outG', 100), el.get('outB', 100)] + if el.get('hasOutline', True) else None + ) + p['bg_tuple'] = _as_fill_filter(bg) + p['outline_tuple'] = _as_fill_filter(outline) + p['blink'] = bool(el.get('blink', False)) + + elif t == 'arc': + x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + w = el.get('width', 24) + h = el.get('height', 24) + p['x_expr'] = x_expr + p['y_expr'] = y_expr + p['x2_expr'] = f"({x_expr}) + {w}" + p['y2_expr'] = f"({y_expr}) + {h}" + p['start_angle'] = int(el.get('startAngle', 0)) + p['end_angle'] = int(el.get('endAngle', 270)) + p['line_width'] = max(1, int(el.get('lineWidth', 2))) + p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})" + p['blink'] = bool(el.get('blink', False)) + + elif t == 'ellipse': + x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + w = el.get('width', 24) + h = el.get('height', 12) + p['x_expr'] = x_expr + p['y_expr'] = y_expr + p['x2_expr'] = f"({x_expr}) + {w}" + p['y2_expr'] = f"({y_expr}) + {h}" + fill = ( + [el.get('fillR', 0), el.get('fillG', 100), el.get('fillB', 200)] + if el.get('hasFill', True) else None + ) + outline = ( + [el.get('outR', 100), el.get('outG', 180), el.get('outB', 255)] + if el.get('hasOutline', True) else None + ) + p['fill_tuple'] = _as_fill_filter(fill) + p['outline_tuple'] = _as_fill_filter(outline) + p['blink'] = bool(el.get('blink', False)) + + elif t == 'pixel': + p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})" + p['blink'] = bool(el.get('blink', False)) + + elif t == 'rounded_rectangle': + x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + w = el.get('width', 24) + h = el.get('height', 10) + p['x_expr'] = x_expr + p['y_expr'] = y_expr + p['x2_expr'] = f"({x_expr}) + {w}" + p['y2_expr'] = f"({y_expr}) + {h}" + p['border_radius'] = int(el.get('borderRadius', 3)) + fill = ( + [el.get('fillR', 0), el.get('fillG', 80), el.get('fillB', 180)] + if el.get('hasFill', True) else None + ) + outline = ( + [el.get('outR', 120), el.get('outG', 180), el.get('outB', 255)] + if el.get('hasOutline', True) else None + ) + p['fill_tuple'] = _as_fill_filter(fill) + p['outline_tuple'] = _as_fill_filter(outline) + p['blink'] = bool(el.get('blink', False)) + + elif t == 'countdown': + font_key = el.get('font', 'four_by_six') + p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font') + p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 180)}, {el.get('b', 0)})" + binding = el.get('binding', {}) + p['binding_key'] = binding.get('key', '') + p['countdown_format'] = el.get('countdownFormat', 'dh') + x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + p['x_expr'] = x_base_expr + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['blink'] = bool(el.get('blink', False)) + + elif t == 'pips': + p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['pip_count'] = max(1, int(el.get('count', 5))) + p['pip_size'] = max(1, int(el.get('pipSize', 4))) + p['pip_spacing'] = max(0, int(el.get('pipSpacing', 2))) + p['show_empty'] = bool(el.get('showEmpty', True)) + binding = el.get('binding', {}) + p['binding_key'] = binding.get('key', '') + p['fill_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})" + p['empty_tuple'] = f"({el.get('emptyR', 50)}, {el.get('emptyG', 50)}, {el.get('emptyB', 50)})" + p['blink'] = bool(el.get('blink', False)) + + elif t == 'sparkline': + x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['x_expr'] = x_expr + p['y_expr'] = y_expr + p['bar_width_px'] = int(el.get('width', 40)) + p['bar_height_px'] = int(el.get('height', 12)) + p['bar_count'] = max(1, int(el.get('barCount', 8))) + p['bar_spacing'] = max(0, int(el.get('barSpacing', 1))) + binding = el.get('binding', {}) + p['binding_key'] = binding.get('key', '') + p['fill_tuple'] = f"({el.get('r', 80)}, {el.get('g', 200)}, {el.get('b', 120)})" + bg = [el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)] if el.get('hasBg', False) else None + p['bg_tuple'] = _as_fill_filter(bg) + p['blink'] = bool(el.get('blink', False)) + + elif t == 'gauge': + x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width') + y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + w = el.get('width', 32) + h = el.get('height', 32) + p['x_expr'] = x_expr + p['y_expr'] = y_expr + p['x2_expr'] = f"({x_expr}) + {w}" + p['y2_expr'] = f"({y_expr}) + {h}" + p['start_angle'] = int(el.get('startAngle', 135)) + p['end_angle'] = int(el.get('endAngle', 45)) + p['line_width'] = max(1, int(el.get('lineWidth', 3))) + p['rgb_tuple'] = f"({el.get('r', 80)}, {el.get('g', 220)}, {el.get('b', 80)})" + track = ( + [el.get('trackR', 40), el.get('trackG', 40), el.get('trackB', 40)] + if el.get('hasTrack', True) else None + ) + p['track_tuple'] = _as_fill_filter(track) + binding = el.get('binding', {}) + p['binding_key'] = binding.get('key', '') + font_key = el.get('font', 'four_by_six') + p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font') + p['show_label'] = bool(el.get('showLabel', True)) + p['label_tuple'] = f"({el.get('labelR', 200)}, {el.get('labelG', 200)}, {el.get('labelB', 200)})" + p['blink'] = bool(el.get('blink', False)) + + elif t == 'marquee': + font_key = el.get('font', 'press_start') + p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font') + p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})" + p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height') + p['text'] = el.get('text', 'Scrolling text') + p['char_w'] = _FONT_CHAR_W.get(font_key, 8) + p['gap'] = int(el.get('gap', 16)) + p['scroll_speed'] = max(1, int(el.get('scrollSpeed', 1))) + p['direction'] = el.get('direction', 'left') + # Data key stored in self._data for stateful scrolling across display() calls + raw_id = str(el.get('id', 0)).replace('-', '_') + p['data_key'] = f"mq_{raw_id}" + p['blink'] = bool(el.get('blink', False)) + + result.append(p) + return result + + +def _generate_plugin_files(data: dict) -> dict: + """ + Generate all plugin file contents as strings. + + Returns dict: {'manager.py', 'manifest.json', 'config_schema.json', 'requirements.txt'} + Raises ValueError with a human-readable message on any validation failure. + """ + metadata = data.get('metadata', {}) + elements = data.get('elements', []) + data_model = data.get('dataModel', {}) + config_vars = data_model.get('configVars', []) + + plugin_id = metadata.get('id', '').strip() + if not _PLUGIN_ID_RE.match(plugin_id): + raise ValueError( + 'Plugin ID must start with a lowercase letter and contain only ' + 'lowercase letters, numbers, and hyphens (max 63 chars).' + ) + + plugin_name = metadata.get('name', '').strip() + if not plugin_name: + raise ValueError('Plugin name is required.') + + author = metadata.get('author', '').strip() + if not author: + raise ValueError('Author is required.') + + version = metadata.get('version', '1.0.0').strip() + + # Validate config var keys are valid Python identifiers + for cv in config_vars: + key = cv.get('key', '') + if not _PYTHON_IDENT_RE.match(key): + raise ValueError(f'Config variable key "{key}" is not a valid Python identifier.') + + class_name = _to_class_name(plugin_name) + # Only consider visible elements for code generation flags + visible_elements = [e for e in elements if e.get('visible') is not False] + processed = _preprocess_elements(elements) + has_clock = any(e.get('type') == 'clock' for e in visible_elements) + has_blink = any(e.get('blink') for e in visible_elements) + has_countdown = any(e.get('type') == 'countdown' for e in visible_elements) + _var_re = re.compile(r'\{[a-zA-Z_]\w*\}') + has_text_template = any( + e.get('type') == 'text' and ( + _var_re.search(e.get('text', '') or '') or + _var_re.search(e.get('text2', '') or '') + ) + for e in visible_elements + ) + + # Background fill color (None → don't render, use LED panel's native black) + bg_color: str | None = None + bg_raw = metadata.get('bgColor') + if isinstance(bg_raw, dict): + r, g, b = int(bg_raw.get('r', 0)), int(bg_raw.get('g', 0)), int(bg_raw.get('b', 0)) + if r or g or b: + bg_color = f'({r}, {g}, {b})' + + # Render manager.py + env = _get_jinja_env() + try: + tmpl = env.get_template('manager.py.j2') + except jinja2.TemplateNotFound: + raise ValueError('Code generation template not found. This is a server configuration issue.') + + manager_py = tmpl.render( + plugin_name=plugin_name, + class_name=class_name, + plugin_id=plugin_id, + generated_date=datetime.now().strftime('%Y-%m-%d'), + config_vars=config_vars, + elements=processed, + has_clock=has_clock, + has_blink=has_blink, + has_countdown=has_countdown, + has_text_template=has_text_template, + bg_color=bg_color, + ) + + # Syntax-check the generated Python + try: + ast.parse(manager_py) + except SyntaxError as exc: + raise ValueError(f'Generated code has a syntax error: {exc}') from exc + + # Build manifest + manifest = { + 'id': plugin_id, + 'name': plugin_name, + 'version': version, + 'author': author, + 'description': metadata.get('description', 'Custom plugin created with LEDMatrix Plugin Composer'), + 'category': metadata.get('category', 'custom'), + 'tags': ['composer', 'custom'], + 'entry_point': 'manager.py', + 'class_name': class_name, + 'display_modes': [plugin_id], + 'compatible_versions': ['>=2.0.0'], + 'last_updated': datetime.now().strftime('%Y-%m-%d'), + 'update_interval': int(metadata.get('update_interval', 60)), + 'default_duration': float(metadata.get('display_duration', 15)), + 'versions': [ + {'released': datetime.now().strftime('%Y-%m-%d'), 'version': version} + ], + } + + # Validate manifest against the project's schema + if composer_bp.project_root: + schema_path = Path(composer_bp.project_root) / 'schema' / 'manifest_schema.json' + if schema_path.exists(): + schema = json.loads(schema_path.read_text()) + validator = jsonschema.Draft7Validator(schema) + errors = list(validator.iter_errors(manifest)) + if errors: + msgs = '; '.join(e.message for e in errors[:3]) + raise ValueError(f'Manifest validation failed: {msgs}') + + # Build config_schema + type_map = { + 'string': {'type': 'string'}, + 'number': {'type': 'number', 'minimum': 0}, + 'boolean': {'type': 'boolean'}, + 'color': { + 'type': 'array', + 'items': {'type': 'integer', 'minimum': 0, 'maximum': 255}, + 'minItems': 3, + 'maxItems': 3, + }, + } + + config_properties = { + 'enabled': {'type': 'boolean', 'default': True}, + 'display_duration': {'type': 'number', 'minimum': 1, 'default': float(metadata.get('display_duration', 15))}, + } + for cv in config_vars: + cv_type = cv.get('type', 'string') + prop = dict(type_map.get(cv_type, {'type': 'string'})) + if cv.get('description'): + prop['description'] = cv['description'] + if cv.get('label'): + prop['title'] = cv['label'] + default = cv.get('default', '') + if cv_type == 'number': + try: + prop['default'] = float(default) if default != '' else 0 + except (TypeError, ValueError): + prop['default'] = 0 + elif cv_type == 'boolean': + prop['default'] = bool(default) + else: + prop['default'] = default + config_properties[cv['key']] = prop + + config_schema = { + '$schema': 'http://json-schema.org/draft-07/schema#', + 'type': 'object', + 'properties': config_properties, + } + + return { + 'manager.py': manager_py, + 'manifest.json': json.dumps(manifest, indent=2), + 'config_schema.json': json.dumps(config_schema, indent=2), + 'requirements.txt': '', + } + + +def _save_composer_state(target_dir: Path, payload: dict) -> None: + """Persist the raw composer payload alongside the generated plugin files.""" + (target_dir / '_composer_state.json').write_text( + json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8' + ) + + +def _pack_zip(files: dict, plugin_id: str) -> io.BytesIO: + """Pack generated plugin files into an in-memory ZIP.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_DEFLATED) as zf: + for filename, content in files.items(): + info = zipfile.ZipInfo(f'{plugin_id}/{filename}') + info.compress_type = zipfile.ZIP_DEFLATED + zf.writestr(info, content.encode('utf-8') if isinstance(content, str) else content) + buf.seek(0) + return buf + + +# ── Routes ──────────────────────────────────────────────────────────────────── + +@composer_bp.route('/') +def index(): + return render_template('v3/composer.html') + + +@composer_bp.route('/api/generate', methods=['POST']) +def generate_zip(): + data = request.get_json(force=True, silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No JSON body'}), 400 + try: + files = _generate_plugin_files(data) + except ValueError as exc: + return jsonify({'status': 'error', 'message': str(exc)}), 422 + + plugin_id = data.get('metadata', {}).get('id', 'plugin') + files['_composer_state.json'] = json.dumps(data, indent=2, ensure_ascii=False) + zip_buf = _pack_zip(files, plugin_id) + return send_file( + zip_buf, + mimetype='application/zip', + as_attachment=True, + download_name=f'{plugin_id}.zip', + ) + + +@composer_bp.route('/api/install', methods=['POST']) +def install_locally(): + if not composer_bp.plugins_dir: + return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503 + + data = request.get_json(force=True, silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No JSON body'}), 400 + + try: + files = _generate_plugin_files(data) + except ValueError as exc: + return jsonify({'status': 'error', 'message': str(exc)}), 422 + + plugin_id = data.get('metadata', {}).get('id', '') + # _generate_plugin_files() above already validates metadata.id via this + # same regex before it will return, but that guarantee lives in a + # different function -- re-check here, at the point the path is actually + # built, so this route stays safe on its own if that call is ever + # reordered or changed. + if not _PLUGIN_ID_RE.match(plugin_id): + return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400 + target = Path(composer_bp.plugins_dir) / plugin_id + force = bool(data.get('_force', False)) + + if target.exists() and not force: + return jsonify({ + 'status': 'conflict', + 'message': f'Plugin "{plugin_id}" is already installed.', + }), 409 + + try: + if target.exists() and force: + import shutil as _shutil + _shutil.rmtree(target) + target.mkdir(parents=True, exist_ok=False) + for filename, content in files.items(): + (target / filename).write_text(content, encoding='utf-8') + _save_composer_state(target, data) + except OSError as exc: + logger.error('Failed to write plugin files for %s: %s', plugin_id, exc) + return jsonify({'status': 'error', 'message': 'Failed to write plugin files'}), 500 + + # Trigger plugin discovery so it shows up in the Plugin Manager immediately + if composer_bp.plugin_manager: + try: + composer_bp.plugin_manager.discover_plugins() + except Exception as exc: + logger.warning('discover_plugins() failed after composer install: %s', exc) + + return jsonify({ + 'status': 'success', + 'message': f'Plugin "{plugin_id}" installed successfully.', + 'plugin_id': plugin_id, + }) + + +@composer_bp.route('/api/fonts/') +def serve_font(font_name): + """Serve an allowlisted font file for canvas FontFace loading.""" + if font_name not in _ALLOWED_FONTS: + return '', 404 + if not composer_bp.project_root: + return '', 503 + font_path = Path(composer_bp.project_root) / 'assets' / 'fonts' / font_name + if not font_path.exists(): + return '', 404 + return send_file(str(font_path), mimetype='font/ttf') + + +@composer_bp.route('/api/validate-id/') +def validate_id(plugin_id): + """Check whether a plugin ID is valid and available.""" + if not _PLUGIN_ID_RE.match(plugin_id): + return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'}) + if composer_bp.plugins_dir: + taken = (Path(composer_bp.plugins_dir) / plugin_id).exists() + if taken: + return jsonify({'valid': True, 'available': False, 'reason': 'Already installed'}) + return jsonify({'valid': True, 'available': True}) + + +@composer_bp.route('/api/plugins') +def list_plugins(): + """List installed plugins, flagging which ones have a saved composer state.""" + if not composer_bp.plugins_dir: + return jsonify([]) + plugins_dir = Path(composer_bp.plugins_dir) + results = [] + for entry in sorted(plugins_dir.iterdir()): + if not entry.is_dir(): + continue + manifest_path = entry / 'manifest.json' + if not manifest_path.exists(): + continue + try: + manifest = json.loads(manifest_path.read_text()) + except Exception as e: + logger.warning("Skipping %s: unreadable manifest.json (%s)", entry.name, e) + continue + has_state = (entry / '_composer_state.json').exists() + results.append({ + 'id': manifest.get('id', entry.name), + 'name': manifest.get('name', entry.name), + 'version': manifest.get('version', ''), + 'author': manifest.get('author', ''), + 'has_composer_state': has_state, + }) + return jsonify(results) + + +@composer_bp.route('/api/preview', methods=['POST']) +def preview_code(): + """Generate plugin files and return them as JSON for the code preview modal.""" + data = request.get_json(force=True, silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No JSON body'}), 400 + try: + files = _generate_plugin_files(data) + except ValueError as exc: + return jsonify({'status': 'error', 'message': str(exc)}), 422 + return jsonify({ + 'status': 'ok', + 'files': { + 'manager.py': files['manager.py'], + 'manifest.json': files['manifest.json'], + 'config_schema.json': files['config_schema.json'], + }, + }) + + +@composer_bp.route('/api/load/') +def load_plugin(plugin_id): + """Load a plugin's composer state for editing. + + If a _composer_state.json exists, return it verbatim. + Otherwise, extract config vars from config_schema.json for a partial import. + """ + if not composer_bp.plugins_dir: + return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503 + if not _PLUGIN_ID_RE.match(plugin_id): + return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400 + + plugin_dir = Path(composer_bp.plugins_dir) / plugin_id + if not plugin_dir.exists(): + return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 + + # Full composer state + state_path = plugin_dir / '_composer_state.json' + if state_path.exists(): + try: + state = json.loads(state_path.read_text()) + return jsonify({'status': 'ok', 'source': 'composer', 'state': state}) + except Exception as exc: + logger.error('Failed to read composer state for %s: %s', plugin_id, exc) + return jsonify({'status': 'error', 'message': 'Failed to read state'}), 500 + + # Partial import from config_schema.json + schema_path = plugin_dir / 'config_schema.json' + manifest_path = plugin_dir / 'manifest.json' + config_vars = [] + + if schema_path.exists(): + try: + schema = json.loads(schema_path.read_text()) + props = schema.get('properties', {}) + skip = {'enabled', 'display_duration', 'update_interval'} + type_map = {'boolean': 'boolean', 'number': 'number', 'integer': 'number', 'string': 'string'} + for key, prop in props.items(): + if key in skip: + continue + prop_type = prop.get('type', 'string') + if isinstance(prop_type, list): + prop_type = next((t for t in prop_type if t != 'null'), 'string') + # Detect color arrays + if prop_type == 'array' and prop.get('maxItems') == 3: + cv_type = 'color' + else: + cv_type = type_map.get(prop_type, 'string') + config_vars.append({ + 'key': key, + 'label': prop.get('title', key.replace('_', ' ').title()), + 'type': cv_type, + 'default': prop.get('default', ''), + 'description': prop.get('description', ''), + }) + except Exception as e: + logger.warning("Failed to parse config_schema.json for %s: %s", plugin_id, e) + + manifest = {} + if manifest_path.exists(): + try: + manifest = json.loads(manifest_path.read_text()) + except Exception: + pass + + partial_state = { + 'composer_version': '1.0', + 'metadata': { + 'id': manifest.get('id', plugin_id), + 'name': manifest.get('name', plugin_id), + 'author': manifest.get('author', ''), + 'version': manifest.get('version', '1.0.0'), + 'description': manifest.get('description', ''), + 'category': manifest.get('category', 'custom'), + 'display_duration': manifest.get('default_duration', 15), + 'update_interval': manifest.get('update_interval', 60), + 'api_requirements': manifest.get('api_requirements', []), + }, + 'elements': [], + 'dataModel': {'configVars': config_vars, 'dataSources': [], 'computedVars': []}, + } + return jsonify({'status': 'ok', 'source': 'schema_import', 'state': partial_state}) diff --git a/web_interface/static/v3/js/composer/composer-app.js b/web_interface/static/v3/js/composer/composer-app.js new file mode 100644 index 00000000..f0124371 --- /dev/null +++ b/web_interface/static/v3/js/composer/composer-app.js @@ -0,0 +1,1337 @@ +/** + * composerApp() — AlpineJS component for the Plugin Composer. + * + * Key architecture notes: + * - All mutations call _snapshot() for undo/redo support + * - Elements with anchors: drag stores offset from anchor, not raw px + * - localStorage autosaves on every mutation (debounced 1.5s) + * - composer_version in payload allows future server-side migration + */ + +// ── Template library ───────────────────────────────────────────────────────── +const COMPOSER_TEMPLATES = [ + { + id: 'blank', + label: 'Blank Canvas', + description: 'Start from scratch', + icon: 'fas fa-plus-square', + preset: '128×32', + elements: [], + dataModel: { configVars: [], dataSources: [], computedVars: [] }, + }, + { + id: 'clock', + label: 'Digital Clock', + description: 'Large clock with date below a divider', + icon: 'fas fa-clock', + preset: '128×32', + elements: [ + { id:1, type:'clock', x:44, y:4, format:'%H:%M', font:'press_start', r:100, g:255, b:100, xAnchor:null, yAnchor:null, minWidth:0, label:'time', conditions:[] }, + { id:2, type:'divider', x:0, y:18, orientation:'horizontal', r:50, g:50, b:50, xAnchor:null, yAnchor:null, minWidth:0, label:'', conditions:[] }, + { id:3, type:'clock', x:32, y:22, format:'%A %b %d', font:'four_by_six', r:150, g:150, b:150, xAnchor:null, yAnchor:null, minWidth:0, label:'date', conditions:[] }, + ], + dataModel: { configVars:[], dataSources:[], computedVars:[] }, + }, + { + id: 'announcement', + label: 'Announcement', + description: 'Static title + configurable message text', + icon: 'fas fa-bullhorn', + preset: '128×32', + elements: [ + { id:1, type:'text', x:4, y:2, text:'ANNOUNCEMENT', font:'four_by_six', r:255, g:200, b:0, xAnchor:null, yAnchor:null, minWidth:0, label:'title', conditions:[] }, + { id:2, type:'divider', x:0, y:11, orientation:'horizontal', r:80, g:80, b:0, xAnchor:null, yAnchor:null, minWidth:0, label:'', conditions:[] }, + { id:3, type:'dynamic_text', x:4, y:15, binding:{source:'config',key:'message',format:null}, font:'four_by_six', r:255, g:255, b:255, xAnchor:null, yAnchor:null, minWidth:0, label:'message', conditions:[] }, + ], + dataModel: { + configVars:[{ key:'message', label:'Message', type:'string', default:'Hello World!', description:'Message to display' }], + dataSources:[], computedVars:[], + }, + }, + { + id: 'scoreboard', + label: 'Scoreboard', + description: 'Two-team scores with status line', + icon: 'fas fa-trophy', + preset: '128×32', + elements: [ + { id:1, type:'dynamic_text', x:2, y:2, binding:{source:'config',key:'home_team',format:null}, font:'four_by_six', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'home team', conditions:[] }, + { id:2, type:'dynamic_text', x:2, y:12, binding:{source:'config',key:'home_score',format:null}, font:'press_start', r:255,g:220,b:50, xAnchor:null,yAnchor:null,minWidth:0, label:'home score', conditions:[] }, + { id:3, type:'divider', x:64, y:0, orientation:'vertical', r:60,g:60,b:60, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, + { id:4, type:'dynamic_text', x:68, y:2, binding:{source:'config',key:'away_team',format:null}, font:'four_by_six', r:200,g:200,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'away team', conditions:[] }, + { id:5, type:'dynamic_text', x:68, y:12, binding:{source:'config',key:'away_score',format:null}, font:'press_start', r:100,g:180,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'away score', conditions:[] }, + { id:6, type:'dynamic_text', x:44, y:25, binding:{source:'config',key:'game_status',format:null}, font:'four_by_six', r:150,g:150,b:150, xAnchor:null,yAnchor:null,minWidth:0, label:'status', conditions:[] }, + ], + dataModel: { + configVars:[ + {key:'home_team', label:'Home Team', type:'string', default:'HOME', description:'Home team abbreviation'}, + {key:'home_score', label:'Home Score', type:'string', default:'0', description:'Home team score'}, + {key:'away_team', label:'Away Team', type:'string', default:'AWAY', description:'Away team abbreviation'}, + {key:'away_score', label:'Away Score', type:'string', default:'0', description:'Away team score'}, + {key:'game_status',label:'Game Status', type:'string', default:'LIVE', description:'Status label'}, + ], + dataSources:[], computedVars:[], + }, + }, + { + id: 'weather', + label: 'Weather Card', + description: 'Temperature + condition + location', + icon: 'fas fa-cloud-sun', + preset: '128×32', + elements: [ + { id:1, type:'dynamic_text', x:2, y:4, binding:{source:'config',key:'temperature',format:null}, font:'press_start', r:255,g:140,b:40, xAnchor:null,yAnchor:null,minWidth:0, label:'temp', conditions:[] }, + { id:2, type:'divider', x:0, y:16, orientation:'horizontal', r:50,g:50,b:80, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, + { id:3, type:'dynamic_text', x:2, y:20, binding:{source:'config',key:'condition',format:null}, font:'four_by_six', r:180,g:200,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'condition', conditions:[] }, + { id:4, type:'dynamic_text', x:80, y:20, binding:{source:'config',key:'location',format:null}, font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'location', conditions:[] }, + ], + dataModel: { + configVars:[ + {key:'temperature', label:'Temperature', type:'string', default:'72°F', description:'Current temperature'}, + {key:'condition', label:'Condition', type:'string', default:'Sunny', description:'Weather condition'}, + {key:'location', label:'Location', type:'string', default:'My City', description:'City name'}, + ], + dataSources:[], computedVars:[], + }, + }, + { + id: 'crypto', + label: 'Crypto Ticker', + description: 'Asset price + 24h change percentage', + icon: 'fas fa-chart-line', + preset: '128×32', + elements: [ + { id:1, type:'dynamic_text', x:2, y:2, binding:{source:'config',key:'ticker',format:null}, font:'press_start', r:255,g:165,b:0, xAnchor:null,yAnchor:null,minWidth:0, label:'ticker', conditions:[] }, + { id:2, type:'dynamic_text', x:2, y:16, binding:{source:'config',key:'price',format:null}, font:'press_start', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'price', conditions:[] }, + { id:3, type:'dynamic_text', x:88, y:16, binding:{source:'config',key:'change_pct',format:null}, font:'four_by_six', r:100,g:255,b:100, xAnchor:null,yAnchor:null,minWidth:0, label:'change', conditions:[] }, + ], + dataModel: { + configVars:[ + {key:'ticker', label:'Ticker', type:'string', default:'BTC', description:'Asset symbol'}, + {key:'price', label:'Price', type:'string', default:'$0.00', description:'Asset price'}, + {key:'change_pct', label:'Change %', type:'string', default:'+0.00%', description:'24h change'}, + ], + dataSources:[], computedVars:[], + }, + }, + { + id: 'system', + label: 'System Monitor', + description: 'CPU, memory, and IP address', + icon: 'fas fa-server', + preset: '128×32', + elements: [ + { id:1, type:'text', x:2, y:2, text:'CPU', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, + { id:2, type:'dynamic_text', x:22, y:2, binding:{source:'config',key:'cpu_usage',format:null}, font:'four_by_six', r:100,g:255,b:100, xAnchor:null,yAnchor:null,minWidth:0, label:'cpu', conditions:[] }, + { id:3, type:'text', x:2, y:12, text:'MEM', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, + { id:4, type:'dynamic_text', x:22, y:12, binding:{source:'config',key:'mem_usage',format:null}, font:'four_by_six', r:100,g:180,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'mem', conditions:[] }, + { id:5, type:'text', x:2, y:22, text:'IP', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, + { id:6, type:'dynamic_text', x:14, y:22, binding:{source:'config',key:'ip_address',format:null}, font:'four_by_six', r:255,g:255,b:150, xAnchor:null,yAnchor:null,minWidth:0, label:'ip', conditions:[] }, + ], + dataModel: { + configVars:[ + {key:'cpu_usage', label:'CPU %', type:'string', default:'0%', description:'CPU usage'}, + {key:'mem_usage', label:'Memory %', type:'string', default:'0%', description:'Memory usage'}, + {key:'ip_address', label:'IP Address',type:'string', default:'0.0.0.0', description:'Network IP'}, + ], + dataSources:[], computedVars:[], + }, + }, + { + id: 'split_panel', + label: 'Split Panel', + description: 'Left label + right value, divided', + icon: 'fas fa-columns', + preset: '128×32', + elements: [ + { id:1, type:'dynamic_text', x:2, y:4, binding:{source:'config',key:'left_label',format:null}, font:'four_by_six', r:180,g:180,b:180, xAnchor:null,yAnchor:null,minWidth:0, label:'label', conditions:[] }, + { id:2, type:'divider', x:64, y:0, orientation:'vertical', r:60,g:60,b:60, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] }, + { id:3, type:'dynamic_text', x:68, y:4, binding:{source:'config',key:'right_value',format:null}, font:'press_start', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'value', conditions:[] }, + ], + dataModel: { + configVars:[ + {key:'left_label', label:'Label', type:'string', default:'LABEL', description:'Left panel label'}, + {key:'right_value', label:'Value', type:'string', default:'--', description:'Right panel value'}, + ], + dataSources:[], computedVars:[], + }, + }, +]; + +// ── LED display color palette ──────────────────────────────────────────────── +const LED_PALETTE = [ + { label: 'White', hex: '#ffffff', r: 255, g: 255, b: 255 }, + { label: 'Red', hex: '#ff2020', r: 255, g: 32, b: 32 }, + { label: 'Green', hex: '#20ff40', r: 32, g: 255, b: 64 }, + { label: 'Blue', hex: '#4060ff', r: 64, g: 96, b: 255 }, + { label: 'Yellow', hex: '#ffee00', r: 255, g: 238, b: 0 }, + { label: 'Orange', hex: '#ff8000', r: 255, g: 128, b: 0 }, + { label: 'Cyan', hex: '#00eeff', r: 0, g: 238, b: 255 }, + { label: 'Magenta', hex: '#ff00cc', r: 255, g: 0, b: 204 }, + { label: 'Amber', hex: '#ffb400', r: 255, g: 180, b: 0 }, + { label: 'Dim white', hex: '#888888', r: 136, g: 136, b: 136 }, + { label: 'Dark gray', hex: '#444444', r: 68, g: 68, b: 68 }, + { label: 'Off/Black', hex: '#000000', r: 0, g: 0, b: 0 }, +]; + +// ── Autosave helpers ───────────────────────────────────────────────────────── +const LS_KEY = 'ledmatrix_composer_draft'; +let _autosaveTimer = null; + +function _debouncedAutosave(payload) { + clearTimeout(_autosaveTimer); + _autosaveTimer = setTimeout(() => { + try { localStorage.setItem(LS_KEY, JSON.stringify(payload)); } catch (_) {} + }, 1500); +} + +// ── Main component ─────────────────────────────────────────────────────────── +function composerApp() { + return { + // ── Plugin metadata ─────────────────────────────────────────────── + metadata: { + id: '', + name: '', + author: '', + version: '1.0.0', + description: '', + category: 'custom', + display_duration: 15, + update_interval: 60, + api_requirements: [], + bgColor: { r: 0, g: 0, b: 0 }, + }, + + // ── Canvas / display preset ─────────────────────────────────────── + SCALE: 4, + MATRIX_W: 128, + MATRIX_H: 32, + currentPreset: '128×32', + + // ── Elements ────────────────────────────────────────────────────── + elements: [], + _nextId: 1, + + // ── Data model ─────────────────────────────────────────────────── + dataModel: { + configVars: [], + dataSources: [], + computedVars: [], + }, + + // ── Undo/redo ───────────────────────────────────────────────────── + _history: [], + _historyIndex: -1, + + // ── Selection & interaction ─────────────────────────────────────── + selectedId: null, + _drag: { active: false }, + _resize: { active: false }, + + // ── Canvas options ──────────────────────────────────────────────── + snapToGrid: false, + snapSize: 4, + + // ── Clipboard ───────────────────────────────────────────────────── + _clipboard: null, + _styleClipboard: null, + + // ── UI state ────────────────────────────────────────────────────── + isDirty: false, + statusMsg: '', + statusType: 'info', + generateStatus: 'idle', + installStatus: 'idle', + showGridOverlay: true, + showGuides: false, + blinkAnimating: false, + _blinkPhase: false, + _blinkTimer: null, + _animTick: 0, + _previewValues: {}, + showHelpModal: false, + _recentColors: [], + showRuler: false, + showConfigVarModal: false, + showOpenModal: false, + showTemplateModal: false, + showCodeModal: false, + _idAutoGenerated: true, + hoverInfo: '', + installedPlugins: [], + loadPluginsStatus: 'idle', + codeFiles: {}, + codeTab: 'manager.py', + loadingCode: false, + newConfigVar: { key: '', label: '', type: 'string', default: '', description: '' }, + + // ── Computed ────────────────────────────────────────────────────── + get selectedElement() { + return this.elements.find(e => e.id === this.selectedId) ?? null; + }, + get canExport() { + return this.metadata.name.trim() && this.metadata.id.trim() && this.elements.length > 0; + }, + get canUndo() { return this._historyIndex > 0; }, + get canRedo() { return this._historyIndex < this._history.length - 1; }, + get templates() { return COMPOSER_TEMPLATES; }, + get displayPresets() { return window.ComposerCanvas.DISPLAY_PRESETS; }, + get palette() { return LED_PALETTE; }, + + // ── Pre-made variable library ───────────────────────────────────── + preMadeVarCategories: [ + { label: 'Display', vars: [ + {key:'title_text', label:'Title', type:'string', default:'My Plugin', description:'Main title'}, + {key:'subtitle', label:'Subtitle', type:'string', default:'', description:'Secondary line'}, + {key:'message', label:'Message', type:'string', default:'Hello!', description:'Dynamic message'}, + ]}, + { label: 'Sports / Score', vars: [ + {key:'home_score', label:'Home Score', type:'string', default:'0', description:'Home team score'}, + {key:'away_score', label:'Away Score', type:'string', default:'0', description:'Away team score'}, + {key:'home_team', label:'Home Team', type:'string', default:'HOME', description:'Home team abbr'}, + {key:'away_team', label:'Away Team', type:'string', default:'AWAY', description:'Away team abbr'}, + {key:'game_status', label:'Game Status', type:'string', default:'LIVE', description:'Status label'}, + {key:'period', label:'Period', type:'string', default:'1st', description:'Period/quarter'}, + {key:'time_left', label:'Time Left', type:'string', default:'15:00',description:'Game clock'}, + ]}, + { label: 'Weather', vars: [ + {key:'temperature', label:'Temperature', type:'string', default:'72°F', description:'Current temperature'}, + {key:'condition', label:'Condition', type:'string', default:'Sunny', description:'Weather condition'}, + {key:'humidity', label:'Humidity', type:'string', default:'50%', description:'Humidity'}, + {key:'location', label:'Location', type:'string', default:'My City',description:'Location name'}, + ]}, + { label: 'System / Stats', vars: [ + {key:'cpu_usage', label:'CPU %', type:'string', default:'0%', description:'CPU usage'}, + {key:'mem_usage', label:'Memory %', type:'string', default:'0%', description:'Memory usage'}, + {key:'ip_address', label:'IP Address',type:'string', default:'0.0.0.0', description:'Network IP'}, + {key:'uptime', label:'Uptime', type:'string', default:'0d 0h', description:'System uptime'}, + ]}, + { label: 'Crypto / Finance', vars: [ + {key:'ticker', label:'Ticker', type:'string', default:'BTC', description:'Asset symbol'}, + {key:'price', label:'Price', type:'string', default:'$0.00', description:'Asset price'}, + {key:'change_pct', label:'Change %', type:'string', default:'+0.00%', description:'24h change'}, + {key:'volume', label:'Volume', type:'string', default:'0', description:'Trading volume'}, + ]}, + ], + + elementTypes: [ + { id: 'text', label: 'Text', icon: 'fas fa-font' }, + { id: 'dynamic_text', label: 'Dynamic Text', icon: 'fas fa-code' }, + { id: 'clock', label: 'Clock', icon: 'fas fa-clock' }, + { id: 'rectangle', label: 'Rectangle', icon: 'fas fa-square' }, + { id: 'rounded_rectangle', label: 'Rounded Rect', icon: 'fas fa-stop' }, + { id: 'ellipse', label: 'Ellipse', icon: 'fas fa-circle' }, + { id: 'arc', label: 'Arc', icon: 'fas fa-circle-notch' }, + { id: 'pixel', label: 'Pixel', icon: 'fas fa-dot-circle' }, + { id: 'line', label: 'Line', icon: 'fas fa-minus' }, + { id: 'divider', label: 'Divider', icon: 'fas fa-grip-lines' }, + { id: 'progress_bar', label: 'Progress Bar', icon: 'fas fa-tasks' }, + { id: 'countdown', label: 'Countdown', icon: 'fas fa-hourglass-half' }, + { id: 'marquee', label: 'Marquee', icon: 'fas fa-arrows-alt-h' }, + { id: 'gauge', label: 'Gauge', icon: 'fas fa-tachometer-alt' }, + { id: 'sparkline', label: 'Sparkline', icon: 'fas fa-chart-bar' }, + { id: 'pips', label: 'Pips / Rating', icon: 'fas fa-star-half-alt' }, + { id: 'section', label: 'Section Label', icon: 'fas fa-tag' }, + ], + + COLOR_THEMES: [ + { label: 'Matrix', colors: [{ r:0,g:255,b:70 }, { r:0,g:180,b:50 }, { r:0,g:100,b:30 }] }, + { label: 'Neon', colors: [{ r:255,g:0,b:200 }, { r:0,g:240,b:255 }, { r:255,g:230,b:0 }] }, + { label: 'Warm', colors: [{ r:255,g:160,b:0 }, { r:255,g:80,b:0 }, { r:255,g:220,b:80 }] }, + { label: 'Arctic', colors: [{ r:100,g:200,b:255},{ r:150,g:230,b:255},{ r:200,g:240,b:255}] }, + { label: 'Retro', colors: [{ r:255,g:140,b:0 }, { r:255,g:60,b:60 }, { r:80,g:200,b:120 }] }, + { label: 'Mono', colors: [{ r:255,g:255,b:255},{ r:170,g:170,b:170},{ r:90,g:90,b:90 }] }, + ], + + // ── Lifecycle ───────────────────────────────────────────────────── + init() { + this.$nextTick(() => { + const canvas = document.getElementById('led-canvas'); + if (canvas) { + window.ComposerCanvas.init(canvas); + window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE); + this._loadFonts().then(() => { + this._tryRestoreDraft(); + this._loadRecentColors(); + this._snapshot(); + this.render(); + }); + } + document.addEventListener('keydown', (e) => this._onKeyDown(e)); + }); + }, + + async _loadFonts() { + try { + const ff = new FontFace('PressStart2P', 'url(/composer/api/fonts/PressStart2P-Regular.ttf)'); + document.fonts.add(await ff.load()); + } catch (e) { + console.warn('PressStart2P load failed:', e); + } + }, + + _loadRecentColors() { + try { + const raw = localStorage.getItem('ledmatrix_composer_recent_colors'); + if (raw) this._recentColors = JSON.parse(raw).slice(0, 8); + } catch (_) {} + }, + + _trackColor(r, g, b) { + this._recentColors = [ + { r, g, b }, + ...this._recentColors.filter(c => !(c.r === r && c.g === g && c.b === b)), + ].slice(0, 8); + try { localStorage.setItem('ledmatrix_composer_recent_colors', JSON.stringify(this._recentColors)); } catch (_) {} + }, + + toggleRuler() { + this.showRuler = !this.showRuler; + this.render(); + }, + + _tryRestoreDraft() { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return; + const draft = JSON.parse(raw); + if (!draft?.composer_version) return; + if (!confirm('Resume your last unsaved session?')) { + localStorage.removeItem(LS_KEY); + return; + } + this._applyState(draft); + this._setStatus('Draft restored from last session', 'info'); + } catch (_) {} + }, + + // ── Undo / Redo ─────────────────────────────────────────────────── + _snapshot() { + const state = JSON.stringify({ + metadata: this.metadata, + elements: this.elements, + dataModel: this.dataModel, + currentPreset: this.currentPreset, + }); + this._history.splice(this._historyIndex + 1); + this._history.push(state); + if (this._history.length > 50) this._history.shift(); + else this._historyIndex++; + _debouncedAutosave(this._buildPayload()); + }, + + undo() { + if (!this.canUndo) return; + this._historyIndex--; + const state = JSON.parse(this._history[this._historyIndex]); + this._applyState(state); + this._setStatus('Undo', 'info'); + this.render(); + }, + + redo() { + if (!this.canRedo) return; + this._historyIndex++; + const state = JSON.parse(this._history[this._historyIndex]); + this._applyState(state); + this._setStatus('Redo', 'info'); + this.render(); + }, + + _applyState(state) { + if (state.metadata) this.metadata = state.metadata; + if (state.elements) this.elements = state.elements.map(el => ({ + xAnchor: null, yAnchor: null, minWidth: 0, conditions: [], + ...el, + id: el.id ?? (this._nextId++), + })); + if (state.dataModel) this.dataModel = state.dataModel; + if (state.currentPreset && state.currentPreset !== this.currentPreset) { + this.changePreset(state.currentPreset, { silent: true }); + } + this._nextId = Math.max(...this.elements.map(e => e.id + 1), 1); + this.selectedId = null; + this.isDirty = true; + }, + + // ── Display preset ──────────────────────────────────────────────── + changePreset(presetLabel, opts = {}) { + const preset = window.ComposerCanvas.DISPLAY_PRESETS.find(p => p.label === presetLabel); + if (!preset) return; + this.currentPreset = presetLabel; + this.MATRIX_W = preset.w; + this.MATRIX_H = preset.h; + this.SCALE = preset.w <= 64 ? 6 : preset.w <= 128 ? 4 : 2; + const canvas = document.getElementById('led-canvas'); + if (canvas) { + window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE); + canvas.style.width = (this.MATRIX_W * this.SCALE) + 'px'; + canvas.style.height = (this.MATRIX_H * this.SCALE) + 'px'; + } + if (!opts.silent) this.render(); + }, + + // ── Rendering ───────────────────────────────────────────────────── + render(opts = {}) { + window.ComposerCanvas.render( + this.elements, this.selectedId, this.MATRIX_W, this.MATRIX_H, this.SCALE, + { bgColor: this.metadata.bgColor, showGuides: this.showGuides, showRuler: this.showRuler, blinkOff: this.blinkAnimating && this._blinkPhase, animTick: this.blinkAnimating ? this._animTick : null, previewValues: this._previewValues, ...opts }, + ); + }, + + toggleGrid() { + this.showGridOverlay = !this.showGridOverlay; + window.ComposerCanvas.setGrid(this.showGridOverlay); + this.render(); + }, + + toggleGuides() { + this.showGuides = !this.showGuides; + this.render(); + }, + + toggleBlinkPreview() { + this.blinkAnimating = !this.blinkAnimating; + if (this.blinkAnimating) { + this._blinkPhase = false; + this._animTick = 0; + let blinkTick = 0; + this._blinkTimer = setInterval(() => { + this._animTick++; + blinkTick++; + if (blinkTick >= 3) { this._blinkPhase = !this._blinkPhase; blinkTick = 0; } + this.render(); + }, 200); + } else { + clearInterval(this._blinkTimer); + this._blinkTimer = null; + this._blinkPhase = false; + this._animTick = 0; + this.render(); + } + }, + + downloadPng() { + const canvas = document.getElementById('led-canvas'); + if (!canvas) return; + const link = document.createElement('a'); + link.download = (this.metadata.id || 'composer-preview') + '.png'; + link.href = canvas.toDataURL('image/png'); + link.click(); + }, + + exportDesign() { + const payload = this._buildPayload(); + const json = JSON.stringify(payload, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.download = (this.metadata.id || 'composer-design') + '.composer.json'; + link.href = url; + link.click(); + URL.revokeObjectURL(url); + }, + + importDesign() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,.composer.json'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const data = JSON.parse(ev.target.result); + if (!data.composer_version) throw new Error('Not a composer file'); + if (this.isDirty && !confirm('Replace current design?')) return; + this._applyState({ metadata: data.metadata, elements: data.elements, dataModel: data.dataModel }); + this.isDirty = false; + this._setStatus('Design loaded', 'success'); + this.render(); + } catch (err) { + this._setStatus('Failed to load: ' + err.message, 'error'); + } + }; + reader.readAsText(file); + }; + input.click(); + }, + + // ── Zoom controls ───────────────────────────────────────────────── + zoomIn() { + if (this.SCALE >= 8) return; + this.SCALE++; + this._applyScale(); + }, + zoomOut() { + if (this.SCALE <= 1) return; + this.SCALE--; + this._applyScale(); + }, + _applyScale() { + const canvas = document.getElementById('led-canvas'); + if (!canvas) return; + window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE); + canvas.style.width = (this.MATRIX_W * this.SCALE) + 'px'; + canvas.style.height = (this.MATRIX_H * this.SCALE) + 'px'; + this.render(); + }, + setCustomSize() { + const input = prompt( + 'Enter canvas size as WxH (e.g. 96×48, 192×64):', + `${this.MATRIX_W}×${this.MATRIX_H}` + ); + if (!input) return; + const m = input.match(/(\d+)[×xX*,\s]+(\d+)/); + if (!m) { this._setStatus('Invalid size — use WxH format', 'error'); return; } + const w = Math.max(8, Math.min(512, parseInt(m[1]))); + const h = Math.max(8, Math.min(256, parseInt(m[2]))); + this.MATRIX_W = w; + this.MATRIX_H = h; + this.currentPreset = `${w}×${h}`; + this.SCALE = w <= 64 ? 6 : w <= 128 ? 4 : 2; + this._applyScale(); + this._setStatus(`Canvas set to ${w}×${h}`, 'info'); + }, + zoomFit() { + // Fit canvas to center column: subtract left (~224px) + right (~288px) panels + margins + const avW = Math.max(80, window.innerWidth - 560); + // Subtract header (44) + toolbar (~40) + layers (~180) + status (28) + padding + const avH = Math.max(40, window.innerHeight - 340); + const fitW = Math.floor(avW / this.MATRIX_W); + const fitH = Math.floor(avH / this.MATRIX_H); + this.SCALE = Math.max(1, Math.min(8, Math.min(fitW, fitH))); + this._applyScale(); + }, + + onBgColorChange(hex) { + this.metadata.bgColor = { + r: parseInt(hex.slice(1, 3), 16), + g: parseInt(hex.slice(3, 5), 16), + b: parseInt(hex.slice(5, 7), 16), + }; + this.render(); + }, + + // ── Canvas events ───────────────────────────────────────────────── + onCanvasMouseDown(event) { + const { lx, ly } = this._canvasToLed(event); + + // Priority 1: resize handle on selected rectangle (skip if locked) + if (this.selectedElement?.type === 'rectangle' && !this.selectedElement.locked) { + const handle = window.ComposerCanvas.getResizeHandle( + this.selectedElement, lx, ly, this.MATRIX_W, this.MATRIX_H + ); + if (handle) { + const el = this.selectedElement; + this._resize = { + active: true, elemId: el.id, handle, + startMX: event.clientX, startMY: event.clientY, + startW: el.width, startH: el.height, + startX: el.x, startY: el.y, + }; + return; + } + } + + // Priority 2: element hit test (reverse = top layer first) + const hit = [...this.elements].reverse().find( + el => window.ComposerCanvas.hitTest(el, lx, ly, this.MATRIX_W, this.MATRIX_H) + ); + + if (hit) { + this.selectedId = hit.id; + if (!hit.locked) { + const stored = this._getStoredPos(hit); + this._drag = { + active: true, elemId: hit.id, + startMX: event.clientX, startMY: event.clientY, + startEX: stored.x, startEY: stored.y, + }; + } + } else { + this.selectedId = null; + } + this.render(); + }, + + onCanvasMouseMove(event) { + const { lx, ly } = this._canvasToLed(event); + this.hoverInfo = `${lx}, ${ly}`; + + // Resize + if (this._resize.active) { + const dx = Math.round((event.clientX - this._resize.startMX) / this.SCALE); + const dy = Math.round((event.clientY - this._resize.startMY) / this.SCALE); + const el = this.elements.find(e => e.id === this._resize.elemId); + if (!el) return; + const h = this._resize.handle; + if (h.includes('e')) el.width = Math.max(1, this._resize.startW + dx); + if (h.includes('s')) el.height = Math.max(1, this._resize.startH + dy); + if (h.includes('w')) { + const nw = Math.max(1, this._resize.startW - dx); + el.x = this._resize.startX + (this._resize.startW - nw); + el.width = nw; + } + if (h.includes('n')) { + const nh = Math.max(1, this._resize.startH - dy); + el.y = this._resize.startY + (this._resize.startH - nh); + el.height = nh; + } + this.isDirty = true; + this.render({ showTooltip: true }); + + // Cursor + const canvas = document.getElementById('led-canvas'); + if (canvas) canvas.style.cursor = window.ComposerCanvas.getCursorForHandle(h); + return; + } + + // Move drag + if (this._drag.active) { + const dx = Math.round((event.clientX - this._drag.startMX) / this.SCALE); + const dy = Math.round((event.clientY - this._drag.startMY) / this.SCALE); + const el = this.elements.find(e => e.id === this._drag.elemId); + if (!el) return; + + if (el.type === 'line') { + const newX = Math.max(0, Math.min(this.MATRIX_W - 1, this._drag.startEX + dx)); + const newY = Math.max(0, Math.min(this.MATRIX_H - 1, this._drag.startEY + dy)); + const offX = newX - el.x0, offY = newY - el.y0; + el.x0 = newX; el.y0 = newY; + el.x1 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x1 + offX)); + el.y1 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y1 + offY)); + this._drag.startEX = el.x0; this._drag.startEY = el.y0; + this._drag.startMX = event.clientX; this._drag.startMY = event.clientY; + } else { + // Store anchor-relative offset + const newStored = { + x: this._drag.startEX + dx, + y: this._drag.startEY + dy, + }; + this._setStoredPos(el, newStored); + } + this.isDirty = true; + this.render({ showTooltip: true }); + return; + } + + // Cursor hints when hovering + const canvas = document.getElementById('led-canvas'); + if (canvas) { + let cursor = 'crosshair'; + if (this.selectedElement?.type === 'rectangle') { + const handle = window.ComposerCanvas.getResizeHandle( + this.selectedElement, lx, ly, this.MATRIX_W, this.MATRIX_H + ); + if (handle) cursor = window.ComposerCanvas.getCursorForHandle(handle); + } + if (cursor === 'crosshair') { + const hit = [...this.elements].reverse().find( + el => window.ComposerCanvas.hitTest(el, lx, ly, this.MATRIX_W, this.MATRIX_H) + ); + if (hit) cursor = 'move'; + } + canvas.style.cursor = cursor; + } + }, + + onCanvasMouseUp() { + if (this._resize.active || this._drag.active) { + this._resize = { active: false }; + this._drag = { active: false }; + this._snapshot(); + } + const canvas = document.getElementById('led-canvas'); + if (canvas) canvas.style.cursor = 'crosshair'; + }, + + onCanvasMouseLeave() { + this.hoverInfo = ''; + this.onCanvasMouseUp(); + }, + + _canvasToLed(event) { + const rect = event.target.getBoundingClientRect(); + const lx = Math.max(0, Math.min(this.MATRIX_W - 1, Math.floor((event.clientX - rect.left) / this.SCALE))); + const ly = Math.max(0, Math.min(this.MATRIX_H - 1, Math.floor((event.clientY - rect.top) / this.SCALE))); + return { lx: this._snap(lx), ly: this._snap(ly) }; + }, + + _snap(v) { + if (!this.snapToGrid || this.snapSize < 2) return v; + return Math.round(v / this.snapSize) * this.snapSize; + }, + + // Anchor-aware position storage: x/y stored as offset from anchor + _getStoredPos(el) { + return { x: el.x ?? el.x0 ?? 0, y: el.y ?? el.y0 ?? 0 }; + }, + + _setStoredPos(el, { x, y }) { + if (this.snapToGrid && this.snapSize > 0) { + x = Math.round(x / this.snapSize) * this.snapSize; + y = Math.round(y / this.snapSize) * this.snapSize; + } + const clampX = v => Math.max(-this.MATRIX_W, Math.min(this.MATRIX_W * 2, v)); + const clampY = v => Math.max(-this.MATRIX_H, Math.min(this.MATRIX_H * 2, v)); + if (el.type === 'line') { + el.x0 = clampX(x); el.y0 = clampY(y); + } else { + el.x = clampX(x); el.y = clampY(y); + } + }, + + // ── Alignment ───────────────────────────────────────────────────── + _alignElement(el, axis, mode) { + const bb = window.ComposerCanvas.getBoundingBox(el, this.MATRIX_W, this.MATRIX_H); + if (!bb) return; + if (axis === 'x') { + const newX = mode === 'start' ? 0 + : mode === 'center' ? Math.round((this.MATRIX_W - bb.w) / 2) + : this.MATRIX_W - bb.w; + // Clear x-anchor so stored x IS the absolute position + if ('xAnchor' in el) el.xAnchor = null; + el.x = Math.round(newX); + if (el.type === 'line') el.x0 = Math.round(newX); + } else { + const newY = mode === 'start' ? 0 + : mode === 'center' ? Math.round((this.MATRIX_H - bb.h) / 2) + : this.MATRIX_H - bb.h; + if ('yAnchor' in el) el.yAnchor = null; + el.y = Math.round(newY); + if (el.type === 'line') el.y0 = Math.round(newY); + } + this._snapshot(); + this.isDirty = true; + this.render(); + }, + alignLeft() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'start'); }, + alignHCenter() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'center'); }, + alignRight() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'end'); }, + alignTop() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'start'); }, + alignVCenter() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'center'); }, + alignBottom() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'end'); }, + + // ── Keyboard shortcuts ──────────────────────────────────────────── + _onKeyDown(e) { + const tag = document.activeElement?.tagName; + const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; + + // Ctrl/Cmd combos work everywhere + if (e.ctrlKey || e.metaKey) { + if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); this.undo(); return; } + if ((e.key === 'y') || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); this.redo(); return; } + if (e.key === 'd' && this.selectedId) { e.preventDefault(); this.duplicateElement(this.selectedId); return; } + if (e.key === 'c' && this.selectedId && !e.shiftKey) { e.preventDefault(); this.copyElement(); return; } + if (e.key === 'c' && this.selectedId && e.shiftKey) { e.preventDefault(); this.copyStyle(); return; } + if (e.key === 'v' && this._clipboard && !e.shiftKey) { e.preventDefault(); this.pasteElement(); return; } + if (e.key === 'v' && this._styleClipboard && e.shiftKey) { e.preventDefault(); this.pasteStyle(); return; } + if (e.key === 'a') { e.preventDefault(); if (this.elements.length) { this.selectedId = this.elements[0].id; this.render(); } return; } + } + + // Tab cycles through elements regardless of input focus + if (e.key === 'Tab' && this.elements.length) { + e.preventDefault(); + const idx = this.elements.findIndex(el => el.id === this.selectedId); + const next = e.shiftKey + ? (idx <= 0 ? this.elements.length - 1 : idx - 1) + : (idx >= this.elements.length - 1 ? 0 : idx + 1); + this.selectedId = this.elements[next].id; + this.render(); + return; + } + + if (inInput) return; + + const dist = e.shiftKey ? 5 : (this.snapToGrid && this.snapSize >= 2 ? this.snapSize : 1); + if (e.key === 'ArrowLeft') { e.preventDefault(); this.nudge(-dist, 0); } + if (e.key === 'ArrowRight') { e.preventDefault(); this.nudge(dist, 0); } + if (e.key === 'ArrowUp') { e.preventDefault(); this.nudge(0, -dist); } + if (e.key === 'ArrowDown') { e.preventDefault(); this.nudge(0, dist); } + if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedId) { + e.preventDefault(); + this.removeElement(this.selectedId); + } + if (e.key === 'Escape') { + if (this.showHelpModal) { this.showHelpModal = false; return; } + this.selectedId = null; this.render(); + } + if (e.key === '?') { this.showHelpModal = !this.showHelpModal; } + if (e.key === 'g' || e.key === 'G') { this.snapToGrid = !this.snapToGrid; } + if (e.key === ']' && this.selectedId) { this.bringToFront(this.selectedId); } + if (e.key === '[' && this.selectedId) { this.sendToBack(this.selectedId); } + }, + + nudge(dx, dy) { + const el = this.selectedElement; + if (!el) return; + if (el.locked) { this._setStatus('Unlock element to move it', 'info'); return; } + if (el.type === 'line') { + el.x0 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x0 + dx)); + el.y0 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y0 + dy)); + el.x1 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x1 + dx)); + el.y1 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y1 + dy)); + } else if (el.type === 'divider') { + if (el.orientation === 'horizontal') el.y = Math.max(0, Math.min(this.MATRIX_H - 1, (el.y ?? 0) + dy)); + else el.x = Math.max(0, Math.min(this.MATRIX_W - 1, (el.x ?? 0) + dx)); + } else { + el.x = (el.x ?? 0) + dx; + el.y = (el.y ?? 0) + dy; + } + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + // ── Element management ──────────────────────────────────────────── + addElement(type) { + const defaults = window.ComposerCanvas.ELEMENT_DEFAULTS[type]; + if (!defaults) return; + const el = { + id: this._nextId++, + type, + label: '', + conditions: [], + xAnchor: null, yAnchor: null, minWidth: 0, + x: Math.floor(this.MATRIX_W / 4), + y: Math.floor(this.MATRIX_H / 2) - 4, + ...structuredClone(defaults), + }; + if (type === 'line') { + const cx = Math.floor(this.MATRIX_W / 4), cy = Math.floor(this.MATRIX_H / 2); + el.x0 = cx; el.y0 = cy; + el.x1 = Math.min(this.MATRIX_W - 1, cx + 30); el.y1 = cy; + el.x = cx; el.y = cy; + } + this.elements.push(el); + this.selectedId = el.id; + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + duplicateElement(id) { + const el = this.elements.find(e => e.id === id); + if (!el) return; + const copy = { + ...structuredClone(el), + id: this._nextId++, + label: el.label ? el.label + ' copy' : '', + }; + // Offset slightly so it's visually distinct + if (copy.type === 'line') { copy.x0 += 4; copy.y0 += 4; copy.x1 += 4; copy.y1 += 4; } + else { copy.x = (copy.x ?? 0) + 4; copy.y = (copy.y ?? 0) + 4; } + + const idx = this.elements.findIndex(e => e.id === id); + this.elements.splice(idx + 1, 0, copy); + this.selectedId = copy.id; + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + copyElement() { + if (!this.selectedElement) return; + this._clipboard = structuredClone(this.selectedElement); + this._setStatus(`Copied: ${this.selectedElement.label || this.selectedElement.type}`, 'info'); + }, + + copyStyle() { + const el = this.selectedElement; + if (!el) return; + this._styleClipboard = { r: el.r, g: el.g, b: el.b, font: el.font }; + this._setStatus('Style copied — select another element and paste style', 'info'); + }, + + pasteStyle() { + const el = this.selectedElement; + if (!el || !this._styleClipboard) return; + const { r, g, b, font } = this._styleClipboard; + if (r !== undefined) { el.r = r; el.g = g; el.b = b; } + if (font !== undefined && el.font !== undefined) el.font = font; + this.isDirty = true; + this._snapshot(); + this.render(); + this._setStatus('Style pasted', 'success'); + }, + + pasteElement() { + if (!this._clipboard) return; + const copy = structuredClone(this._clipboard); + copy.id = this._nextId++; + if (copy.type === 'line') { copy.x0 += 6; copy.y0 += 6; copy.x1 += 6; copy.y1 += 6; } + else { copy.x = (copy.x ?? 0) + 6; copy.y = (copy.y ?? 0) + 6; } + this.elements.push(copy); + this.selectedId = copy.id; + this.isDirty = true; + this._snapshot(); + this.render(); + this._setStatus('Pasted element', 'success'); + }, + + removeElement(id) { + this.elements = this.elements.filter(e => e.id !== id); + if (this.selectedId === id) this.selectedId = null; + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + moveElementUp(id) { + const idx = this.elements.findIndex(e => e.id === id); + if (idx < this.elements.length - 1) { + [this.elements[idx], this.elements[idx + 1]] = [this.elements[idx + 1], this.elements[idx]]; + this.isDirty = true; + this._snapshot(); + this.render(); + } + }, + + moveElementDown(id) { + const idx = this.elements.findIndex(e => e.id === id); + if (idx > 0) { + [this.elements[idx], this.elements[idx - 1]] = [this.elements[idx - 1], this.elements[idx]]; + this.isDirty = true; + this._snapshot(); + this.render(); + } + }, + + bringToFront(id) { + const idx = this.elements.findIndex(e => e.id === id); + if (idx < this.elements.length - 1) { + this.elements.push(this.elements.splice(idx, 1)[0]); + this.isDirty = true; + this._snapshot(); + this.render(); + } + }, + + sendToBack(id) { + const idx = this.elements.findIndex(e => e.id === id); + if (idx > 0) { + this.elements.unshift(this.elements.splice(idx, 1)[0]); + this.isDirty = true; + this._snapshot(); + this.render(); + } + }, + + clearAll() { + if (this.elements.length === 0) return; + if (!confirm('Remove all elements from the canvas?')) return; + this.elements = []; + this.selectedId = null; + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + // ── Alignment ───────────────────────────────────────────────────── + alignElement(dir) { + const el = this.selectedElement; + if (!el) return; + const bb = window.ComposerCanvas.getBoundingBox(el, this.MATRIX_W, this.MATRIX_H); + switch (dir) { + case 'left': el.x = 0; break; + case 'center': el.x = Math.round((this.MATRIX_W - bb.w) / 2); break; + case 'right': el.x = this.MATRIX_W - bb.w; break; + case 'top': el.y = 0; break; + case 'middle': el.y = Math.round((this.MATRIX_H - bb.h) / 2); break; + case 'bottom': el.y = this.MATRIX_H - bb.h; break; + } + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + // ── Templates ───────────────────────────────────────────────────── + loadTemplate(tmpl) { + if (this.isDirty && this.elements.length > 0) { + if (!confirm('Load template? Your current canvas will be replaced.')) return; + } + this.elements = tmpl.elements.map((el, i) => ({ + xAnchor: null, yAnchor: null, minWidth: 0, conditions: [], + ...structuredClone(el), + id: i + 1, + })); + this._nextId = this.elements.length + 1; + this.dataModel = structuredClone(tmpl.dataModel); + if (tmpl.preset) this.changePreset(tmpl.preset, { silent: true }); + this.selectedId = null; + this.showTemplateModal = false; + this.isDirty = true; + this._snapshot(); + this.render(); + this._setStatus(`Loaded template: ${tmpl.label}`, 'success'); + }, + + // ── Metadata helpers ────────────────────────────────────────────── + onNameInput() { + if (this._idAutoGenerated) { + this.metadata.id = this.metadata.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + } + }, + onIdInput() { this._idAutoGenerated = false; }, + isIdValid() { return /^[a-z][a-z0-9-]{0,62}$/.test(this.metadata.id); }, + + // ── Config variables ────────────────────────────────────────────── + addConfigVar() { + const key = this.newConfigVar.key.trim(); + if (!key) { this._setStatus('Key is required', 'error'); return; } + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { + this._setStatus('Key must be a valid Python identifier', 'error'); return; + } + if (this.dataModel.configVars.find(v => v.key === key)) { + this._setStatus(`"${key}" already exists`, 'error'); return; + } + this.dataModel.configVars.push({ ...this.newConfigVar, key }); + this.newConfigVar = { key:'', label:'', type:'string', default:'', description:'' }; + this.showConfigVarModal = false; + this.isDirty = true; + this._snapshot(); + }, + + removeConfigVar(key) { + const bound = this.elements.filter( + e => e.type === 'dynamic_text' && e.binding?.source === 'config' && e.binding?.key === key + ); + if (bound.length && !confirm(`"${key}" is used by ${bound.length} element(s). Remove anyway?`)) return; + this.dataModel.configVars = this.dataModel.configVars.filter(v => v.key !== key); + this.isDirty = true; + this._snapshot(); + }, + + _isBound(key) { + return this.elements.some( + e => e.type === 'dynamic_text' && e.binding?.source === 'config' && e.binding?.key === key + ); + }, + + addPreMadeVar(varDef) { + if (this.dataModel.configVars.find(v => v.key === varDef.key)) { + this._setStatus(`"${varDef.key}" already added`, 'warning'); return; + } + this.dataModel.configVars.push({ ...varDef }); + this.isDirty = true; + this._snapshot(); + this._setStatus(`Added: ${varDef.key}`, 'success'); + }, + + // ── Open existing plugin ────────────────────────────────────────── + async openPluginModal() { + this.showOpenModal = true; + this.installedPlugins = []; + this.loadPluginsStatus = 'loading'; + try { + const resp = await fetch('/composer/api/plugins'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + this.installedPlugins = await resp.json(); + this.loadPluginsStatus = 'done'; + } catch (e) { + this.loadPluginsStatus = 'error'; + this._setStatus('Could not load plugin list: ' + e.message, 'error'); + } + }, + + async loadPlugin(pluginId) { + this.showOpenModal = false; + try { + const resp = await fetch(`/composer/api/load/${pluginId}`); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || `HTTP ${resp.status}`); + } + const data = await resp.json(); + const state = data.state ?? data; + this._applyState(state); + this._idAutoGenerated = false; + this.isDirty = false; + this._snapshot(); + this._setStatus(`Loaded: ${pluginId}` + (data.source === 'schema_import' ? ' (config vars only)' : ''), 'success'); + this.render(); + } catch (e) { + this._setStatus('Failed to load plugin: ' + e.message, 'error'); + } + }, + + // ── Code preview ────────────────────────────────────────────────── + async showPreview() { + if (!this._validateBeforeExport()) return; + this.showCodeModal = true; + this.loadingCode = true; + this.codeFiles = {}; + try { + const resp = await fetch('/composer/api/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this._buildPayload()), + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.message || `HTTP ${resp.status}`); + this.codeFiles = data.files; + this.codeTab = 'manager.py'; + } catch (e) { + this._setStatus('Preview failed: ' + e.message, 'error'); + this.showCodeModal = false; + } finally { + this.loadingCode = false; + } + }, + + // ── Color helpers ───────────────────────────────────────────────── + rgbToHex(r, g, b) { + return '#' + [r, g, b].map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join(''); + }, + + onColorChange(el, hexField, hex) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + if (hexField === 'fill') { el.fillR = r; el.fillG = g; el.fillB = b; } + else if (hexField === 'out') { el.outR = r; el.outG = g; el.outB = b; } + else if (hexField === 'bg') { el.bgR = r; el.bgG = g; el.bgB = b; } + else if (hexField === 'track') { el.trackR = r; el.trackG = g; el.trackB = b; } + else if (hexField === 'empty') { el.emptyR = r; el.emptyG = g; el.emptyB = b; } + else { el.r = r; el.g = g; el.b = b; } + this._trackColor(r, g, b); + this.render(); + }, + + applyPaletteColor(el, hexField, swatch) { + const { r, g, b } = swatch; + if (hexField === 'fill') { el.fillR = r; el.fillG = g; el.fillB = b; } + else if (hexField === 'out') { el.outR = r; el.outG = g; el.outB = b; } + else if (hexField === 'bg') { el.bgR = r; el.bgG = g; el.bgB = b; } + else if (hexField === 'track') { el.trackR = r; el.trackG = g; el.trackB = b; } + else if (hexField === 'empty') { el.emptyR = r; el.emptyG = g; el.emptyB = b; } + else { el.r = r; el.g = g; el.b = b; } + this._trackColor(r, g, b); + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + // ── Export & Install ────────────────────────────────────────────── + _buildPayload() { + return { + composer_version: '1.0', + metadata: this.metadata, + elements: this.elements, + dataModel: this.dataModel, + preset: this.currentPreset, + }; + }, + + _validateBeforeExport() { + if (!this.metadata.name.trim()) { this._setStatus('Plugin name is required', 'error'); return false; } + if (!this.isIdValid()) { this._setStatus('Plugin ID is invalid', 'error'); return false; } + if (!this.metadata.author.trim()) { this._setStatus('Author is required', 'error'); return false; } + if (this.elements.length === 0) { this._setStatus('Add at least one element', 'error'); return false; } + const unbound = this.elements.filter( + e => (e.type === 'dynamic_text' || e.type === 'progress_bar') && !e.binding?.key + ); + if (unbound.length) { this._setStatus(`${unbound.length} element(s) have no variable bound`, 'error'); return false; } + return true; + }, + + async generateZip() { + if (!this._validateBeforeExport()) return; + this.generateStatus = 'working'; + try { + const resp = await fetch('/composer/api/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this._buildPayload()), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({ message: 'Server error' })); + throw new Error(err.message || `HTTP ${resp.status}`); + } + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `${this.metadata.id}.zip`; a.click(); + URL.revokeObjectURL(url); + this.generateStatus = 'done'; + this._setStatus('Plugin ZIP downloaded', 'success'); + setTimeout(() => { if (this.generateStatus === 'done') this.generateStatus = 'idle'; }, 4000); + } catch (err) { + this.generateStatus = 'error'; + this._setStatus(err.message, 'error'); + setTimeout(() => { if (this.generateStatus === 'error') this.generateStatus = 'idle'; }, 6000); + } + }, + + async installLocally(force = false) { + if (!this._validateBeforeExport()) return; + this.installStatus = 'working'; + try { + const resp = await fetch('/composer/api/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...this._buildPayload(), _force: force }), + }); + const data = await resp.json().catch(() => ({ message: 'Server error' })); + + if (resp.status === 409 && !force) { + this.installStatus = 'idle'; + if (confirm(`Plugin "${this.metadata.id}" is already installed.\n\nOverwrite it with the current design?`)) { + return this.installLocally(true); + } + return; + } + + if (!resp.ok) throw new Error(data.message || `HTTP ${resp.status}`); + + this.installStatus = 'done'; + this.isDirty = false; + localStorage.removeItem(LS_KEY); + this._setStatus(`"${this.metadata.id}" installed — enable it in the Plugin Manager`, 'success'); + setTimeout(() => { if (this.installStatus === 'done') this.installStatus = 'idle'; }, 6000); + } catch (err) { + this.installStatus = 'error'; + this._setStatus(err.message, 'error'); + setTimeout(() => { if (this.installStatus === 'error') this.installStatus = 'idle'; }, 6000); + } + }, + + // ── Color themes ────────────────────────────────────────────────── + applyColorTheme(theme) { + // Distribute theme colors round-robin across text-type elements + const textTypes = ['text', 'dynamic_text', 'clock', 'countdown', 'marquee', 'gauge']; + let idx = 0; + for (const el of this.elements) { + if (textTypes.includes(el.type)) { + const c = theme.colors[idx % theme.colors.length]; + el.r = c.r; el.g = c.g; el.b = c.b; + idx++; + } + } + this.isDirty = true; + this._snapshot(); + this.render(); + this._setStatus(`Applied theme: ${theme.label}`, 'success'); + }, + + // ── Status ──────────────────────────────────────────────────────── + _setStatus(msg, type = 'info') { + this.statusMsg = msg; + this.statusType = type; + }, + + statusClass() { + return { + info: 'text-gray-500', + success: 'text-green-600', + error: 'text-red-600', + warning: 'text-yellow-600', + }[this.statusType] || 'text-gray-500'; + }, + }; +} diff --git a/web_interface/static/v3/js/composer/composer-canvas.js b/web_interface/static/v3/js/composer/composer-canvas.js new file mode 100644 index 00000000..adc3791d --- /dev/null +++ b/web_interface/static/v3/js/composer/composer-canvas.js @@ -0,0 +1,757 @@ +/** + * ComposerCanvas — stateless LED matrix canvas renderer. + * + * Coordinate system: LED pixels (integers). All drawing multiplies by SCALE. + * PIL draw.text(x,y) is top-left; canvas fillText(x,y) is baseline. + * → Canvas text cy = (actualY + fontSizePx) * SCALE + * + * Anchors: element x/y are offsets from their anchor point: + * xAnchor=null/'left' → x is fixed offset from left + * xAnchor='center' → x is offset from width/2 + * xAnchor='right' → x is offset inward from right edge + * yAnchor follows the same pattern with 'top'/'middle'/'bottom' + * + * Breakpoints: elements with minWidth > currentMatrixW are rendered at 25% opacity. + * + * Resize handles: drawn on selected rectangles; 8 handles (corners + edge mids). + */ +window.ComposerCanvas = (() => { + 'use strict'; + + let _canvas = null; + let _ctx = null; + let _showGrid = true; + + const DISPLAY_PRESETS = [ + { label: '64×32', w: 64, h: 32 }, + { label: '128×32', w: 128, h: 32 }, + { label: '128×64', w: 128, h: 64 }, + { label: '256×32', w: 256, h: 32 }, + { label: '256×64', w: 256, h: 64 }, + ]; + + const FONT_MAP = { + press_start: { family: "'PressStart2P', monospace", sizePx: 8, charW: 8 }, + four_by_six: { family: 'monospace', sizePx: 6, charW: 4 }, + five_by_seven: { family: 'monospace', sizePx: 7, charW: 5 }, + }; + + const ELEMENT_DEFAULTS = { + text: { + text: 'Hello', font: 'press_start', + r: 255, g: 255, b: 255, + text2: '', lineSpacing: 2, textAlign: 'left', + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + dynamic_text: { + binding: { source: 'config', key: '', format: null }, + font: 'press_start', textAlign: 'left', + r: 255, g: 200, b: 100, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + clock: { + format: '%H:%M', font: 'press_start', + r: 100, g: 255, b: 100, + format2: '', lineSpacing: 2, textAlign: 'left', + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + rectangle: { + width: 20, height: 8, + fillR: 0, fillG: 0, fillB: 128, hasFill: true, + outR: 255, outG: 255, outB: 255, hasOutline: true, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + ellipse: { + width: 24, height: 12, + fillR: 0, fillG: 100, fillB: 200, hasFill: true, + outR: 100, outG: 180, outB: 255, hasOutline: true, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + arc: { + width: 24, height: 24, + startAngle: 0, endAngle: 270, lineWidth: 2, + r: 255, g: 200, b: 0, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + pixel: { + r: 255, g: 255, b: 255, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + rounded_rectangle: { + width: 24, height: 10, borderRadius: 3, + fillR: 0, fillG: 80, fillB: 180, hasFill: true, + outR: 120, outG: 180, outB: 255, hasOutline: true, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + line: { + x0: 0, y0: 16, x1: 63, y1: 16, + r: 180, g: 180, b: 180, lineWidth: 1, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + divider: { + orientation: 'horizontal', y: 16, x: 64, + r: 100, g: 100, b: 100, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + progress_bar: { + barWidth: 60, barHeight: 6, + binding: { source: 'config', key: '', format: null }, + r: 80, g: 200, b: 80, + bgR: 30, bgG: 30, bgB: 30, hasBg: true, + outR: 100, outG: 100, outB: 100, hasOutline: true, + previewPct: 65, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + countdown: { + binding: { source: 'config', key: '', format: null }, + countdownFormat: 'dh', + font: 'four_by_six', textAlign: 'left', + r: 255, g: 180, b: 0, + previewText: '42d 3h', + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + marquee: { + text: 'Scrolling text', font: 'press_start', + r: 255, g: 255, b: 255, + scrollSpeed: 1, gap: 16, direction: 'left', + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + section: { + label: 'Section', + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + pips: { + count: 5, filled: 3, pipSize: 4, pipSpacing: 2, + r: 255, g: 200, b: 0, + emptyR: 50, emptyG: 50, emptyB: 50, showEmpty: true, + binding: { source: 'config', key: '', format: null }, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + sparkline: { + width: 40, height: 12, + barCount: 8, barSpacing: 1, + r: 80, g: 200, b: 120, + bgR: 30, bgG: 30, bgB: 30, hasBg: false, + binding: { source: 'config', key: '', format: null }, + previewData: '0.3,0.6,0.4,0.8,0.5,0.9,0.7,0.85', + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + gauge: { + width: 32, height: 32, + startAngle: 135, endAngle: 45, lineWidth: 3, + binding: { source: 'config', key: '', format: null }, + r: 80, g: 220, b: 80, + trackR: 40, trackG: 40, trackB: 40, hasTrack: true, + showLabel: true, font: 'four_by_six', labelR: 200, labelG: 200, labelB: 200, + previewPct: 65, + xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true, + }, + }; + + // ── Anchor resolution ──────────────────────────────────────────────── + function resolveAnchor(val, anchor, dim) { + if (!anchor || anchor === 'left' || anchor === 'top') return val; + if (anchor === 'center' || anchor === 'middle') return Math.floor(dim / 2) + val; + if (anchor === 'right' || anchor === 'bottom') return dim - val; + return val; + } + + function computeActualPos(el, matrixW, matrixH) { + const ax = resolveAnchor(el.x ?? el.x0 ?? 0, el.xAnchor, matrixW); + const ay = resolveAnchor(el.y ?? el.y0 ?? 0, el.yAnchor, matrixH); + return { x: ax, y: ay }; + } + + // ── Bounding box (LED pixel space) ────────────────────────────────── + function getBoundingBox(el, matrixW, matrixH) { + const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH); + const finfo = FONT_MAP[el.font] || FONT_MAP.press_start; + + switch (el.type) { + case 'text': { + const t1 = el.text || '', t2 = el.text2 || ''; + const w = Math.max(t1.length, t2.length) * finfo.charW; + const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx; + const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax; + return { x: bx, y: ay, w, h }; + } + case 'dynamic_text': { + const key = el.binding?.key || '?'; + const w = (`{${key}}`).length * finfo.charW; + const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax; + return { x: bx, y: ay, w, h: finfo.sizePx }; + } + case 'clock': { + const t1 = el.format || '%H:%M', t2 = el.format2 || ''; + const w = Math.max(t1.length, t2.length) * finfo.charW; + const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx; + const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax; + return { x: bx, y: ay, w, h }; + } + case 'countdown': { + const pt = el.previewText || '--d --h'; + const w = pt.length * finfo.charW; + const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax; + return { x: bx, y: ay, w, h: finfo.sizePx }; + } + case 'rectangle': + case 'rounded_rectangle': + case 'ellipse': + case 'arc': + return { x: ax, y: ay, w: el.width, h: el.height }; + case 'pixel': + return { x: ax, y: ay, w: 1, h: 1 }; + case 'line': + return { + x: Math.min(el.x0, el.x1), y: Math.min(el.y0, el.y1), + w: Math.max(1, Math.abs(el.x1 - el.x0)), + h: Math.max(1, Math.abs(el.y1 - el.y0)), + }; + case 'divider': + return el.orientation === 'horizontal' + ? { x: 0, y: ay, w: matrixW, h: 1 } + : { x: ax, y: 0, w: 1, h: matrixH }; + case 'progress_bar': + return { x: ax, y: ay, w: el.barWidth ?? 60, h: el.barHeight ?? 6 }; + case 'marquee': { + const mfinfo = FONT_MAP[el.font] || FONT_MAP.press_start; + return { x: 0, y: ay, w: matrixW, h: mfinfo.sizePx }; + } + case 'gauge': + return { x: ax, y: ay, w: el.width ?? 32, h: el.height ?? 32 }; + case 'sparkline': + return { x: ax, y: ay, w: el.width ?? 40, h: el.height ?? 12 }; + case 'pips': { + const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2; + return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps }; + } + case 'section': + return { x: ax, y: ay, w: 0, h: 0 }; + default: + return { x: ax, y: ay, w: 4, h: 4 }; + } + } + + // ── Resize handle support ───────────────────────────────────────────── + // Returns 8 handle points for a rectangle in LED pixel space + function _getRectHandles(el, matrixW, matrixH) { + const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH); + const w = el.width, h = el.height; + const cx = ax + w / 2, cy = ay + h / 2; + return { + nw: { x: ax, y: ay }, + n: { x: cx, y: ay }, + ne: { x: ax + w, y: ay }, + w: { x: ax, y: cy }, + e: { x: ax + w, y: cy }, + sw: { x: ax, y: ay + h }, + s: { x: cx, y: ay + h }, + se: { x: ax + w, y: ay + h }, + }; + } + + // Returns the handle direction under LED-space point (lx, ly), or null + function getResizeHandle(el, lx, ly, matrixW, matrixH) { + if (!['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) return null; + const handles = _getRectHandles(el, matrixW, matrixH); + const PAD = 4; + for (const [dir, pt] of Object.entries(handles)) { + if (Math.abs(lx - pt.x) <= PAD && Math.abs(ly - pt.y) <= PAD) return dir; + } + return null; + } + + const _HANDLE_CURSORS = { + nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize', + w: 'w-resize', e: 'e-resize', + sw: 'sw-resize', s: 's-resize', se: 'se-resize', + }; + function getCursorForHandle(handle) { + return _HANDLE_CURSORS[handle] || 'crosshair'; + } + + // ── Hit test ───────────────────────────────────────────────────────── + function hitTest(el, lx, ly, matrixW, matrixH) { + const PAD = 3; + const bb = getBoundingBox(el, matrixW, matrixH); + return ( + lx >= bb.x - PAD && lx <= bb.x + bb.w + PAD && + ly >= bb.y - PAD && ly <= bb.y + bb.h + PAD + ); + } + + // ── Draw a single element ───────────────────────────────────────────── + function _drawElement(ctx, el, SCALE, matrixW, matrixH, opts = {}) { + const s = SCALE; + const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH); + const belowBreakpoint = el.minWidth > 0 && matrixW < el.minWidth; + const hidden = el.visible === false; + + ctx.save(); + if (hidden) ctx.globalAlpha = 0.12; + else if (belowBreakpoint) ctx.globalAlpha = 0.25; + + // Blink animation: when blinkOff, fully hide blinking elements + if (el.blink) { + if (opts.blinkOff) { ctx.restore(); return; } + ctx.globalAlpha *= 0.55; + } + + // Helper: compute draw X for text alignment + const _textX = (text, finfo) => { + const tw = text.length * finfo.charW * s; + if (el.textAlign === 'center') return ax * s - tw / 2; + if (el.textAlign === 'right') return ax * s - tw; + return ax * s; + }; + + try { + switch (el.type) { + case 'text': + case 'dynamic_text': + case 'clock': { + const finfo = FONT_MAP[el.font] || FONT_MAP.press_start; + const key = el.binding?.key || '?'; + const pv = opts.previewValues?.[key]; + // Substitute {variable} tokens in text using previewValues + const _subVars = str => (str || '').replace(/\{(\w+)\}/g, (_, k) => { + const v = opts.previewValues?.[k]; + return v !== undefined && v !== '' ? String(v) : `{${k}}`; + }); + const displayText = + el.type === 'text' ? _subVars(el.text || '') + : el.type === 'clock' ? (el.format || '%H:%M') + : (pv !== undefined && pv !== '' ? String(pv) : `{${key}}`); + ctx.font = `${finfo.sizePx * s}px ${finfo.family}`; + ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.fillText(displayText, _textX(displayText, finfo), (ay + finfo.sizePx) * s); + // Second line (text and clock) + if (el.type === 'text' && el.text2) { + const t2 = _subVars(el.text2); + const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2); + ctx.fillText(t2, _textX(t2, finfo), (y2 + finfo.sizePx) * s); + } + if (el.type === 'clock' && el.format2) { + const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2); + ctx.fillText(el.format2, _textX(el.format2, finfo), (y2 + finfo.sizePx) * s); + } + break; + } + + case 'countdown': { + const finfo = FONT_MAP[el.font] || FONT_MAP.press_start; + const t = el.previewText || '--d --h'; + ctx.font = `${finfo.sizePx * s}px ${finfo.family}`; + ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.fillText(t, _textX(t, finfo), (ay + finfo.sizePx) * s); + break; + } + + case 'rectangle': { + const rx = ax * s, ry = ay * s; + const rw = el.width * s, rh = el.height * s; + if (el.hasFill) { + ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`; + ctx.fillRect(rx, ry, rw, rh); + } + if (el.hasOutline) { + ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`; + ctx.lineWidth = 1; + ctx.strokeRect(rx, ry, rw, rh); + } + break; + } + + case 'ellipse': { + const cx = (ax + el.width / 2) * s; + const cy = (ay + el.height / 2) * s; + const rx = (el.width / 2) * s; + const ry = (el.height / 2) * s; + ctx.beginPath(); + ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); + if (el.hasFill) { + ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`; + ctx.fill(); + } + if (el.hasOutline) { + ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`; + ctx.lineWidth = 1; + ctx.stroke(); + } + break; + } + + case 'arc': { + const cx = (ax + el.width / 2) * s; + const cy = (ay + el.height / 2) * s; + const rx = (el.width / 2) * s; + const ry = (el.height / 2) * s; + // PIL: 0°=right, clockwise. Canvas: same with anticlockwise=false + const startRad = (el.startAngle ?? 0) * Math.PI / 180; + const endRad = (el.endAngle ?? 270) * Math.PI / 180; + ctx.beginPath(); + ctx.ellipse(cx, cy, rx, ry, 0, startRad, endRad, false); + ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.lineWidth = Math.max(1, el.lineWidth || 2); + ctx.stroke(); + break; + } + + case 'pixel': { + ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.fillRect(ax * s, ay * s, s, s); + break; + } + + case 'rounded_rectangle': { + const rx = ax * s, ry = ay * s; + const rw = el.width * s, rh = el.height * s; + const rad = Math.min((el.borderRadius ?? 3) * s, rw / 2, rh / 2); + ctx.beginPath(); + ctx.roundRect(rx, ry, rw, rh, rad); + if (el.hasFill) { + ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`; + ctx.fill(); + } + if (el.hasOutline) { + ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`; + ctx.lineWidth = 1; + ctx.stroke(); + } + break; + } + + case 'line': { + ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.lineWidth = Math.max(1, el.lineWidth || 1); + ctx.beginPath(); + ctx.moveTo(el.x0 * s, el.y0 * s); + ctx.lineTo(el.x1 * s, el.y1 * s); + ctx.stroke(); + break; + } + + case 'divider': { + const isH = (el.orientation || 'horizontal') === 'horizontal'; + ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.lineWidth = 1; + ctx.beginPath(); + if (isH) { + ctx.moveTo(0, ay * s + 0.5); + ctx.lineTo(_canvas.width, ay * s + 0.5); + } else { + ctx.moveTo(ax * s + 0.5, 0); + ctx.lineTo(ax * s + 0.5, _canvas.height); + } + ctx.stroke(); + break; + } + + case 'pips': { + const pipCount = Math.max(1, el.count ?? 5); + const pvPips = opts.previewValues?.[el.binding?.key]; + const filledN = pvPips !== undefined + ? Math.max(0, Math.min(pipCount, Math.round(parseFloat(pvPips) || 0))) + : Math.max(0, Math.min(pipCount, el.filled ?? 3)); + const ps = Math.max(1, el.pipSize ?? 4); + const pg = Math.max(0, el.pipSpacing ?? 2); + for (let i = 0; i < pipCount; i++) { + const isFilled = i < filledN; + if (!isFilled && !el.showEmpty) continue; + ctx.fillStyle = isFilled + ? `rgb(${el.r},${el.g},${el.b})` + : `rgb(${el.emptyR ?? 50},${el.emptyG ?? 50},${el.emptyB ?? 50})`; + ctx.fillRect((ax + i * (ps + pg)) * s, ay * s, ps * s, ps * s); + } + break; + } + + case 'sparkline': { + const slW = el.width ?? 40, slH = el.height ?? 12; + const count = Math.max(1, el.barCount ?? 8); + const spacing = el.barSpacing ?? 1; + const barW = Math.max(1, Math.floor((slW - spacing * (count - 1)) / count)); + const rawVals = (el.previewData || '').split(',') + .map(v => parseFloat(v.trim())).filter(n => !isNaN(n)); + while (rawVals.length < count) rawVals.push(0); + const maxV = Math.max(...rawVals.slice(0, count), 0.001); + const rx = ax * s, ry = ay * s; + if (el.hasBg) { + ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`; + ctx.fillRect(rx, ry, slW * s, slH * s); + } + ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; + for (let i = 0; i < count; i++) { + const norm = Math.max(0, Math.min(1, rawVals[i] / maxV)); + const barH = Math.max(1, Math.round(slH * norm)); + const bx = rx + (barW + spacing) * i * s; + const by = ry + (slH - barH) * s; + ctx.fillRect(bx, by, barW * s, barH * s); + } + break; + } + + case 'gauge': { + const gw = (el.width ?? 32), gh = (el.height ?? 32); + const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s; + const rx = (gw / 2) * s, ry = (gh / 2) * s; + const lw = Math.max(1, (el.lineWidth ?? 3)); + const startDeg = el.startAngle ?? 135; + const endDeg = el.endAngle ?? 45; + // Arc sweep: from startDeg clockwise to endDeg (PIL convention) + const totalSweep = ((endDeg - startDeg) + 360) % 360 || 360; + const pvGauge = opts.previewValues?.[el.binding?.key]; + const pct = pvGauge !== undefined + ? Math.max(0, Math.min(100, parseFloat(pvGauge) || 0)) / 100 + : Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100; + const fillSweep = totalSweep * pct; + const toRad = deg => (deg - 90) * Math.PI / 180; // canvas 0=top, PIL 0=right → offset -90 + + // Track arc + if (el.hasTrack !== false) { + ctx.beginPath(); + ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false); + ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`; + ctx.lineWidth = lw * s; + ctx.stroke(); + } + // Fill arc + if (pct > 0) { + ctx.beginPath(); + ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false); + ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.lineWidth = lw * s; + ctx.stroke(); + } + // Centre label + if (el.showLabel) { + const gfinfo = FONT_MAP[el.font || 'four_by_six'] || FONT_MAP.four_by_six; + const labelText = Math.round(pct * 100) + '%'; + ctx.font = `${gfinfo.sizePx * s}px ${gfinfo.family}`; + ctx.fillStyle = `rgb(${el.labelR ?? 200},${el.labelG ?? 200},${el.labelB ?? 200})`; + const ltw = ctx.measureText(labelText).width; + ctx.fillText(labelText, cx - ltw / 2, cy + (gfinfo.sizePx * s) / 2); + } + break; + } + + case 'marquee': { + const finfo = FONT_MAP[el.font] || FONT_MAP.press_start; + const text = el.text || 'Scrolling text'; + const tw = text.length * finfo.charW * s; + const gap = (el.gap ?? 16) * s; + const totalW = tw + gap; + const tick = opts.animTick ?? 0; + const speed = (el.scrollSpeed ?? 1) * 2; + const scrolled = (tick * speed) % totalW; + // left: text enters from right; right: text enters from left + const startX = el.direction === 'right' + ? scrolled - tw + : matrixW * s - scrolled; + ctx.font = `${finfo.sizePx * s}px ${finfo.family}`; + ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; + // Clip to canvas width so text doesn't bleed outside + ctx.save(); + ctx.beginPath(); + ctx.rect(0, ay * s - 1, matrixW * s, (finfo.sizePx + 2) * s); + ctx.clip(); + for (let i = -1; i <= 2; i++) { + ctx.fillText(text, startX + i * totalW, (ay + finfo.sizePx) * s); + } + ctx.restore(); + break; + } + + case 'progress_bar': { + const bw = el.barWidth ?? 60, bh = el.barHeight ?? 6; + const pvPb = opts.previewValues?.[el.binding?.key]; + const pct = pvPb !== undefined + ? Math.max(0, Math.min(100, parseFloat(pvPb) || 0)) / 100 + : Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100; + const rx = ax * s, ry = ay * s; + if (el.hasBg) { + ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`; + ctx.fillRect(rx, ry, bw * s, bh * s); + } + const fillW = Math.max(0, Math.round(bw * pct)); + if (fillW > 0) { + ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.fillRect(rx, ry, fillW * s, bh * s); + } + if (el.hasOutline) { + ctx.strokeStyle = `rgb(${el.outR ?? 100},${el.outG ?? 100},${el.outB ?? 100})`; + ctx.lineWidth = 1; + ctx.strokeRect(rx, ry, bw * s, bh * s); + } + break; + } + } + + if (belowBreakpoint) { + ctx.globalAlpha = 0.6; + const bb = getBoundingBox(el, matrixW, matrixH); + ctx.font = `${Math.max(8, s * 2)}px monospace`; + ctx.fillStyle = '#facc15'; + ctx.fillText(`≥${el.minWidth}px`, bb.x * s, (bb.y + 4) * s); + } + } finally { + ctx.restore(); + } + } + + // ── Selection indicator ────────────────────────────────────────────── + function _drawSelection(ctx, el, SCALE, matrixW, matrixH) { + const bb = getBoundingBox(el, matrixW, matrixH); + const PAD = 2, s = SCALE; + const rx = bb.x * s - PAD, ry = bb.y * s - PAD; + const rw = bb.w * s + PAD * 2, rh = bb.h * s + PAD * 2; + + ctx.save(); + ctx.strokeStyle = '#3b82f6'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 2]); + ctx.strokeRect(rx, ry, rw, rh); + ctx.setLineDash([]); + + if (el.xAnchor || el.yAnchor) { + ctx.font = `${Math.max(7, s)}px sans-serif`; + ctx.fillStyle = '#a78bfa'; + const anchorText = [ + el.xAnchor ? `x:${el.xAnchor[0]}` : '', + el.yAnchor ? `y:${el.yAnchor[0]}` : '', + ].filter(Boolean).join(' '); + if (anchorText) ctx.fillText(anchorText, rx + 1, ry - 2); + } + + // Resize handles: on rect, rounded rect, ellipse + if (['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) { + const handles = _getRectHandles(el, matrixW, matrixH); + const HS = 5; + ctx.fillStyle = 'white'; + ctx.strokeStyle = '#2563eb'; + ctx.lineWidth = 1; + for (const pt of Object.values(handles)) { + const hx = pt.x * s - HS / 2; + const hy = pt.y * s - HS / 2; + ctx.fillRect(hx, hy, HS, HS); + ctx.strokeRect(hx, hy, HS, HS); + } + } else { + // Corner dots for non-rectangle elements + ctx.fillStyle = '#3b82f6'; + const HS = 4; + for (const [hx, hy] of [ + [rx - HS / 2, ry - HS / 2], [rx + rw - HS / 2, ry - HS / 2], + [rx - HS / 2, ry + rh - HS / 2], [rx + rw - HS / 2, ry + rh - HS / 2], + ]) ctx.fillRect(hx, hy, HS, HS); + } + + ctx.restore(); + } + + // ── Dimension tooltip while dragging ───────────────────────────────── + function drawDragTooltip(ctx, el, SCALE, matrixW, matrixH) { + const bb = getBoundingBox(el, matrixW, matrixH); + const label = el.type === 'rectangle' + ? `${el.width}×${el.height}` + : `${bb.x},${bb.y}`; + const s = SCALE; + ctx.save(); + ctx.font = `${Math.max(9, s * 1.5)}px monospace`; + const tw = ctx.measureText(label).width; + const tx = bb.x * s, ty = (bb.y - 2) * s; + ctx.fillStyle = 'rgba(0,0,0,0.7)'; + ctx.fillRect(tx - 2, ty - 10, tw + 4, 12); + ctx.fillStyle = 'white'; + ctx.fillText(label, tx, ty); + ctx.restore(); + } + + // ── Public API ─────────────────────────────────────────────────────── + + function init(canvasEl) { + _canvas = canvasEl; + _ctx = canvasEl.getContext('2d'); + } + + function setGrid(show) { _showGrid = show; } + + function updateCanvasSize(matrixW, matrixH, SCALE) { + if (!_canvas) return; + _canvas.width = matrixW * SCALE; + _canvas.height = matrixH * SCALE; + } + + function render(elements, selectedId, matrixW, matrixH, SCALE, opts = {}) { + if (!_ctx) return; + const cW = matrixW * SCALE, cH = matrixH * SCALE; + + const bg = opts.bgColor; + _ctx.fillStyle = bg ? `rgb(${bg.r},${bg.g},${bg.b})` : '#000'; + _ctx.fillRect(0, 0, cW, cH); + + if (_showGrid) { + _ctx.strokeStyle = 'rgba(255,255,255,0.07)'; + _ctx.lineWidth = 0.5; + for (let x = SCALE; x < cW; x += SCALE) { + _ctx.beginPath(); _ctx.moveTo(x, 0); _ctx.lineTo(x, cH); _ctx.stroke(); + } + for (let y = SCALE; y < cH; y += SCALE) { + _ctx.beginPath(); _ctx.moveTo(0, y); _ctx.lineTo(cW, y); _ctx.stroke(); + } + } + + for (const el of elements) _drawElement(_ctx, el, SCALE, matrixW, matrixH, opts); + + if (opts.showRuler) { + _ctx.save(); + _ctx.fillStyle = 'rgba(255,255,255,0.08)'; + _ctx.fillRect(0, 0, cW, SCALE); // top strip + _ctx.fillRect(0, 0, SCALE, cH); // left strip + _ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + _ctx.fillStyle = 'rgba(255,255,255,0.6)'; + _ctx.font = `${Math.max(5, SCALE - 1)}px monospace`; + const step = SCALE >= 4 ? 8 : 16; + for (let px = 0; px <= matrixW; px += step) { + const cx = px * SCALE; + const major = px % 32 === 0; + _ctx.lineWidth = 0.5; + _ctx.beginPath(); _ctx.moveTo(cx, 0); _ctx.lineTo(cx, major ? SCALE : SCALE * 0.5); _ctx.stroke(); + if (major && px > 0 && px < matrixW - 4) _ctx.fillText(String(px), cx + 1, SCALE - 1); + } + for (let py = 0; py <= matrixH; py += step) { + const cy = py * SCALE; + const major = py % 32 === 0; + _ctx.beginPath(); _ctx.moveTo(0, cy); _ctx.lineTo(major ? SCALE : SCALE * 0.5, cy); _ctx.stroke(); + if (major && py > 0 && py < matrixH - 4) _ctx.fillText(String(py), 1, cy + SCALE - 1); + } + _ctx.restore(); + } + + if (opts.showGuides) { + _ctx.save(); + _ctx.strokeStyle = 'rgba(255,60,60,0.45)'; + _ctx.lineWidth = 1; + _ctx.setLineDash([4, 3]); + const mx = Math.floor(cW / 2) + 0.5; + const my = Math.floor(cH / 2) + 0.5; + _ctx.beginPath(); _ctx.moveTo(mx, 0); _ctx.lineTo(mx, cH); _ctx.stroke(); + _ctx.beginPath(); _ctx.moveTo(0, my); _ctx.lineTo(cW, my); _ctx.stroke(); + _ctx.setLineDash([]); + _ctx.restore(); + } + + const sel = selectedId != null ? elements.find(e => e.id === selectedId) : null; + if (sel) { + _drawSelection(_ctx, sel, SCALE, matrixW, matrixH); + if (opts.showTooltip) drawDragTooltip(_ctx, sel, SCALE, matrixW, matrixH); + } + } + + return { + init, render, setGrid, updateCanvasSize, + hitTest, getBoundingBox, computeActualPos, resolveAnchor, + getResizeHandle, getCursorForHandle, + ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS, + }; +})(); diff --git a/web_interface/templates/v3/composer.html b/web_interface/templates/v3/composer.html new file mode 100644 index 00000000..b6b062a8 --- /dev/null +++ b/web_interface/templates/v3/composer.html @@ -0,0 +1,1852 @@ + + + + + + Plugin Composer — LEDMatrix + + + + + + +
+ + +
+ + + +
+

+ + Plugin Composer + Beta +

+ + +
+ + +
+ +
+ + + + + + + + + + +
+ + + + + + + + + + +
+ + +
+ + +
+
+ + +
+ Plugin Info +
+
+ Name * + +
+
+ + ID * + + + + +
+
+ Author * + +
+
+
+ Duration s + +
+
+ Version + +
+
+
+ Background +
+ + + +
+
+
+ Category + +
+
+ Description + +
+
+
+ + +
+ Add Elements +
+ +
+
+ + +
+ Color Themes +
+ +
+
+ + +
+
+ Config Variables + +
+
+ +

No variables

+
+
+ + +
+ Quick-Add Vars + +
+ +
+ +
+ +
+
+ + +
+ + +
+
+ Display + + +
+ + × + · × + + + + + + +
+ + +
+ +
+ + + + +
+ +
+ +
+ + +
+ + +
+ + +
+
+ Layers + top ↑ +
+
+ + +
+
+ +
+ + +
+
+ + + + + +
+
+ +
+ + +
+ + + unsaved + + +
+ + + SNAP + + + el · + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + diff --git a/web_interface/templates/v3/composer/manager.py.j2 b/web_interface/templates/v3/composer/manager.py.j2 new file mode 100644 index 00000000..5d90e06d --- /dev/null +++ b/web_interface/templates/v3/composer/manager.py.j2 @@ -0,0 +1,298 @@ +""" +{{ plugin_name }} — LEDMatrix Plugin +Generated by LEDMatrix Plugin Composer on {{ generated_date }} + +Extension points: + update() → add HTTP/MQTT data-fetching logic here + _get_display_values() → map fetched data to display strings + display() → add new elements or adapt layout per display size +""" +from src.plugin_system.base_plugin import BasePlugin +{% if has_clock or has_countdown %} +from datetime import datetime +{% endif %} +{% if has_blink %} +import time +{% endif %} +{% if has_text_template %} +from collections import defaultdict +{% endif %} + + +class {{ class_name }}(BasePlugin): + + def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager): + super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) +{% for var in config_vars %} + self.{{ var.key }} = config.get({{ var.key | tojson }}, {{ var.default | tojson }}) +{% endfor %} + # Live data cache — populated by update(); always {} in static layouts + self._data = {} + + def update(self): + """Fetch and refresh display data. + + For dynamic plugins: fetch from APIs/MQTT here and store in self._data. + _get_display_values() will read self._data to produce display strings. + """ + # --- Data sources (add fetch logic here for dynamic plugins) --- + pass + + def _get_display_values(self): + """Map config variables and live data to display-ready strings. + + This is the single extension point for v2 data sources: + add self._data lookups here once update() populates them. + """ + return { +{% for var in config_vars %} + {{ var.key | tojson }}: str(self.{{ var.key }}), +{% endfor %} + } + + def display(self, force_clear=False): + try: +{% if has_text_template %} + values = defaultdict(str, self._get_display_values()) +{% else %} + values = self._get_display_values() +{% endif %} + + if force_clear: + self.display_manager.clear() + + width = self.display_manager.width + height = self.display_manager.height +{% if bg_color %} + + self.display_manager.draw.rectangle([0, 0, width, height], fill={{ bg_color }}) +{% endif %} + + # ── Elements (rendered bottom to top) ────────────────────────── +{% for el in elements %} +{% set p = " " if el.min_width > 0 else " " %} +{% set pi = (p + " ") if el.blink else p %} +{% if el.min_width > 0 %} + if width >= {{ el.min_width }}: # breakpoint: {{ el.min_width }}px+ displays only +{% endif %} +{% if el.blink %} +{{ p }}if int(time.time() * 2) % 2: +{% endif %} + +{% if el.type == 'text' %} +{{ pi }}self.display_manager.draw_text( +{% if el.text_is_template %} +{{ pi }} {{ el.text | tojson }}.format_map(values), +{% else %} +{{ pi }} {{ el.text | tojson }}, +{% endif %} +{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% if el.text2 %} +{{ pi }}self.display_manager.draw_text( +{% if el.text_is_template %} +{{ pi }} {{ el.text2 | tojson }}.format_map(values), +{% else %} +{{ pi }} {{ el.text2 | tojson }}, +{% endif %} +{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% endif %} +{% elif el.type == 'dynamic_text' %} +{% if el.binding_source == 'config' %} +{{ pi }}self.display_manager.draw_text( +{{ pi }} values.get({{ el.binding_key | tojson }}, ''), +{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% endif %} +{% elif el.type == 'clock' %} +{{ pi }}self.display_manager.draw_text( +{{ pi }} datetime.now().strftime({{ el.format | tojson }}), +{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% if el.format2 %} +{{ pi }}self.display_manager.draw_text( +{{ pi }} datetime.now().strftime({{ el.format2 | tojson }}), +{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% endif %} +{% elif el.type == 'countdown' %} +{{ pi }}_cd_target = float(values.get({{ el.binding_key | tojson }}, 0) or 0) +{{ pi }}_cd_secs = max(0.0, _cd_target - datetime.now().timestamp()) +{% if el.countdown_format == 'dhms' %} +{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400) +{{ pi }}_cd_h, _cd_rem = divmod(_cd_rem, 3600) +{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60) +{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}:{_cd_m:02d}:{_cd_s:02d}' +{% elif el.countdown_format == 'hms' %} +{{ pi }}_cd_h, _cd_rem = divmod(int(_cd_secs), 3600) +{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60) +{{ pi }}_cd_str = f'{_cd_h}h {_cd_m:02d}:{_cd_s:02d}' +{% elif el.countdown_format == 'dhm' %} +{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400) +{{ pi }}_cd_h, _cd_m = divmod(_cd_rem // 60, 60) +{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}h {_cd_m:02d}m' +{% else %} +{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400) +{{ pi }}_cd_h = _cd_rem // 3600 +{{ pi }}_cd_str = f'{_cd_d}d {_cd_h}h' +{% endif %} +{{ pi }}self.display_manager.draw_text( +{{ pi }} _cd_str, +{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% elif el.type == 'rectangle' %} +{{ pi }}self.display_manager.draw.rectangle( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], +{{ pi }} fill={{ el.fill_tuple }}, +{{ pi }} outline={{ el.outline_tuple }}, +{{ pi }}) +{% elif el.type == 'arc' %} +{{ pi }}self.display_manager.draw.arc( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], +{{ pi }} start={{ el.start_angle }}, end={{ el.end_angle }}, +{{ pi }} fill={{ el.rgb_tuple }}, +{{ pi }} width={{ el.line_width }}, +{{ pi }}) +{% elif el.type == 'ellipse' %} +{{ pi }}self.display_manager.draw.ellipse( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], +{{ pi }} fill={{ el.fill_tuple }}, +{{ pi }} outline={{ el.outline_tuple }}, +{{ pi }}) +{% elif el.type == 'pixel' %} +{{ pi }}self.display_manager.draw.point( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}], +{{ pi }} fill={{ el.rgb_tuple }}, +{{ pi }}) +{% elif el.type == 'rounded_rectangle' %} +{{ pi }}self.display_manager.draw.rounded_rectangle( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], +{{ pi }} radius={{ el.border_radius }}, +{{ pi }} fill={{ el.fill_tuple }}, +{{ pi }} outline={{ el.outline_tuple }}, +{{ pi }}) +{% elif el.type in ('line', 'divider') %} +{{ pi }}self.display_manager.draw.line( +{{ pi }} [{{ el.x0_expr }}, {{ el.y0_expr }}, {{ el.x1_expr }}, {{ el.y1_expr }}], +{{ pi }} fill={{ el.rgb_tuple }}, +{{ pi }} width={{ el.line_width }}, +{{ pi }}) +{% elif el.type == 'pips' %} +{{ pi }}_pip_filled = max(0, min({{ el.pip_count }}, int(float(values.get({{ el.binding_key | tojson }}, 0) or 0)))) +{{ pi }}for _pip_i in range({{ el.pip_count }}): +{{ pi }} _pip_x = ({{ el.x_expr }}) + _pip_i * ({{ el.pip_size }} + {{ el.pip_spacing }}) +{{ pi }} _pip_color = {{ el.fill_tuple }} if _pip_i < _pip_filled else {{ el.empty_tuple }} +{% if not el.show_empty %} +{{ pi }} if _pip_i >= _pip_filled: +{{ pi }} continue +{% endif %} +{{ pi }} self.display_manager.draw.rectangle( +{{ pi }} [_pip_x, {{ el.y_expr }}, _pip_x + {{ el.pip_size }} - 1, ({{ el.y_expr }}) + {{ el.pip_size }} - 1], +{{ pi }} fill=_pip_color, +{{ pi }} ) +{% elif el.type == 'sparkline' %} +{{ pi }}_sl_raw = str(values.get({{ el.binding_key | tojson }}, '') or '') +{{ pi }}_sl_vals = [float(v.strip()) for v in _sl_raw.split(',') if v.strip()][:{{ el.bar_count }}] +{{ pi }}_sl_vals += [0.0] * max(0, {{ el.bar_count }} - len(_sl_vals)) +{{ pi }}_sl_max = max(_sl_vals) if any(_sl_vals) else 1.0 +{{ pi }}_sl_bw = max(1, ({{ el.bar_width_px }} - {{ el.bar_spacing }} * ({{ el.bar_count }} - 1)) // {{ el.bar_count }}) +{% if el.bg_tuple != 'None' %} +{{ pi }}self.display_manager.draw.rectangle( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, ({{ el.x_expr }}) + {{ el.bar_width_px }}, ({{ el.y_expr }}) + {{ el.bar_height_px }}], +{{ pi }} fill={{ el.bg_tuple }}, +{{ pi }}) +{% endif %} +{{ pi }}for _sl_i, _sl_v in enumerate(_sl_vals): +{{ pi }} _sl_norm = max(0.0, min(1.0, _sl_v / (_sl_max or 1))) +{{ pi }} _sl_bh = max(1, round({{ el.bar_height_px }} * _sl_norm)) +{{ pi }} _sl_bx = ({{ el.x_expr }}) + (_sl_bw + {{ el.bar_spacing }}) * _sl_i +{{ pi }} _sl_by = ({{ el.y_expr }}) + {{ el.bar_height_px }} - _sl_bh +{{ pi }} self.display_manager.draw.rectangle( +{{ pi }} [_sl_bx, _sl_by, _sl_bx + _sl_bw - 1, _sl_by + _sl_bh - 1], +{{ pi }} fill={{ el.fill_tuple }}, +{{ pi }} ) +{% elif el.type == 'gauge' %} +{{ pi }}_gv = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0))) +{{ pi }}_g_total = (({{ el.end_angle }} - {{ el.start_angle }}) % 360) or 360 +{{ pi }}_g_sweep = _g_total * _gv / 100.0 +{% if el.track_tuple != 'None' %} +{{ pi }}self.display_manager.draw.arc( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], +{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_total, +{{ pi }} fill={{ el.track_tuple }}, +{{ pi }} width={{ el.line_width }}, +{{ pi }}) +{% endif %} +{{ pi }}if _g_sweep > 0: +{{ pi }} self.display_manager.draw.arc( +{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}], +{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_sweep, +{{ pi }} fill={{ el.rgb_tuple }}, +{{ pi }} width={{ el.line_width }}, +{{ pi }} ) +{% if el.show_label %} +{{ pi }}_g_cx = ({{ el.x_expr }}) + ({{ el.x2_expr }} - ({{ el.x_expr }})) // 2 +{{ pi }}_g_cy = ({{ el.y_expr }}) + ({{ el.y2_expr }} - ({{ el.y_expr }})) // 2 +{{ pi }}self.display_manager.draw_text( +{{ pi }} f'{int(_gv)}%', +{{ pi }} x=_g_cx, y=_g_cy, +{{ pi }} color={{ el.label_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% endif %} +{% elif el.type == 'marquee' %} +{{ pi }}_{{ el.data_key }}_text = {{ el.text | tojson }} +{{ pi }}_{{ el.data_key }}_tw = len(_{{ el.data_key }}_text) * {{ el.char_w }} +{{ pi }}_{{ el.data_key }}_x = int(self._data.get({{ el.data_key | tojson }}, width)) +{% if el.direction == 'right' %} +{{ pi }}_{{ el.data_key }}_x += {{ el.scroll_speed }} +{{ pi }}if _{{ el.data_key }}_x > width: +{{ pi }} _{{ el.data_key }}_x = -(_{{ el.data_key }}_tw + {{ el.gap }}) +{% else %} +{{ pi }}_{{ el.data_key }}_x -= {{ el.scroll_speed }} +{{ pi }}if _{{ el.data_key }}_x < -(_{{ el.data_key }}_tw + {{ el.gap }}): +{{ pi }} _{{ el.data_key }}_x = width +{% endif %} +{{ pi }}self._data[{{ el.data_key | tojson }}] = _{{ el.data_key }}_x +{{ pi }}self.display_manager.draw_text( +{{ pi }} _{{ el.data_key }}_text, +{{ pi }} x=_{{ el.data_key }}_x, y={{ el.y_expr }}, +{{ pi }} color={{ el.rgb_tuple }}, +{{ pi }} font=self.display_manager.{{ el.font_attr }}, +{{ pi }}) +{% elif el.type == 'progress_bar' %} +{{ pi }}_pb_x = {{ el.x_expr }} +{{ pi }}_pb_y = {{ el.y_expr }} +{{ pi }}_pb_pct = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0))) / 100.0 +{{ pi }}_pb_fill_w = int({{ el.bar_width }} * _pb_pct) +{{ pi }}self.display_manager.draw.rectangle( +{{ pi }} [_pb_x, _pb_y, _pb_x + {{ el.bar_width }}, _pb_y + {{ el.bar_height }}], +{{ pi }} fill={{ el.bg_tuple }}, +{{ pi }} outline={{ el.outline_tuple }}, +{{ pi }}) +{{ pi }}if _pb_fill_w > 0: +{{ pi }} self.display_manager.draw.rectangle( +{{ pi }} [_pb_x, _pb_y, _pb_x + _pb_fill_w, _pb_y + {{ el.bar_height }}], +{{ pi }} fill={{ el.fill_tuple }}, +{{ pi }} ) +{% endif %} +{% endfor %} + # ── End elements ─────────────────────────────────────────────── + + self.display_manager.update_display() + + except Exception as e: + self.logger.error('Display error: %s', e, exc_info=True)