diff --git a/test/test_composer_code_injection.py b/test/test_composer_code_injection.py new file mode 100644 index 00000000..a9527098 --- /dev/null +++ b/test/test_composer_code_injection.py @@ -0,0 +1,301 @@ +"""The composer generates Python that the plugin loader imports and executes. + +/api/install writes the generated manager.py into plugins_dir and the loader +imports it, so anything the payload can splice into that source runs on the +device. The ast.parse check in _generate_plugin_files rejects only *invalid* +syntax -- an injected `import os` is perfectly valid and passed it. + +Two ways in, both confirmed against the code before it was fixed: + + metadata.name = a name containing a triple-quote, a newline, then + `import os; PWNED = os.getuid()`, then another triple-quote + -> closes the module docstring; the rest became module-level statements + (spelled out rather than shown literally -- writing the payload into + this docstring closes *this* file's docstring, which is the bug) + + element x = '0 or __import__("os").system("id")' + -> f-string interpolated it verbatim: x=0 or __import__("os").system("id") +""" +import ast +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from web_interface.blueprints import composer as C # noqa: E402 + +BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a", + "version": "1.0.0", "description": "d"} + +#: Values that terminate a Python expression and start a new statement. +EXPR_PAYLOADS = [ + '0 or __import__("os").system("id")', + '0);import os;os.system("id");(', + '__import__("subprocess").run(["id"])', + "0 if False else exec('x=1')", + "1e999", "nan", "0x41", "0__0", +] + +#: Values that close a string literal in the generated source. +LITERAL_PAYLOADS = [ + 'Clock"""\nimport os; PWNED = os.getuid()\n"""', + "Clock'''\nimport os\n'''", + 'Clock" + __import__("os").system("id") + "', + "Clock\\", "Clock\nimport os", +] + + +def _payload(**over): + # dataModel.configVars is the key _generate_plugin_files reads; "config_vars" + # was never looked at, so anything passed through it tested nothing. + p = {"metadata": dict(BASE_META), "elements": [], + "dataModel": {"configVars": over.pop("config_vars", [])}} + p["metadata"].update(over.pop("metadata", {})) + p.update(over) + return p + + +def _generated(payload): + return C._generate_plugin_files(payload)["manager.py"] + + +def _module_level_code(src): + """Statements at module level that are not the docstring/imports/classes.""" + tree = ast.parse(src) + out = [] + for node in tree.body: + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.ImportFrom)): + continue + if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant): + continue # the docstring + out.append(ast.unparse(node)) + return out + + +@pytest.mark.parametrize("payload", LITERAL_PAYLOADS) +def test_a_name_that_breaks_out_of_a_literal_is_refused(payload): + with pytest.raises(C.ComposerInputError): + _generated(_payload(metadata={"name": payload})) + + +#: Types with a drawing branch in manager.py.j2. An injection test using any +#: other type proves nothing: _preprocess_elements drops it, so its values +#: never reach the generated source and every assertion passes trivially. +#: This test previously used "line", which has never had a branch. +RENDERED_GEOMETRY_CASES = [ + ("rectangle", {"x": 0, "y": 0, "width": 10, "height": 8}), + ("arc", {"x": 0, "y": 0, "width": 24, "height": 24}), + ("ellipse", {"x": 0, "y": 0, "width": 24, "height": 12}), + ("rounded_rectangle", {"x": 0, "y": 0, "width": 24, "height": 10}), + ("gauge", {"x": 0, "y": 0, "width": 32, "height": 32}), +] + + +@pytest.mark.parametrize("etype,base", RENDERED_GEOMETRY_CASES) +@pytest.mark.parametrize("evil", EXPR_PAYLOADS) +@pytest.mark.parametrize("field", ["x", "y", "width", "height"]) +def test_a_non_numeric_geometry_value_cannot_reach_the_source(etype, base, evil, field): + """width/height were interpolated raw into the generated source. + + p['x2_expr'] = f"({x_expr}) + {w}" with w straight off the payload, so a + rectangle with width='0 or __import__("os").system("id")' produced + + [0, 0, (0) + 0 or __import__("os").system("id"), (0) + 8], + + in a manager.py that /api/install writes to disk and the loader imports. + """ + el = {"type": etype, "id": "e1", **base} + el[field] = evil + src = _generated(_payload(elements=[el])) + assert "__import__" not in src, f"{etype}.{field}={evil!r} reached the generated source" + assert "os.system" not in src + assert not _module_level_code(src), \ + f"{etype}.{field}={evil!r} produced module-level statements: {_module_level_code(src)}" + + +def test_every_injection_case_uses_a_type_that_actually_renders(): + """Guards against the whole suite quietly going vacuous again. + + An element type with no template branch is dropped before generation, so + an injection test written against one asserts nothing and still passes. + """ + used = {etype for etype, _ in RENDERED_GEOMETRY_CASES} + missing = used - set(C._RENDERABLE_ELEMENT_TYPES) + assert not missing, f"injection tests use non-rendering types: {sorted(missing)}" + + +@pytest.mark.parametrize("evil", EXPR_PAYLOADS) +@pytest.mark.parametrize("channel", ["r", "g", "b"]) +def test_a_non_numeric_colour_channel_cannot_reach_the_source(evil, channel): + el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi", + "font": "press_start", "r": 255, "g": 255, "b": 255} + el[channel] = evil + src = _generated(_payload(elements=[el])) + assert "__import__" not in src and "os.system" not in src + assert not _module_level_code(src) + + +def test_colour_channels_are_clamped_to_a_byte(): + el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi", + "font": "press_start", "r": 99999, "g": -5, "b": 128} + src = _generated(_payload(elements=[el])) + assert "(255, 0, 128)" in src, "channels were not clamped to 0-255" + + +def test_the_generated_module_still_has_no_top_level_statements(): + """The clean case: a normal payload produces only imports and a class.""" + el = {"type": "text", "id": "t1", "x": 4, "y": 4, "text": "hi", + "font": "press_start", "r": 1, "g": 2, "b": 3} + src = _generated(_payload(elements=[el])) + assert not _module_level_code(src) + assert "(1, 2, 3)" in src + + +# --- config variable keys --------------------------------------------------- + +def _with_key(key): + return {"metadata": dict(BASE_META), "elements": [], + "dataModel": {"configVars": [{"key": key, "type": "string", + "default": "x", "label": "L"}]}} + + +@pytest.mark.parametrize("key", ["class", "def", "import", "None", "True", + "lambda", "pass", "match", "case"]) +def test_a_keyword_config_key_is_named_in_the_error(key): + """ast.parse already rejected these, but as an unhelpful line number. + + "Generated code has a syntax error: invalid syntax (line 17)" tells the + user nothing about which field to fix. + """ + with pytest.raises(C.ComposerInputError) as exc: + _generated(_with_key(key)) + assert key in str(exc.value) and "keyword" in str(exc.value).lower() + + +@pytest.mark.parametrize("key", ["config", "logger", "display_manager", + "cache_manager", "plugin_id", "enabled", + "self", "update", "display"]) +def test_a_reserved_attribute_config_key_is_refused(key): + """These generate *valid* Python that silently clobbers plugin state. + + The worst is `config`: the assignment lands right after super().__init__(), + so `self.config = config.get("config", "x")` replaces the plugin's config + dict with a string and every later self.config.get(...) fails at runtime. + """ + with pytest.raises(C.ComposerInputError) as exc: + _generated(_with_key(key)) + assert key in str(exc.value) and "reserved" in str(exc.value).lower() + + +@pytest.mark.parametrize("key", ["brightness", "my_var", "_private", "x1", + "update_interval_seconds"]) +def test_ordinary_config_keys_are_still_accepted(key): + src = _generated(_with_key(key)) + assert f"self.{key} = config.get(" in src + + +def test_the_generated_config_assignment_does_not_precede_super_init(): + """Guards the reasoning behind the reserved list, not just the list.""" + src = _generated(_with_key("brightness")) + body = src.splitlines() + super_at = next(i for i, line in enumerate(body) if "super().__init__(" in line) + assign_at = next(i for i, line in enumerate(body) + if "self.brightness = config.get(" in line) + assert assign_at > super_at, ( + "config vars are assigned before super().__init__(); the reserved-name " + "list assumes they land after it") + + +# --- optional keys ---------------------------------------------------------- + +@pytest.mark.parametrize("el_type,missing", [ + ("text", "text"), ("text", "text2"), ("clock", "format"), +]) +def test_an_element_missing_an_optional_key_does_not_500(el_type, missing): + """`p` is a copy of the raw element, so an absent key stays absent. + + The defaults were applied to locals only, so manager.py.j2 rendered + `{{ el.text | tojson }}` over a jinja2.Undefined and tojson raised + TypeError -- which no handler catches, making a missing key a 500 rather + than a validation error or a sensible default. + """ + el = {"type": el_type, "id": "e1", "x": 0, "y": 0, "font": "press_start"} + src = _generated(_payload(elements=[el])) + ast.parse(src) # must still be valid Python + assert "Undefined" not in src + + +def test_a_clock_without_a_format_uses_the_documented_default(): + el = {"type": "clock", "id": "c1", "x": 0, "y": 0, "font": "press_start"} + src = _generated(_payload(elements=[el])) + assert '"%H:%M"' in src, "the %H:%M default did not reach the generated source" + + +#: (element type, channel key, base element) for colour channels that were +#: interpolated raw rather than through _rgb_expr/_safe_int. Prefixed channels +#: (emptyR/G/B, labelR/G/B) were the ones the original r/g/b test never reached. +RAW_COLOUR_CASES = [ + ("progress_bar", "r", {"x": 0, "y": 0}), + ("progress_bar", "g", {"x": 0, "y": 0}), + ("pips", "b", {"x": 0, "y": 0}), + ("pips", "emptyR", {"x": 0, "y": 0}), + ("pips", "emptyG", {"x": 0, "y": 0}), + ("sparkline", "r", {"x": 0, "y": 0}), + ("gauge", "labelR", {"x": 0, "y": 0, "width": 32, "height": 32}), + ("gauge", "labelB", {"x": 0, "y": 0, "width": 32, "height": 32}), +] + + +@pytest.mark.parametrize("etype,channel,base", RAW_COLOUR_CASES) +@pytest.mark.parametrize("evil", EXPR_PAYLOADS) +def test_a_prefixed_colour_channel_cannot_reach_the_source(etype, channel, base, evil): + """Five tuples were built with f"({el.get('r', 100)}, ...)" -- no coercion. + + The pre-existing colour test only covered r/g/b on a text element, so the + prefixed channels and the four other types were never exercised. + """ + el = {"type": etype, "id": "e1", **base} + el[channel] = evil + src = _generated(_payload(elements=[el])) + assert "__import__" not in src, f"{etype}.{channel}={evil!r} reached the source" + assert "os.system" not in src + assert not _module_level_code(src) + + +@pytest.mark.parametrize("value", [float("inf"), float("-inf"), float("nan")]) +@pytest.mark.parametrize("field", ["x", "y", "width", "height"]) +def test_a_non_finite_dimension_does_not_escape_as_an_unhandled_error(field, value): + """json.loads accepts Infinity/NaN and Flask passes them through, so a + payload can hand _safe_int a non-finite float. int(inf) raises + OverflowError -- neither ValueError nor ComposerInputError -- so it escaped + both handlers and surfaced as a 500 with a traceback instead of a 422.""" + el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8} + el[field] = value + src = _generated(_payload(elements=[el])) # must not raise + # A non-finite value must be replaced by the default, not spelled into the + # source. Word-boundary match: "info" in self.logger.info contains "inf". + assert not re.search(r"\b(inf|nan|Infinity|NaN)\b", src), \ + f"{field}={value!r} leaked a non-finite literal into the source" + assert not _module_level_code(src) + + +@pytest.mark.parametrize("bad_id", [ + 'x = __import__("os").system("id") #', + "x\nimport os\n_y", + "x[0]", + "", + "a" * 200, +]) +def test_a_marquee_id_cannot_become_code(bad_id): + """data_key is spliced UNQUOTED into variable names + (_{{ data_key }}_text = ...), so a non-identifier id landed in the source + as code. ast.parse caught it, but the caller then got an opaque + "Generated code has a syntax error" rather than being told the id is bad.""" + el = {"type": "marquee", "id": bad_id, "x": 0, "y": 0, "text": "hi"} + src = _generated(_payload(elements=[el])) # must not raise + assert "__import__(" not in src + assert "os.system(" not in src + assert not _module_level_code(src) diff --git a/test/test_composer_empty_block.py b/test/test_composer_empty_block.py new file mode 100644 index 00000000..954d391b --- /dev/null +++ b/test/test_composer_empty_block.py @@ -0,0 +1,80 @@ +"""An element the template cannot draw must not produce an empty `if` block. + +manager.py.j2 wraps each element in `if width >= N:` (breakpoint) and/or +`if int(time.time() * 2) % 2:` (blink), and the body comes from the per-type +branches. A type with no branch contributed nothing, so the wrapper opened a +block with no statements in it. ast.parse in _generate_plugin_files then +failed and the caller was told only: + + Generated code has a syntax error: expected an indented block after + 'if' statement on line 49 + +which names a line of generated source the user never sees. Confirmed against +the code before the fix with a `group` element carrying minWidth. + +Two defences, both covered here: _preprocess_elements drops types the template +has no branch for, and the template emits a `pass` fallback so a type added to +the canvas before its branch exists degrades to a no-op instead of a broken +plugin. +""" +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from web_interface.blueprints import composer as C # noqa: E402 + +TEMPLATE = (Path(__file__).resolve().parent.parent + / "web_interface/templates/v3/composer/manager.py.j2") + +BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a", + "version": "1.0.0", "description": "d"} + + +def generate(element): + return C._generate_plugin_files({ + "metadata": BASE_META, + "elements": [element], + "dataModel": {"configVars": []}, + }) + + +@pytest.mark.parametrize("wrapper", [ + {"minWidth": 64}, # breakpoint block + {"blink": True}, # blink block + {"minWidth": 64, "blink": True}, # both, nested +]) +@pytest.mark.parametrize("etype", ["group", "widget_9000", "section"]) +def test_undrawable_element_does_not_break_generation(etype, wrapper): + element = {"type": etype, "x": 0, "y": 0, "color": "#ffffff", **wrapper} + files = generate(element) # must not raise ComposerInputError + assert "manager.py" in files + + +def test_drawable_element_still_renders_inside_a_breakpoint(): + files = generate({"type": "text", "text": "hi", "x": 0, "y": 0, + "minWidth": 64, "color": "#ffffff"}) + src = files["manager.py"] + assert "if width >= 64:" in src + assert "draw_text" in src + + +def test_renderable_types_match_the_template_branches(): + """The constant and the template must agree. + + A type listed in the constant with no branch emits an empty block (the bug + above); a type with a branch but missing from the constant is silently + dropped from every generated plugin. Neither is visible without this check. + """ + branches = set(re.findall(r"el\.type == '([a-z_]+)'", TEMPLATE.read_text())) + assert branches == set(C._RENDERABLE_ELEMENT_TYPES) + + +def test_template_closes_the_branch_chain_with_a_fallback(): + """Belt and braces: even if the constant drifts, no empty block escapes.""" + text = TEMPLATE.read_text() + assert "{% else %}" in text + assert "pass # element type" in text diff --git a/test/test_composer_js_contracts.py b/test/test_composer_js_contracts.py new file mode 100644 index 00000000..eb08e0de --- /dev/null +++ b/test/test_composer_js_contracts.py @@ -0,0 +1,187 @@ +"""Structural checks on the composer's JavaScript. + +There is no JS test runner in this repo, but three defects here are structural +enough to assert on the parse tree, and each was a real bug: + +- Stroke widths inside _drawElement were left in canvas pixels while the + geometry around them scaled by SCALE, so at SCALE>1 every outline rendered + thinner than one LED pixel and the preview stopped matching the panel. +- The `line` branch drew raw el.x0/el.y0/el.x1/el.y1, ignoring the anchor that + every other element type honours, so setting xAnchor moved everything except + lines -- and getBoundingBox had the same omission, leaving the hit box behind. +- Four methods mutated exactly what _snapshot() serialises (metadata, + currentPreset) without calling it. _snapshot is the only caller of + _debouncedAutosave, so those changes were lost on reload and could not be + undone. +""" +import re +from pathlib import Path + +import pytest + +tree_sitter = pytest.importorskip("tree_sitter") +tree_sitter_javascript = pytest.importorskip("tree_sitter_javascript") + +JS_DIR = Path(__file__).resolve().parent.parent / "web_interface/static/v3/js/composer" +CANVAS = JS_DIR / "composer-canvas.js" +APP = JS_DIR / "composer-app.js" + + +def _function_source(path: Path, name: str) -> str: + """Return the source of a top-level function declaration by name.""" + src = path.read_bytes() + lang = tree_sitter.Language(tree_sitter_javascript.language()) + tree = tree_sitter.Parser(lang).parse(src) + found = [] + + def walk(node): + if node.type == "function_declaration": + ident = node.child_by_field_name("name") + if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name: + found.append(src[node.start_byte:node.end_byte].decode()) + for c in node.children: + walk(c) + + walk(tree.root_node) + assert found, f"{name} not found in {path.name}" + return found[0] + + +def _method_source(path: Path, name: str) -> str: + """Return the source of a top-level object method by name.""" + src = path.read_bytes() + lang = tree_sitter.Language(tree_sitter_javascript.language()) + tree = tree_sitter.Parser(lang).parse(src) + found = [] + + def walk(node): + if node.type == "method_definition": + ident = node.child_by_field_name("name") + if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name: + found.append(src[node.start_byte:node.end_byte].decode()) + for c in node.children: + walk(c) + + walk(tree.root_node) + assert found, f"{name} not found in {path.name}" + return found[0] + + +def test_both_files_parse(): + lang = tree_sitter.Language(tree_sitter_javascript.language()) + parser = tree_sitter.Parser(lang) + for path in (CANVAS, APP): + tree = parser.parse(path.read_bytes()) + errors = [] + + def walk(node): + if node.type == "ERROR" or node.is_missing: + errors.append(node.start_point[0] + 1) + for c in node.children: + walk(c) + + walk(tree.root_node) + assert not errors, f"{path.name} has parse errors at lines {errors}" + + +def test_element_strokes_scale_with_scale(): + """No bare `ctx.lineWidth = 1` inside _drawElement. + + Selection handles and the grid are drawn in canvas pixels deliberately and + live in other functions, so this is scoped to the element drawing routine. + """ + body = _function_source(CANVAS, "_drawElement") + offenders = re.findall(r"ctx\.lineWidth\s*=\s*1\s*;", body) + assert not offenders, f"{len(offenders)} unscaled stroke width(s) in _drawElement" + + +def test_line_branch_applies_the_anchor_offset(): + """Scoped to _drawElement. + + getBoundingBox has its own `case 'line': {` and appears first in the file, + so searching the whole text found *that* branch -- this assertion passed + with the draw branch's anchor offset removed. Verified: stripping it and + re-running gave 11/11 green. + """ + body = _function_source(CANVAS, "_drawElement") + line_branch = body[body.index("case 'line': {"):] + line_branch = line_branch[:line_branch.index("case 'divider'")] + assert "ax - el.x0" in line_branch and "ay - el.y0" in line_branch, \ + "line drawing ignores xAnchor/yAnchor" + assert "moveTo(el.x0 * s" not in line_branch, \ + "line still drawn from unanchored endpoints" + + +def test_line_bounding_box_applies_the_anchor_offset(): + """The companion to the above: scoped to getBoundingBox specifically, so + the two tests cannot both be satisfied by the same branch.""" + body = _function_source(CANVAS, "getBoundingBox") + box = body[body.index("case 'line'"):] + box = box[:box.index("case 'divider'")] + assert "ax - el.x0" in box, "line bounding box ignores the anchor" + + +@pytest.mark.parametrize("method", [ + "onBgColorChange", # mutates metadata.bgColor + "setCustomSize", # mutates currentPreset / MATRIX_W / MATRIX_H + "changePreset", # mutates currentPreset / MATRIX_W / MATRIX_H + "applyPresetLabel", # same, for sizes not in DISPLAY_PRESETS + "onColorChange", # the one that was already fixed — keeps it fixed +]) +def test_state_mutations_take_a_snapshot(method): + body = _method_source(APP, method) + assert "_snapshot()" in body, \ + f"{method} changes snapshotted state without calling _snapshot()" + assert "isDirty = true" in body, f"{method} does not mark the design dirty" + + +@pytest.mark.parametrize("method", ["changePreset", "applyPresetLabel"]) +def test_restore_path_stays_snapshot_free(method): + """_applyState and loadTemplate call these with {silent: true} while + restoring; snapshotting there would push restore steps onto the undo stack + and re-autosave the state just loaded.""" + body = _method_source(APP, method) + assert "opts.silent" in body, f"{method} lost its silent guard" + snap = body.index("_snapshot()") + guard = body.index("!opts.silent") + assert guard < snap, f"{method} snapshots outside the !opts.silent guard" + + +TEMPLATE_HTML = (Path(__file__).resolve().parent.parent + / "web_interface/templates/v3/composer.html") + +#: The six toolbar buttons and the wrapper each must call. +ALIGN_BUTTONS = ["alignLeft", "alignHCenter", "alignRight", + "alignTop", "alignVCenter", "alignBottom"] + + +def test_alignment_buttons_use_the_anchor_clearing_path(): + """Two alignment implementations existed and the toolbar used the wrong one. + + The legacy alignElement(dir) set el.x/el.y but left xAnchor/yAnchor in + place. resolveAnchor turns anchor='right' into `dim - val`, so "align left" + (el.x = 0) resolved to x = MATRIX_W -- the element jumped to the far right + edge instead. _alignElement clears the anchor first, so the stored value is + absolute, and it also updates el.x0/el.y0 so lines actually move. + """ + html = TEMPLATE_HTML.read_text() + for wrapper in ALIGN_BUTTONS: + assert f"{wrapper}()" in html, f"toolbar does not call {wrapper}()" + assert not re.search(r"[^_]alignElement\(", html), \ + "toolbar still calls the legacy alignElement()" + + +def test_the_legacy_alignelement_is_gone(): + """Leaving it in place invites the toolbar drifting back to it.""" + src = APP.read_text() + assert not re.search(r"^\s{4}alignElement\(dir\)", src, re.M), \ + "legacy alignElement(dir) still defined" + + +def test_align_clears_the_anchor_and_moves_line_endpoints(): + body = _method_source(APP, "_alignElement") + assert "xAnchor = null" in body and "yAnchor = null" in body, \ + "_alignElement no longer clears the anchor, so aligning an anchored " \ + "element resolves to the wrong edge" + assert "el.x0" in body and "el.y0" in body, \ + "_alignElement no longer moves line endpoints" diff --git a/test/test_composer_path_containment.py b/test/test_composer_path_containment.py new file mode 100644 index 00000000..f0f760b6 --- /dev/null +++ b/test/test_composer_path_containment.py @@ -0,0 +1,225 @@ +"""A composer plugin id must never resolve outside the plugins directory. + +CodeQL reported sixteen high-severity py/path-injection alerts against +web_interface/blueprints/composer.py: a request-supplied plugin_id reaching +Path(plugins_dir) / plugin_id, which is then created, written to, deleted +(shutil.rmtree) and read back. + +The id was already validated by an anchored regex, so every traversal payload +was in fact rejected. What was missing was the guarantee living *with* the path +building rather than in a regex several hundred lines away -- loosen that regex +later and the traversal opens silently, with nothing at the filesystem boundary +to catch it. _plugin_dir() closes that, and is the form static analysis can see. +""" +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from web_interface.blueprints import composer as C # noqa: E402 + +#: Anything that has ever been used to climb out of a directory. +TRAVERSAL = [ + "../../etc/passwd", "..", ".", "a/../../etc", "good/../../..", + "/etc/passwd", "//etc/passwd", "a\\..\\..", "a%2f..%2f..", + "....//....//etc", "a/./../../etc", "~", "~root", + "plugin/../../../../../../etc/shadow", +] + +#: Rejected for shape, not traversal -- but rejected all the same. +MALFORMED = ["", "A-upper", "1-leading-digit", "-leading-dash", "has_underscore", + "has space", "has.dot", "a" * 64, "plugin\n", "plugin\n../../etc", + "\n", "plug\x00in"] + + +@pytest.fixture +def plugins_dir(tmp_path, monkeypatch): + base = tmp_path / "plugin-repos" + base.mkdir() + monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False) + return base + + +@pytest.mark.parametrize("payload", TRAVERSAL) +def test_traversal_payloads_are_refused(plugins_dir, payload): + assert C._plugin_dir(payload) is None + + +@pytest.mark.parametrize("payload", MALFORMED) +def test_malformed_ids_are_refused(plugins_dir, payload): + assert C._plugin_dir(payload) is None + + +@pytest.mark.parametrize("payload", ["a", "my-plugin", "x9", "a" * 63]) +def test_valid_ids_resolve_inside_the_base(plugins_dir, payload): + resolved = C._plugin_dir(payload) + assert resolved is not None, f"{payload!r} was rejected but is valid" + assert resolved.parent == plugins_dir.resolve(), ( + f"{payload!r} resolved to {resolved}, outside {plugins_dir}") + + +def test_no_payload_can_escape_even_if_the_regex_is_loosened(plugins_dir, monkeypatch): + """The containment check must stand on its own. + + This is the whole point of resolving at the filesystem boundary: if the id + pattern is ever relaxed, traversal must still be impossible. Replace the + regex with one that permits slashes and dots, then re-run the payloads. + """ + import re + monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z")) + import os + escaped = [] + base = os.path.realpath(str(plugins_dir)) + for payload in TRAVERSAL: + resolved = C._plugin_dir(payload) + if resolved is None: + continue + real = os.path.realpath(str(resolved)) + if real != base and os.path.commonpath([base, real]) != base: + escaped.append((payload, real)) + assert not escaped, f"these escaped the base with a loosened regex: {escaped}" + + +def test_a_sibling_directory_with_a_shared_prefix_is_not_inside(tmp_path, monkeypatch): + """commonpath, not startswith. + + "/x/plugins-evil" starts with "/x/plugins" but is a different directory, so + a prefix test would accept it. + """ + base = tmp_path / "plugins" + base.mkdir() + (tmp_path / "plugins-evil").mkdir() + monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False) + import re + # Neutralise the two layers in front so this exercises the containment + # check itself; otherwise secure_filename rejects the payload first and a + # startswith regression would go unnoticed here. + monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z")) + monkeypatch.setattr(C, "secure_filename", lambda v: v) + assert C._plugin_dir("../plugins-evil") is None + + +def test_containment_still_holds_if_the_sanitiser_is_defeated(plugins_dir, monkeypatch): + """Each layer is tested on its own, not just the stack. + + secure_filename's equality guard rejects every traversal payload before the + containment check sees it, so removing containment does not fail the other + tests -- which would make it look load-bearing when it is not. Neutralise + the regex *and* the sanitiser, and the realpath/commonpath check must still + refuse everything on its own. + """ + import re + monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z")) + monkeypatch.setattr(C, "secure_filename", lambda v: v) + import os + base = os.path.realpath(str(plugins_dir)) + escaped = [] + for payload in TRAVERSAL: + resolved = C._plugin_dir(payload) + if resolved is None: + continue + real = os.path.realpath(str(resolved)) + # Inside the base is fine -- "...." and "~" are ordinary directory + # names on Linux, so they are not escapes. What must never happen is + # landing outside the base, or on the base itself: install() rmtrees + # its target, so the plugins root resolving to a "plugin" would wipe + # every installed plugin. + if real == base or os.path.commonpath([base, real]) != base: + escaped.append((payload, real)) + assert not escaped, f"containment alone let these through: {escaped}" + + +def test_secure_filename_never_rewrites_an_accepted_id(plugins_dir): + """The sanitiser must be a no-op on everything the regex accepts. + + If secure_filename ever altered an accepted id, _plugin_dir would resolve + to a *different* plugin's directory than the caller asked for -- a silent + redirect, which is worse than a refusal. The guard turns that into a + refusal; this proves the guard never has to fire in practice. + """ + import random + from werkzeug.utils import secure_filename + random.seed(1) + alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-" + altered = [] + for _ in range(2000): + n = random.randint(1, 63) + cand = random.choice("abcdefghijklmnopqrstuvwxyz") + "".join( + random.choice(alphabet) for _ in range(n - 1)) + if C._PLUGIN_ID_RE.match(cand) and secure_filename(cand) != cand: + altered.append((cand, secure_filename(cand))) + assert not altered, f"secure_filename rewrote accepted ids: {altered[:5]}" + + +def test_a_trailing_newline_is_not_a_valid_id(): + r"""Python's `$` also matches before a trailing newline, so the original + `^...$` accepted "myplugin\n" and would have created a directory whose + name ends in one. \Z does not.""" + assert C._PLUGIN_ID_RE.match("myplugin") is not None + assert C._PLUGIN_ID_RE.match("myplugin\n") is None + + +# --- font serving ----------------------------------------------------------- + +FONT_TRAVERSAL = [ + "../../../etc/passwd", "../config/config.json", "..%2f..%2fetc%2fpasswd", + "PressStart2P-Regular.ttf/../../../etc/passwd", "/etc/passwd", "", + "PressStart2P-Regular.TTF", # case differs -> not the allowlisted name + "PressStart2P-Regular.ttf ", # trailing space +] + + +def test_serve_font_refuses_a_file_that_exists_but_is_not_allowlisted(monkeypatch, tmp_path): + """The allowlist must be what refuses it, not a missing file. + + Asserting 404 on traversal payloads proves nothing here: Flask's router + will not match a path segment containing '/', and everything else 404s + simply because no such file exists. Put a real, readable file next to the + fonts and confirm it is still refused -- that is the allowlist working. + """ + fonts = tmp_path / "assets" / "fonts" + fonts.mkdir(parents=True) + (fonts / "id_rsa.ttf").write_bytes(b"PRIVATE KEY") + monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False) + app = __import__("flask").Flask(__name__) + app.register_blueprint(C.composer_bp) + with app.test_client() as client: + resp = client.get("/api/fonts/id_rsa.ttf") + assert resp.status_code == 404, ( + "a readable non-allowlisted file was served; the allowlist is not gating") + assert b"PRIVATE KEY" not in resp.data + + +@pytest.mark.parametrize("payload", FONT_TRAVERSAL) +def test_serve_font_refuses_anything_not_allowlisted(payload, monkeypatch, tmp_path): + """The name reaching the filesystem must come from the allowlist constant. + + _ALLOWED_FONTS gates this endpoint, so nothing here was ever exploitable. + Building the path from the matched constant rather than the request value + is what makes that provable -- and it is why CodeQL reported two + high-severity py/path-injection alerts on an endpoint that was already + safe. + """ + monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False) + app = C.composer_bp.name and __import__("flask").Flask(__name__) + app.register_blueprint(C.composer_bp) + with app.test_client() as client: + resp = client.get(f"/api/fonts/{payload}") + assert resp.status_code in (404, 405, 308), ( + f"{payload!r} was not refused (status {resp.status_code})") + + +def test_serve_font_still_serves_each_allowlisted_font(monkeypatch, tmp_path): + fonts = tmp_path / "assets" / "fonts" + fonts.mkdir(parents=True) + monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False) + app = __import__("flask").Flask(__name__) + app.register_blueprint(C.composer_bp) + for name in C._ALLOWED_FONTS: + (fonts / name).write_bytes(b"\x00\x01ttf") + with app.test_client() as client: + resp = client.get(f"/api/fonts/{name}") + assert resp.status_code == 200, f"{name} should be served, got {resp.status_code}" diff --git a/web_interface/blueprints/composer.py b/web_interface/blueprints/composer.py new file mode 100644 index 00000000..843e620a --- /dev/null +++ b/web_interface/blueprints/composer.py @@ -0,0 +1,1084 @@ +""" +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 math +import keyword +import logging +import os +import re +from typing import Optional +import zipfile +from datetime import datetime +from pathlib import Path + +import jinja2 +import jsonschema +from flask import Blueprint, jsonify, render_template, request, send_file +from werkzeug.utils import secure_filename + +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[a-z][a-z0-9-]{0,62}\Z') +#: \Z, not $. Python's $ also matches just before a trailing newline, +#: so '$' would accept "myplugin\\n" and create a directory whose name +#: ends in one. Not traversal, but not a name anything should have to +#: handle either. +_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( # nosec B701 - see below + loader=jinja2.FileSystemLoader(str(template_dir)), + # These templates emit Python source, not HTML. Autoescaping would + # turn a quote in a plugin name into " inside generated code + # and break it, so it stays off deliberately -- and the safety has + # to come from the values instead. It does: every numeric value is + # coerced by _safe_int/_rgb_expr, and text that could terminate a + # string literal is rejected by _reject_source_breaking. Both are + # covered by test/test_composer_code_injection.py, which is where + # to look before relaxing any of it. + 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' + + +#: Attribute names BasePlugin (or the generated __init__) already owns. A +#: config var using one of these produces valid Python that quietly clobbers +#: the plugin's own state instead of failing loudly. +_RESERVED_ATTRS = frozenset({ + 'config', 'logger', 'display_manager', 'cache_manager', 'plugin_manager', + 'plugin_id', 'enabled', 'global_config', 'self', 'update', 'display', + 'validate_config', 'get_info', 'cleanup', +}) + + +def _reject_source_breaking(value: str, field: str) -> None: + """Refuse text that could terminate a string literal in generated source. + + Anything interpolated into manager.py inside quotes has to survive being + read back as Python. A quote, a backslash or a newline can end the literal + early and turn the remainder into executable statements. + """ + for bad, label in (('"', 'a double quote'), ("'", 'a single quote'), + ('\\', 'a backslash'), ('\n', 'a newline'), + ('\r', 'a carriage return')): + if bad in value: + raise ComposerInputError( + f'{field} cannot contain {label}.') + + +def _safe_int(value, default: int = 0, lo: int | None = None, + hi: int | None = None) -> int: + """Coerce a payload value to int, falling back rather than raising. + + Everything this module interpolates into generated Python has to go + through here first. The payload is JSON from the browser, so a field + annotated `int` can arrive as any string, and these values are formatted + straight into `manager.py` -- which /api/install writes to disk and the + plugin loader then imports and executes. An x of + + '0 or __import__("os").system("id")' + + produced `x=0 or __import__("os").system("id")` in the generated source, + which is valid Python and so passed the ast.parse check. + """ + # json.loads accepts Infinity/-Infinity/NaN by default and Flask's + # get_json passes them straight through, so a payload can hand this a + # non-finite float. int(inf) raises OverflowError, which is neither + # ValueError nor ComposerInputError -- it escaped both handlers and became + # a 500 with a traceback instead of a 422. + if isinstance(value, float) and not math.isfinite(value): + return default + try: + out = int(value) + except (TypeError, ValueError, OverflowError): + return default + if lo is not None: + out = max(lo, out) + if hi is not None: + out = min(hi, out) + return out + + +def _rgb_tuple(el: dict, keys, defaults) -> str: + """A colour tuple literal from coerced, clamped values under *keys*. + + Several element types carry prefixed channels (emptyR/G/B, labelR/G/B) + rather than r/g/b. Those were interpolated straight into the generated + source, so they were an injection route exactly like an uncoerced + dimension. Every channel now goes through _safe_int. + """ + return "(" + ", ".join( + str(_safe_int(el.get(k), d, 0, 255)) for k, d in zip(keys, defaults) + ) + ")" + + +def _rgb_expr(el: dict, dr: int = 255, dg: int = 255, db: int = 255) -> str: + """A colour tuple literal built from coerced, clamped channel values.""" + return _rgb_tuple(el, ('r', 'g', 'b'), (dr, dg, db)) + + +def _compute_pos_expr(val, 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 + """ + val = _safe_int(val, 0) + 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 + + +#: Element types manager.py.j2 has a drawing branch for. Kept next to the +#: preprocessor because the two must agree: a type here with no branch emits an +#: empty block, and a type with a branch but missing here is silently dropped. +_RENDERABLE_ELEMENT_TYPES = frozenset({ + 'text', 'dynamic_text', 'clock', 'countdown', 'rectangle', 'arc', + 'ellipse', 'pixel', 'rounded_rectangle', 'pips', 'sparkline', 'gauge', + 'marquee', 'progress_bar', +}) + + +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 + + # A type the template has no branch for still gets its breakpoint and + # blink wrappers emitted, and those would open an `if` with nothing in + # it -- ast.parse then fails and the caller is told only "Generated + # code has a syntax error". Drop it here instead. The template also + # emits a `pass` fallback, so a type added to the canvas before its + # branch exists degrades to a no-op rather than a broken plugin. + if t not in _RENDERABLE_ELEMENT_TYPES: + logger.info("composer: skipping element type %r with no template branch", t) + 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'] = _rgb_expr(el, 255, 255, 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 is a copy of the raw element, so a payload omitting these + # leaves the key absent and the template renders + # {{ el.text | tojson }} over a jinja2.Undefined. tojson then + # raises TypeError, which no handler catches -- so a missing + # key came back as a 500 rather than a validation error. + p['text'] = t1 + 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' + p['format'] = fmt1 + 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'] = _rgb_expr(el, 255, 200, 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 = _safe_int(el.get('width'), 10, 0, 4096) + h = _safe_int(el.get('height'), 8, 0, 4096) + 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(_safe_int(el.get('x1'), 127)) + p['y1_expr'] = str(_safe_int(el.get('y1'), 0)) + p['rgb_tuple'] = _rgb_expr(el, 180, 180, 180) + p['line_width'] = _safe_int(el.get('lineWidth'), 1, 1, 64) + 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'] = _rgb_tuple(el, ('r', 'g', 'b'), (100, 200, 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 = _safe_int(el.get('width'), 24, 0, 4096) + h = _safe_int(el.get('height'), 24, 0, 4096) + 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'] = _safe_int(el.get('lineWidth'), 2, 1, 64) + p['rgb_tuple'] = _rgb_expr(el, 255, 200, 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 = _safe_int(el.get('width'), 24, 0, 4096) + h = _safe_int(el.get('height'), 12, 0, 4096) + 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'] = _rgb_expr(el, 255, 255, 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 = _safe_int(el.get('width'), 24, 0, 4096) + h = _safe_int(el.get('height'), 10, 0, 4096) + 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'] = _rgb_expr(el, 255, 180, 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'] = _rgb_tuple(el, ('r', 'g', 'b'), (255, 200, 0)) + p['empty_tuple'] = _rgb_tuple(el, ('emptyR', 'emptyG', 'emptyB'), (50, 50, 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'] = _rgb_tuple(el, ('r', 'g', 'b'), (80, 200, 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 = _safe_int(el.get('width'), 32, 0, 4096) + h = _safe_int(el.get('height'), 32, 0, 4096) + 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'] = _safe_int(el.get('lineWidth'), 3, 1, 64) + p['rgb_tuple'] = _rgb_expr(el, 80, 220, 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'] = _rgb_tuple(el, ('labelR', 'labelG', 'labelB'), (200, 200, 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'] = _rgb_expr(el, 255, 255, 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. It is spliced UNQUOTED into variable names + # (_{{ data_key }}_text = ...), so anything that is not an + # identifier character lands in the generated source as code. + # ast.parse catches the result, but the caller then gets an opaque + # "Generated code has a syntax error" instead of being told the id + # is unusable. Restrict it to identifier characters and bound it. + raw_id = re.sub(r'[^A-Za-z0-9_]', '_', str(el.get('id', 0)))[:64] + p['data_key'] = f"mq_{raw_id or '0'}" + 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 ComposerInputError( + '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 ComposerInputError('Plugin name is required.') + # The template drops this straight into manager.py's module docstring. A + # name carrying a triple quote closes that docstring and everything after + # it becomes module-level code, which /api/install writes to disk and the + # loader imports and runs: + # + # Clock"""\nimport os; PWNED = os.getuid()\n""" + # -> import os <- executed on load + # PWNED = os.getuid() + # + # ast.parse further down only rejects invalid syntax, and that is valid. + _reject_source_breaking(plugin_name, 'Plugin name') + + author = metadata.get('author', '').strip() + if not author: + raise ComposerInputError('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 ComposerInputError(f'Config variable key "{key}" is not a valid Python identifier.') + # A keyword produces `self.class = ...`, which the ast.parse check + # below does catch -- but as "Generated code has a syntax error: + # invalid syntax (line 17)", which tells the user nothing about which + # field to fix. + if keyword.iskeyword(key) or keyword.issoftkeyword(key): + raise ComposerInputError( + f'Config variable key "{key}" is a Python keyword.') + # These generate *valid* code that silently shadows the plugin's own + # state. "config" is the worst: the assignment runs immediately after + # super().__init__(), so + # self.config = config.get("config", "x") + # replaces the plugin's config dict with a string and every later + # self.config.get(...) fails at runtime. + if key in _RESERVED_ATTRS: + raise ComposerInputError( + f'Config variable key "{key}" is reserved by BasePlugin.') + + 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 ComposerInputError('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 ComposerInputError(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 ComposerInputError(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': '', + } + + +class ComposerInputError(ValueError): + """A validation failure whose message is safe to show the caller. + + _generate_plugin_files raises this for input the user can fix. Anything + else reaching the handlers is unexpected, and its text may name internal + paths or library internals, so it is logged and answered generically. + """ + + +def _plugin_dir(plugin_id: str) -> Optional[Path]: + """Resolve a plugin directory, refusing anything outside plugins_dir. + + _PLUGIN_ID_RE already rejects '/', '.' and '..', so this cannot currently + fail -- every traversal payload is blocked before it gets here. It exists + anyway for two reasons: the guarantee then lives with the path building + rather than in a regex several hundred lines away, so loosening that regex + later cannot silently open a traversal; and it is the form static analysis + recognises, which is why CodeQL reported sixteen path-injection alerts + against code that was already safe. + + Returns None for a malformed id or one that escapes the base. It returns + rather than raises so the handlers answer with a fixed literal: routing a + caught exception's text into a response is what py/stack-trace-exposure + flags, and there is nothing here a caller needs beyond "that id is not ok". + """ + if not _PLUGIN_ID_RE.match(plugin_id or ''): + return None + # secure_filename strips path separators and traversal. Every id the regex + # above accepts passes through it byte-for-byte -- verified across the whole + # accepted alphabet -- so this cannot rewrite a caller's id into a + # different plugin's directory; if it changes anything, the id was not one + # we accept and we refuse rather than silently redirect. + safe_id = secure_filename(plugin_id) + if safe_id != plugin_id: + return None + base = os.path.realpath(str(composer_bp.plugins_dir)) + candidate = os.path.realpath(os.path.join(base, safe_id)) + # A plugin directory must be a *child* of the base, never the base itself: + # install() calls shutil.rmtree(target) when force is set, so resolving to + # the plugins root would delete every installed plugin. + # + # commonpath, not startswith: "/plugins-evil" starts with "/plugins" but is + # a different directory. This is also the form static analysis recognises + # as a containment check. + if candidate == base or os.path.commonpath([base, candidate]) != base: + return None + return Path(candidate) + + +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 ComposerInputError as exc: + return jsonify({'status': 'error', 'message': str(exc)}), 422 + except ValueError as exc: + # Not one of ours: the text may name internal paths or library + # internals, so log it and answer generically. + logger.exception('Unexpected error generating plugin files: %s', exc) + return jsonify({'status': 'error', 'message': 'Could not generate plugin files'}), 422 + + # .strip() to match _generate_plugin_files, which strips before it + # validates. Without it " my-plugin " generates successfully and then + # fails the id check here, which reads as a bug in the generator. + plugin_id = data.get('metadata', {}).get('id', 'plugin').strip() or '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 ComposerInputError as exc: + return jsonify({'status': 'error', 'message': str(exc)}), 422 + except ValueError as exc: + # Not one of ours: the text may name internal paths or library + # internals, so log it and answer generically. + logger.exception('Unexpected error generating plugin files: %s', exc) + return jsonify({'status': 'error', 'message': 'Could not generate plugin files'}), 422 + + plugin_id = data.get('metadata', {}).get('id', '').strip() + # _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. + target = _plugin_dir(plugin_id) + if target is None: + return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400 + 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.""" + # Build the path from the allowlist entry, not from the request value. + # They are equal strings, so this changes nothing at runtime -- but the + # name that reaches the filesystem now provably originates in a module + # constant, which is the difference between "guarded" and "not derived + # from user input at all". + allowed_name = next((f for f in sorted(_ALLOWED_FONTS) if f == font_name), None) + if allowed_name is None: + return '', 404 + if not composer_bp.project_root: + return '', 503 + font_path = Path(composer_bp.project_root) / 'assets' / 'fonts' / allowed_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: + resolved = _plugin_dir(plugin_id) + if resolved is None: + return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'}) + taken = resolved.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) + if not plugins_dir.is_dir(): + # Configured but not created yet -- a fresh install, or a bad path. + # iterdir() raises FileNotFoundError/NotADirectoryError here, which + # surfaced as a 500 rather than "no plugins". + logger.warning("Plugin directory %s does not exist", plugins_dir) + return jsonify([]) + 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 ComposerInputError as exc: + return jsonify({'status': 'error', 'message': str(exc)}), 422 + except ValueError as exc: + # Not one of ours: the text may name internal paths or library + # internals, so log it and answer generically. + logger.exception('Unexpected error generating plugin files: %s', exc) + return jsonify({'status': 'error', 'message': 'Could not generate plugin files'}), 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 + plugin_dir = _plugin_dir(plugin_id) + if plugin_dir is None: + return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400 + 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 (OSError, ValueError) as exc: + # Swallowing this left "partial import produced nothing" with no + # way to tell a malformed manifest from an absent one. + logger.warning("Failed to parse manifest.json for %s: %s", plugin_id, exc) + + 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/app.css b/web_interface/static/v3/app.css index 993902dd..70d3f979 100644 --- a/web_interface/static/v3/app.css +++ b/web_interface/static/v3/app.css @@ -426,6 +426,9 @@ a, button, input, select, textarea { .md\:hidden { display: none; } .md\:block { display: block; } .md\:w-auto { width: auto; } + /* composer.html labels its toolbar buttons `hidden md:inline`, so without + this the label is hidden at every width and the buttons stay icon-only. */ + .md\:inline { display: inline; } } @media (min-width: 1024px) { 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..01e6767f --- /dev/null +++ b/web_interface/static/v3/js/composer/composer-app.js @@ -0,0 +1,1397 @@ +/** + * 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; + // _buildPayload writes this as `preset`; older drafts and hand-edited + // files may carry `currentPreset`. Reading only the latter meant every + // saved design restored onto the default 128x32 canvas, with every + // element then drawn at the wrong place. + const savedPreset = state.preset ?? state.currentPreset; + if (savedPreset && savedPreset !== this.currentPreset) { + this.applyPresetLabel(savedPreset, { silent: true }); + } + this._nextId = Math.max(...this.elements.map(e => e.id + 1), 1); + this.selectedId = null; + this.isDirty = true; + }, + + // ── Display preset ──────────────────────────────────────────────── + /** + * Restore any saved canvas label, preset or custom. + * + * setCustomSize() stores labels like "200×50" that are deliberately not in + * DISPLAY_PRESETS, so changePreset() alone cannot round-trip them: its + * find() misses and it returns without doing anything, silently. + */ + applyPresetLabel(label, opts = {}) { + if (!label) return; + const known = window.ComposerCanvas.DISPLAY_PRESETS.some(p => p.label === label); + if (known) { this.changePreset(label, opts); return; } + const m = String(label).match(/(\d+)[×xX*,\s]+(\d+)/); + if (!m) return; + const w = Math.max(8, Math.min(512, parseInt(m[1], 10))); + const h = Math.max(8, Math.min(256, parseInt(m[2], 10))); + this.MATRIX_W = w; + this.MATRIX_H = h; + this.currentPreset = `${w}×${h}`; + this.SCALE = w <= 64 ? 6 : w <= 128 ? 4 : 2; + this._applyScale(); + if (!opts.silent) { + this.isDirty = true; + this._snapshot(); + this.render(); + } + }, + + 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.isDirty = true; + this._snapshot(); + 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; + // Carry the canvas size through too -- an imported 256x64 design + // laid out on a 128x32 canvas puts every element in the wrong place. + this._applyState({ + metadata: data.metadata, elements: data.elements, + dataModel: data.dataModel, + preset: data.preset ?? data.currentPreset, + }); + 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(); + // currentPreset is part of the snapshot; a custom size set here was + // otherwise dropped on reload and could not be undone. + this.isDirty = true; + this._snapshot(); + 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), + }; + // _snapshot serialises metadata and is the only caller of + // _debouncedAutosave, so without this the background colour was lost on + // reload and could not be undone. Same defect as onColorChange. + this.isDirty = true; + this._snapshot(); + this.render(); + }, + + // ── Canvas events ───────────────────────────────────────────────── + onCanvasMouseDown(event) { + const { lx, ly } = this._canvasToLed(event); + + // Priority 1: resize handle on selected rectangle (skip if locked) + // Gate on the same list the canvas draws handles from, or the editor + // advertises handles it will not honour. + if (window.ComposerCanvas.RESIZABLE_TYPES.includes(this.selectedElement?.type) + && !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 (window.ComposerCanvas.RESIZABLE_TYPES.includes(this.selectedElement?.type)) { + 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) { + // addElement() assigns `x`/`y` before spreading ELEMENT_DEFAULTS, and the + // line defaults define only x0/y0 -- so a line carries both, with `x` set + // to canvas/4 and x0 to 0. Drag and nudge move x0/y0 only, so preferring + // `x` here handed the drag a base it never updates and the line jumped by + // the difference on the next grab. + if (el.type === 'line') { + return { x: el.x0 ?? 0, y: el.y0 ?? 0 }; + } + 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' + || document.activeElement?.isContentEditable === true; + + // While a field has focus the browser's own editing keys win. This + // guard used to sit below, so Ctrl+C copied the selected *element* + // instead of the selected text (preventDefault stopped the real copy), + // Ctrl+V pasted an element, Ctrl+A could not select the field's + // contents, and Tab always moved the element selection rather than + // focus -- leaving no way to reach the next input from the keyboard. + if (inInput) return; + + 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 (canvas focus only -- see the guard above) + 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; + } + + 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 ───────────────────────────────────────────────────── + // ── 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 { + // encodeURIComponent: this id comes back from /composer/api/plugins, + // and any character outside the expected set would otherwise change + // which path is requested rather than being part of the id. + const resp = await fetch(`/composer/api/load/${encodeURIComponent(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); + // Matches applyPaletteColor below. Without these, a colour set through + // the picker never marked the design dirty and never took a snapshot -- + // _debouncedAutosave only runs from _snapshot -- so the change was lost + // on reload and could not be undone. + this.isDirty = true; + this._snapshot(); + 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..b07c26b8 --- /dev/null +++ b/web_interface/static/v3/js/composer/composer-canvas.js @@ -0,0 +1,804 @@ +/** + * 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; + + //: Element types the canvas draws resize handles for. Exported because the + //: editor has to gate its resize and hover behaviour on exactly this list -- + //: the two had drifted, so handles appeared on five shapes that could not + //: actually be resized. + const RESIZABLE_TYPES = ['rectangle', 'rounded_rectangle', 'ellipse', 'arc', + 'gauge', 'sparkline']; + + 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': { + // Same anchor offset the draw path applies, or the hit box sits where + // the line used to be. + const dx = ax - el.x0, dy = ay - el.y0; + return { + x: Math.min(el.x0, el.x1) + dx, y: Math.min(el.y0, el.y1) + dy, + 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': { + // Was 0x0, so the element was unselectable except through the 3px + // hit-test padding and drew nothing at all -- a user adding one from + // the palette saw an empty canvas. + // Same font resolution as the draw case below, or the box will not + // match the glyphs: getBoundingBox's shared `finfo` falls back to + // press_start, and a section has no font of its own. + const sinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six; + const label = el.label || 'Section'; + return { x: ax, y: ay, w: label.length * sinfo.charW, h: sinfo.sizePx }; + } + 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 (!RESIZABLE_TYPES.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 'section': { + // A design-time label: it marks a region for the author and is not + // emitted into the generated plugin. There was no case here at all, + // so adding "Section Label" from the palette drew nothing and left + // the user with an apparently broken control. + const sfinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six; + ctx.font = `${sfinfo.sizePx * s}px ${sfinfo.family}`; + ctx.fillStyle = `rgba(${el.r ?? 120},${el.g ?? 120},${el.b ?? 120},0.85)`; + ctx.textBaseline = 'top'; + ctx.fillText(el.label || 'Section', ax * s, ay * s); + break; + } + 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})`; + // 1 LED pixel, not 1 canvas pixel: at SCALE>1 an unscaled stroke + // renders thinner than the geometry it outlines. + ctx.lineWidth = s; + ctx.strokeRect(rx, ry, rw, rh); + } + break; + } + + case 'ellipse': { + const cx = (ax + el.width / 2) * s; + const cy = (ay + el.height / 2) * s; + const lwPx = s; // 1 LED pixel + // Inset by half the stroke, which straddles the path, so the outline + // stays within the element's bounds. + const rx = Math.max(0, (el.width / 2) * s - (el.hasOutline ? lwPx / 2 : 0)); + const ry = Math.max(0, (el.height / 2) * s - (el.hasOutline ? lwPx / 2 : 0)); + 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 = lwPx; + ctx.stroke(); + } + break; + } + + case 'arc': { + const cx = (ax + el.width / 2) * s; + const cy = (ay + el.height / 2) * s; + const lwPx = Math.max(1, el.lineWidth || 2) * s; + const rx = Math.max(0, (el.width / 2) * s - lwPx / 2); + const ry = Math.max(0, (el.height / 2) * s - lwPx / 2); + // 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 = lwPx; + 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 = s; + ctx.stroke(); + } + break; + } + + case 'line': { + // ax/ay resolve from el.x0 for a line, so this is the anchor offset. + // Without it a line stayed put while every other type moved. + const dx = ax - el.x0, dy = ay - el.y0; + ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.lineWidth = Math.max(1, el.lineWidth || 1) * s; + ctx.beginPath(); + ctx.moveTo((el.x0 + dx) * s, (el.y0 + dy) * s); + ctx.lineTo((el.x1 + dx) * s, (el.y1 + dy) * s); + ctx.stroke(); + break; + } + + case 'divider': { + const isH = (el.orientation || 'horizontal') === 'horizontal'; + ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.lineWidth = s; + 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)); + // rx/ry are canvas pixels ((gw/2)*s) but lw is LED pixels, so + // insetting by lw/2 under-corrected by the scale factor while the + // stroke was drawn at lw*s -- the arc spilled outside the element's + // reported bounding box at any SCALE > 1, and the preview stopped + // matching the generated PIL output. + const lwPx = lw * s; + 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 - lwPx / 2, ry - lwPx / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false); + ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`; + ctx.lineWidth = lwPx; + ctx.stroke(); + } + // Fill arc + if (pct > 0) { + ctx.beginPath(); + ctx.ellipse(cx, cy, rx - lwPx / 2, ry - lwPx / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false); + ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; + ctx.lineWidth = lwPx; + 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 = s; + 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 (RESIZABLE_TYPES.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, RESIZABLE_TYPES, + }; +})(); diff --git a/web_interface/templates/v3/composer.html b/web_interface/templates/v3/composer.html new file mode 100644 index 00000000..053c316b --- /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..87d9fa9a --- /dev/null +++ b/web_interface/templates/v3/composer/manager.py.j2 @@ -0,0 +1,300 @@ +""" +{{ 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 }} ) +{% else %} +{{ pi }}pass # element type "{{ el.type }}" draws nothing +{% endif %} +{% endfor %} + # ── End elements ─────────────────────────────────────────────── + + self.display_manager.update_display() + + except Exception as e: + self.logger.error('Display error: %s', e, exc_info=True)