Files
LEDMatrix/web_interface/static/v3/js/composer/composer-app.js
T
ChuckandClaude Fable 5 73a5304194 chore: remove dead modules and unused dependencies (~1,180 LOC)
Deletions, each re-verified with a fresh repo-wide grep (core, web,
scripts, docs, plugin monorepo) immediately before removal:

Modules with zero live importers:
- src/background_cache_mixin.py + src/generic_cache_mixin.py (134+150
  LOC — referenced only by each other)
- src/font_test_manager.py (134 LOC)
- src/image_utils.py (22 LOC, self-documented deprecated)
- src/layout_manager.py (408 LOC — only its own test imported it) +
  test/test_layout_manager.py
- src/common/basketball_plugin_example.py (328 LOC sample)

requirements.txt entries with zero importers in core (pre-plugin-era
manager deps): icalevents, geopy, timezonefinder, unidecode. Plus the
google-auth trio (google-auth-oauthlib, google-auth-httplib2,
google-api-python-client): their only importer is the calendar PLUGIN,
which declares all three in its own requirements.txt (verified in the
monorepo and on an installed copy) — the plugin dependency installer
owns them. Existing venvs are unaffected (removal doesn't uninstall);
fresh installs get them when calendar is installed.

Two stale references cleaned (a comment in test_pillow_compat.py, a
directory listing in HOW_TO_RUN_TESTS.md). Full suite green except the
two documented pre-existing failures (circuit_breaker mock drift, fixed
in #400; clock-simple 64x32 overflow, pre-dates this series); all core
entry modules verified importing cleanly under the emulator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam
2026-07-13 07:54:39 -04:00

1338 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* composerApp() — AlpineJS component for the Plugin Composer.
*
* Key architecture notes:
* - All mutations call _snapshot() for undo/redo support
* - Elements with anchors: drag stores offset from anchor, not raw px
* - localStorage autosaves on every mutation (debounced 1.5s)
* - composer_version in payload allows future server-side migration
*/
// ── Template library ─────────────────────────────────────────────────────────
const COMPOSER_TEMPLATES = [
{
id: 'blank',
label: 'Blank Canvas',
description: 'Start from scratch',
icon: 'fas fa-plus-square',
preset: '128×32',
elements: [],
dataModel: { configVars: [], dataSources: [], computedVars: [] },
},
{
id: 'clock',
label: 'Digital Clock',
description: 'Large clock with date below a divider',
icon: 'fas fa-clock',
preset: '128×32',
elements: [
{ id:1, type:'clock', x:44, y:4, format:'%H:%M', font:'press_start', r:100, g:255, b:100, xAnchor:null, yAnchor:null, minWidth:0, label:'time', conditions:[] },
{ id:2, type:'divider', x:0, y:18, orientation:'horizontal', r:50, g:50, b:50, xAnchor:null, yAnchor:null, minWidth:0, label:'', conditions:[] },
{ id:3, type:'clock', x:32, y:22, format:'%A %b %d', font:'four_by_six', r:150, g:150, b:150, xAnchor:null, yAnchor:null, minWidth:0, label:'date', conditions:[] },
],
dataModel: { configVars:[], dataSources:[], computedVars:[] },
},
{
id: 'announcement',
label: 'Announcement',
description: 'Static title + configurable message text',
icon: 'fas fa-bullhorn',
preset: '128×32',
elements: [
{ id:1, type:'text', x:4, y:2, text:'ANNOUNCEMENT', font:'four_by_six', r:255, g:200, b:0, xAnchor:null, yAnchor:null, minWidth:0, label:'title', conditions:[] },
{ id:2, type:'divider', x:0, y:11, orientation:'horizontal', r:80, g:80, b:0, xAnchor:null, yAnchor:null, minWidth:0, label:'', conditions:[] },
{ id:3, type:'dynamic_text', x:4, y:15, binding:{source:'config',key:'message',format:null}, font:'four_by_six', r:255, g:255, b:255, xAnchor:null, yAnchor:null, minWidth:0, label:'message', conditions:[] },
],
dataModel: {
configVars:[{ key:'message', label:'Message', type:'string', default:'Hello World!', description:'Message to display' }],
dataSources:[], computedVars:[],
},
},
{
id: 'scoreboard',
label: 'Scoreboard',
description: 'Two-team scores with status line',
icon: 'fas fa-trophy',
preset: '128×32',
elements: [
{ id:1, type:'dynamic_text', x:2, y:2, binding:{source:'config',key:'home_team',format:null}, font:'four_by_six', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'home team', conditions:[] },
{ id:2, type:'dynamic_text', x:2, y:12, binding:{source:'config',key:'home_score',format:null}, font:'press_start', r:255,g:220,b:50, xAnchor:null,yAnchor:null,minWidth:0, label:'home score', conditions:[] },
{ id:3, type:'divider', x:64, y:0, orientation:'vertical', r:60,g:60,b:60, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] },
{ id:4, type:'dynamic_text', x:68, y:2, binding:{source:'config',key:'away_team',format:null}, font:'four_by_six', r:200,g:200,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'away team', conditions:[] },
{ id:5, type:'dynamic_text', x:68, y:12, binding:{source:'config',key:'away_score',format:null}, font:'press_start', r:100,g:180,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'away score', conditions:[] },
{ id:6, type:'dynamic_text', x:44, y:25, binding:{source:'config',key:'game_status',format:null}, font:'four_by_six', r:150,g:150,b:150, xAnchor:null,yAnchor:null,minWidth:0, label:'status', conditions:[] },
],
dataModel: {
configVars:[
{key:'home_team', label:'Home Team', type:'string', default:'HOME', description:'Home team abbreviation'},
{key:'home_score', label:'Home Score', type:'string', default:'0', description:'Home team score'},
{key:'away_team', label:'Away Team', type:'string', default:'AWAY', description:'Away team abbreviation'},
{key:'away_score', label:'Away Score', type:'string', default:'0', description:'Away team score'},
{key:'game_status',label:'Game Status', type:'string', default:'LIVE', description:'Status label'},
],
dataSources:[], computedVars:[],
},
},
{
id: 'weather',
label: 'Weather Card',
description: 'Temperature + condition + location',
icon: 'fas fa-cloud-sun',
preset: '128×32',
elements: [
{ id:1, type:'dynamic_text', x:2, y:4, binding:{source:'config',key:'temperature',format:null}, font:'press_start', r:255,g:140,b:40, xAnchor:null,yAnchor:null,minWidth:0, label:'temp', conditions:[] },
{ id:2, type:'divider', x:0, y:16, orientation:'horizontal', r:50,g:50,b:80, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] },
{ id:3, type:'dynamic_text', x:2, y:20, binding:{source:'config',key:'condition',format:null}, font:'four_by_six', r:180,g:200,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'condition', conditions:[] },
{ id:4, type:'dynamic_text', x:80, y:20, binding:{source:'config',key:'location',format:null}, font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'location', conditions:[] },
],
dataModel: {
configVars:[
{key:'temperature', label:'Temperature', type:'string', default:'72°F', description:'Current temperature'},
{key:'condition', label:'Condition', type:'string', default:'Sunny', description:'Weather condition'},
{key:'location', label:'Location', type:'string', default:'My City', description:'City name'},
],
dataSources:[], computedVars:[],
},
},
{
id: 'crypto',
label: 'Crypto Ticker',
description: 'Asset price + 24h change percentage',
icon: 'fas fa-chart-line',
preset: '128×32',
elements: [
{ id:1, type:'dynamic_text', x:2, y:2, binding:{source:'config',key:'ticker',format:null}, font:'press_start', r:255,g:165,b:0, xAnchor:null,yAnchor:null,minWidth:0, label:'ticker', conditions:[] },
{ id:2, type:'dynamic_text', x:2, y:16, binding:{source:'config',key:'price',format:null}, font:'press_start', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'price', conditions:[] },
{ id:3, type:'dynamic_text', x:88, y:16, binding:{source:'config',key:'change_pct',format:null}, font:'four_by_six', r:100,g:255,b:100, xAnchor:null,yAnchor:null,minWidth:0, label:'change', conditions:[] },
],
dataModel: {
configVars:[
{key:'ticker', label:'Ticker', type:'string', default:'BTC', description:'Asset symbol'},
{key:'price', label:'Price', type:'string', default:'$0.00', description:'Asset price'},
{key:'change_pct', label:'Change %', type:'string', default:'+0.00%', description:'24h change'},
],
dataSources:[], computedVars:[],
},
},
{
id: 'system',
label: 'System Monitor',
description: 'CPU, memory, and IP address',
icon: 'fas fa-server',
preset: '128×32',
elements: [
{ id:1, type:'text', x:2, y:2, text:'CPU', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] },
{ id:2, type:'dynamic_text', x:22, y:2, binding:{source:'config',key:'cpu_usage',format:null}, font:'four_by_six', r:100,g:255,b:100, xAnchor:null,yAnchor:null,minWidth:0, label:'cpu', conditions:[] },
{ id:3, type:'text', x:2, y:12, text:'MEM', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] },
{ id:4, type:'dynamic_text', x:22, y:12, binding:{source:'config',key:'mem_usage',format:null}, font:'four_by_six', r:100,g:180,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'mem', conditions:[] },
{ id:5, type:'text', x:2, y:22, text:'IP', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] },
{ id:6, type:'dynamic_text', x:14, y:22, binding:{source:'config',key:'ip_address',format:null}, font:'four_by_six', r:255,g:255,b:150, xAnchor:null,yAnchor:null,minWidth:0, label:'ip', conditions:[] },
],
dataModel: {
configVars:[
{key:'cpu_usage', label:'CPU %', type:'string', default:'0%', description:'CPU usage'},
{key:'mem_usage', label:'Memory %', type:'string', default:'0%', description:'Memory usage'},
{key:'ip_address', label:'IP Address',type:'string', default:'0.0.0.0', description:'Network IP'},
],
dataSources:[], computedVars:[],
},
},
{
id: 'split_panel',
label: 'Split Panel',
description: 'Left label + right value, divided',
icon: 'fas fa-columns',
preset: '128×32',
elements: [
{ id:1, type:'dynamic_text', x:2, y:4, binding:{source:'config',key:'left_label',format:null}, font:'four_by_six', r:180,g:180,b:180, xAnchor:null,yAnchor:null,minWidth:0, label:'label', conditions:[] },
{ id:2, type:'divider', x:64, y:0, orientation:'vertical', r:60,g:60,b:60, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] },
{ id:3, type:'dynamic_text', x:68, y:4, binding:{source:'config',key:'right_value',format:null}, font:'press_start', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'value', conditions:[] },
],
dataModel: {
configVars:[
{key:'left_label', label:'Label', type:'string', default:'LABEL', description:'Left panel label'},
{key:'right_value', label:'Value', type:'string', default:'--', description:'Right panel value'},
],
dataSources:[], computedVars:[],
},
},
];
// ── LED display color palette ────────────────────────────────────────────────
const LED_PALETTE = [
{ label: 'White', hex: '#ffffff', r: 255, g: 255, b: 255 },
{ label: 'Red', hex: '#ff2020', r: 255, g: 32, b: 32 },
{ label: 'Green', hex: '#20ff40', r: 32, g: 255, b: 64 },
{ label: 'Blue', hex: '#4060ff', r: 64, g: 96, b: 255 },
{ label: 'Yellow', hex: '#ffee00', r: 255, g: 238, b: 0 },
{ label: 'Orange', hex: '#ff8000', r: 255, g: 128, b: 0 },
{ label: 'Cyan', hex: '#00eeff', r: 0, g: 238, b: 255 },
{ label: 'Magenta', hex: '#ff00cc', r: 255, g: 0, b: 204 },
{ label: 'Amber', hex: '#ffb400', r: 255, g: 180, b: 0 },
{ label: 'Dim white', hex: '#888888', r: 136, g: 136, b: 136 },
{ label: 'Dark gray', hex: '#444444', r: 68, g: 68, b: 68 },
{ label: 'Off/Black', hex: '#000000', r: 0, g: 0, b: 0 },
];
// ── Autosave helpers ─────────────────────────────────────────────────────────
const LS_KEY = 'ledmatrix_composer_draft';
let _autosaveTimer = null;
function _debouncedAutosave(payload) {
clearTimeout(_autosaveTimer);
_autosaveTimer = setTimeout(() => {
try { localStorage.setItem(LS_KEY, JSON.stringify(payload)); } catch (_) {}
}, 1500);
}
// ── Main component ───────────────────────────────────────────────────────────
function composerApp() {
return {
// ── Plugin metadata ───────────────────────────────────────────────
metadata: {
id: '',
name: '',
author: '',
version: '1.0.0',
description: '',
category: 'custom',
display_duration: 15,
update_interval: 60,
api_requirements: [],
bgColor: { r: 0, g: 0, b: 0 },
},
// ── Canvas / display preset ───────────────────────────────────────
SCALE: 4,
MATRIX_W: 128,
MATRIX_H: 32,
currentPreset: '128×32',
// ── Elements ──────────────────────────────────────────────────────
elements: [],
_nextId: 1,
// ── Data model ───────────────────────────────────────────────────
dataModel: {
configVars: [],
dataSources: [],
computedVars: [],
},
// ── Undo/redo ─────────────────────────────────────────────────────
_history: [],
_historyIndex: -1,
// ── Selection & interaction ───────────────────────────────────────
selectedId: null,
_drag: { active: false },
_resize: { active: false },
// ── Canvas options ────────────────────────────────────────────────
snapToGrid: false,
snapSize: 4,
// ── Clipboard ─────────────────────────────────────────────────────
_clipboard: null,
_styleClipboard: null,
// ── UI state ──────────────────────────────────────────────────────
isDirty: false,
statusMsg: '',
statusType: 'info',
generateStatus: 'idle',
installStatus: 'idle',
showGridOverlay: true,
showGuides: false,
blinkAnimating: false,
_blinkPhase: false,
_blinkTimer: null,
_animTick: 0,
_previewValues: {},
showHelpModal: false,
_recentColors: [],
showRuler: false,
showConfigVarModal: false,
showOpenModal: false,
showTemplateModal: false,
showCodeModal: false,
_idAutoGenerated: true,
hoverInfo: '',
installedPlugins: [],
loadPluginsStatus: 'idle',
codeFiles: {},
codeTab: 'manager.py',
loadingCode: false,
newConfigVar: { key: '', label: '', type: 'string', default: '', description: '' },
// ── Computed ──────────────────────────────────────────────────────
get selectedElement() {
return this.elements.find(e => e.id === this.selectedId) ?? null;
},
get canExport() {
return this.metadata.name.trim() && this.metadata.id.trim() && this.elements.length > 0;
},
get canUndo() { return this._historyIndex > 0; },
get canRedo() { return this._historyIndex < this._history.length - 1; },
get templates() { return COMPOSER_TEMPLATES; },
get displayPresets() { return window.ComposerCanvas.DISPLAY_PRESETS; },
get palette() { return LED_PALETTE; },
// ── Pre-made variable library ─────────────────────────────────────
preMadeVarCategories: [
{ label: 'Display', vars: [
{key:'title_text', label:'Title', type:'string', default:'My Plugin', description:'Main title'},
{key:'subtitle', label:'Subtitle', type:'string', default:'', description:'Secondary line'},
{key:'message', label:'Message', type:'string', default:'Hello!', description:'Dynamic message'},
]},
{ label: 'Sports / Score', vars: [
{key:'home_score', label:'Home Score', type:'string', default:'0', description:'Home team score'},
{key:'away_score', label:'Away Score', type:'string', default:'0', description:'Away team score'},
{key:'home_team', label:'Home Team', type:'string', default:'HOME', description:'Home team abbr'},
{key:'away_team', label:'Away Team', type:'string', default:'AWAY', description:'Away team abbr'},
{key:'game_status', label:'Game Status', type:'string', default:'LIVE', description:'Status label'},
{key:'period', label:'Period', type:'string', default:'1st', description:'Period/quarter'},
{key:'time_left', label:'Time Left', type:'string', default:'15:00',description:'Game clock'},
]},
{ label: 'Weather', vars: [
{key:'temperature', label:'Temperature', type:'string', default:'72°F', description:'Current temperature'},
{key:'condition', label:'Condition', type:'string', default:'Sunny', description:'Weather condition'},
{key:'humidity', label:'Humidity', type:'string', default:'50%', description:'Humidity'},
{key:'location', label:'Location', type:'string', default:'My City',description:'Location name'},
]},
{ label: 'System / Stats', vars: [
{key:'cpu_usage', label:'CPU %', type:'string', default:'0%', description:'CPU usage'},
{key:'mem_usage', label:'Memory %', type:'string', default:'0%', description:'Memory usage'},
{key:'ip_address', label:'IP Address',type:'string', default:'0.0.0.0', description:'Network IP'},
{key:'uptime', label:'Uptime', type:'string', default:'0d 0h', description:'System uptime'},
]},
{ label: 'Crypto / Finance', vars: [
{key:'ticker', label:'Ticker', type:'string', default:'BTC', description:'Asset symbol'},
{key:'price', label:'Price', type:'string', default:'$0.00', description:'Asset price'},
{key:'change_pct', label:'Change %', type:'string', default:'+0.00%', description:'24h change'},
{key:'volume', label:'Volume', type:'string', default:'0', description:'Trading volume'},
]},
],
elementTypes: [
{ id: 'text', label: 'Text', icon: 'fas fa-font' },
{ id: 'dynamic_text', label: 'Dynamic Text', icon: 'fas fa-code' },
{ id: 'clock', label: 'Clock', icon: 'fas fa-clock' },
{ id: 'rectangle', label: 'Rectangle', icon: 'fas fa-square' },
{ id: 'rounded_rectangle', label: 'Rounded Rect', icon: 'fas fa-stop' },
{ id: 'ellipse', label: 'Ellipse', icon: 'fas fa-circle' },
{ id: 'arc', label: 'Arc', icon: 'fas fa-circle-notch' },
{ id: 'pixel', label: 'Pixel', icon: 'fas fa-dot-circle' },
{ id: 'line', label: 'Line', icon: 'fas fa-minus' },
{ id: 'divider', label: 'Divider', icon: 'fas fa-grip-lines' },
{ id: 'progress_bar', label: 'Progress Bar', icon: 'fas fa-tasks' },
{ id: 'countdown', label: 'Countdown', icon: 'fas fa-hourglass-half' },
{ id: 'marquee', label: 'Marquee', icon: 'fas fa-arrows-alt-h' },
{ id: 'gauge', label: 'Gauge', icon: 'fas fa-tachometer-alt' },
{ id: 'sparkline', label: 'Sparkline', icon: 'fas fa-chart-bar' },
{ id: 'pips', label: 'Pips / Rating', icon: 'fas fa-star-half-alt' },
{ id: 'section', label: 'Section Label', icon: 'fas fa-tag' },
],
COLOR_THEMES: [
{ label: 'Matrix', colors: [{ r:0,g:255,b:70 }, { r:0,g:180,b:50 }, { r:0,g:100,b:30 }] },
{ label: 'Neon', colors: [{ r:255,g:0,b:200 }, { r:0,g:240,b:255 }, { r:255,g:230,b:0 }] },
{ label: 'Warm', colors: [{ r:255,g:160,b:0 }, { r:255,g:80,b:0 }, { r:255,g:220,b:80 }] },
{ label: 'Arctic', colors: [{ r:100,g:200,b:255},{ r:150,g:230,b:255},{ r:200,g:240,b:255}] },
{ label: 'Retro', colors: [{ r:255,g:140,b:0 }, { r:255,g:60,b:60 }, { r:80,g:200,b:120 }] },
{ label: 'Mono', colors: [{ r:255,g:255,b:255},{ r:170,g:170,b:170},{ r:90,g:90,b:90 }] },
],
// ── Lifecycle ─────────────────────────────────────────────────────
init() {
this.$nextTick(() => {
const canvas = document.getElementById('led-canvas');
if (canvas) {
window.ComposerCanvas.init(canvas);
window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE);
this._loadFonts().then(() => {
this._tryRestoreDraft();
this._loadRecentColors();
this._snapshot();
this.render();
});
}
document.addEventListener('keydown', (e) => this._onKeyDown(e));
});
},
async _loadFonts() {
try {
const ff = new FontFace('PressStart2P', 'url(/composer/api/fonts/PressStart2P-Regular.ttf)');
document.fonts.add(await ff.load());
} catch (e) {
console.warn('PressStart2P load failed:', e);
}
},
_loadRecentColors() {
try {
const raw = localStorage.getItem('ledmatrix_composer_recent_colors');
if (raw) this._recentColors = JSON.parse(raw).slice(0, 8);
} catch (_) {}
},
_trackColor(r, g, b) {
this._recentColors = [
{ r, g, b },
...this._recentColors.filter(c => !(c.r === r && c.g === g && c.b === b)),
].slice(0, 8);
try { localStorage.setItem('ledmatrix_composer_recent_colors', JSON.stringify(this._recentColors)); } catch (_) {}
},
toggleRuler() {
this.showRuler = !this.showRuler;
this.render();
},
_tryRestoreDraft() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return;
const draft = JSON.parse(raw);
if (!draft?.composer_version) return;
if (!confirm('Resume your last unsaved session?')) {
localStorage.removeItem(LS_KEY);
return;
}
this._applyState(draft);
this._setStatus('Draft restored from last session', 'info');
} catch (_) {}
},
// ── Undo / Redo ───────────────────────────────────────────────────
_snapshot() {
const state = JSON.stringify({
metadata: this.metadata,
elements: this.elements,
dataModel: this.dataModel,
currentPreset: this.currentPreset,
});
this._history.splice(this._historyIndex + 1);
this._history.push(state);
if (this._history.length > 50) this._history.shift();
else this._historyIndex++;
_debouncedAutosave(this._buildPayload());
},
undo() {
if (!this.canUndo) return;
this._historyIndex--;
const state = JSON.parse(this._history[this._historyIndex]);
this._applyState(state);
this._setStatus('Undo', 'info');
this.render();
},
redo() {
if (!this.canRedo) return;
this._historyIndex++;
const state = JSON.parse(this._history[this._historyIndex]);
this._applyState(state);
this._setStatus('Redo', 'info');
this.render();
},
_applyState(state) {
if (state.metadata) this.metadata = state.metadata;
if (state.elements) this.elements = state.elements.map(el => ({
xAnchor: null, yAnchor: null, minWidth: 0, conditions: [],
...el,
id: el.id ?? (this._nextId++),
}));
if (state.dataModel) this.dataModel = state.dataModel;
if (state.currentPreset && state.currentPreset !== this.currentPreset) {
this.changePreset(state.currentPreset, { silent: true });
}
this._nextId = Math.max(...this.elements.map(e => e.id + 1), 1);
this.selectedId = null;
this.isDirty = true;
},
// ── Display preset ────────────────────────────────────────────────
changePreset(presetLabel, opts = {}) {
const preset = window.ComposerCanvas.DISPLAY_PRESETS.find(p => p.label === presetLabel);
if (!preset) return;
this.currentPreset = presetLabel;
this.MATRIX_W = preset.w;
this.MATRIX_H = preset.h;
this.SCALE = preset.w <= 64 ? 6 : preset.w <= 128 ? 4 : 2;
const canvas = document.getElementById('led-canvas');
if (canvas) {
window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE);
canvas.style.width = (this.MATRIX_W * this.SCALE) + 'px';
canvas.style.height = (this.MATRIX_H * this.SCALE) + 'px';
}
if (!opts.silent) this.render();
},
// ── Rendering ─────────────────────────────────────────────────────
render(opts = {}) {
window.ComposerCanvas.render(
this.elements, this.selectedId, this.MATRIX_W, this.MATRIX_H, this.SCALE,
{ bgColor: this.metadata.bgColor, showGuides: this.showGuides, showRuler: this.showRuler, blinkOff: this.blinkAnimating && this._blinkPhase, animTick: this.blinkAnimating ? this._animTick : null, previewValues: this._previewValues, ...opts },
);
},
toggleGrid() {
this.showGridOverlay = !this.showGridOverlay;
window.ComposerCanvas.setGrid(this.showGridOverlay);
this.render();
},
toggleGuides() {
this.showGuides = !this.showGuides;
this.render();
},
toggleBlinkPreview() {
this.blinkAnimating = !this.blinkAnimating;
if (this.blinkAnimating) {
this._blinkPhase = false;
this._animTick = 0;
let blinkTick = 0;
this._blinkTimer = setInterval(() => {
this._animTick++;
blinkTick++;
if (blinkTick >= 3) { this._blinkPhase = !this._blinkPhase; blinkTick = 0; }
this.render();
}, 200);
} else {
clearInterval(this._blinkTimer);
this._blinkTimer = null;
this._blinkPhase = false;
this._animTick = 0;
this.render();
}
},
downloadPng() {
const canvas = document.getElementById('led-canvas');
if (!canvas) return;
const link = document.createElement('a');
link.download = (this.metadata.id || 'composer-preview') + '.png';
link.href = canvas.toDataURL('image/png');
link.click();
},
exportDesign() {
const payload = this._buildPayload();
const json = JSON.stringify(payload, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = (this.metadata.id || 'composer-design') + '.composer.json';
link.href = url;
link.click();
URL.revokeObjectURL(url);
},
importDesign() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,.composer.json';
input.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
if (!data.composer_version) throw new Error('Not a composer file');
if (this.isDirty && !confirm('Replace current design?')) return;
this._applyState({ metadata: data.metadata, elements: data.elements, dataModel: data.dataModel });
this.isDirty = false;
this._setStatus('Design loaded', 'success');
this.render();
} catch (err) {
this._setStatus('Failed to load: ' + err.message, 'error');
}
};
reader.readAsText(file);
};
input.click();
},
// ── Zoom controls ─────────────────────────────────────────────────
zoomIn() {
if (this.SCALE >= 8) return;
this.SCALE++;
this._applyScale();
},
zoomOut() {
if (this.SCALE <= 1) return;
this.SCALE--;
this._applyScale();
},
_applyScale() {
const canvas = document.getElementById('led-canvas');
if (!canvas) return;
window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE);
canvas.style.width = (this.MATRIX_W * this.SCALE) + 'px';
canvas.style.height = (this.MATRIX_H * this.SCALE) + 'px';
this.render();
},
setCustomSize() {
const input = prompt(
'Enter canvas size as WxH (e.g. 96×48, 192×64):',
`${this.MATRIX_W}×${this.MATRIX_H}`
);
if (!input) return;
const m = input.match(/(\d+)[×xX*,\s]+(\d+)/);
if (!m) { this._setStatus('Invalid size — use WxH format', 'error'); return; }
const w = Math.max(8, Math.min(512, parseInt(m[1])));
const h = Math.max(8, Math.min(256, parseInt(m[2])));
this.MATRIX_W = w;
this.MATRIX_H = h;
this.currentPreset = `${w}×${h}`;
this.SCALE = w <= 64 ? 6 : w <= 128 ? 4 : 2;
this._applyScale();
this._setStatus(`Canvas set to ${w}×${h}`, 'info');
},
zoomFit() {
// Fit canvas to center column: subtract left (~224px) + right (~288px) panels + margins
const avW = Math.max(80, window.innerWidth - 560);
// Subtract header (44) + toolbar (~40) + layers (~180) + status (28) + padding
const avH = Math.max(40, window.innerHeight - 340);
const fitW = Math.floor(avW / this.MATRIX_W);
const fitH = Math.floor(avH / this.MATRIX_H);
this.SCALE = Math.max(1, Math.min(8, Math.min(fitW, fitH)));
this._applyScale();
},
onBgColorChange(hex) {
this.metadata.bgColor = {
r: parseInt(hex.slice(1, 3), 16),
g: parseInt(hex.slice(3, 5), 16),
b: parseInt(hex.slice(5, 7), 16),
};
this.render();
},
// ── Canvas events ─────────────────────────────────────────────────
onCanvasMouseDown(event) {
const { lx, ly } = this._canvasToLed(event);
// Priority 1: resize handle on selected rectangle (skip if locked)
if (this.selectedElement?.type === 'rectangle' && !this.selectedElement.locked) {
const handle = window.ComposerCanvas.getResizeHandle(
this.selectedElement, lx, ly, this.MATRIX_W, this.MATRIX_H
);
if (handle) {
const el = this.selectedElement;
this._resize = {
active: true, elemId: el.id, handle,
startMX: event.clientX, startMY: event.clientY,
startW: el.width, startH: el.height,
startX: el.x, startY: el.y,
};
return;
}
}
// Priority 2: element hit test (reverse = top layer first)
const hit = [...this.elements].reverse().find(
el => window.ComposerCanvas.hitTest(el, lx, ly, this.MATRIX_W, this.MATRIX_H)
);
if (hit) {
this.selectedId = hit.id;
if (!hit.locked) {
const stored = this._getStoredPos(hit);
this._drag = {
active: true, elemId: hit.id,
startMX: event.clientX, startMY: event.clientY,
startEX: stored.x, startEY: stored.y,
};
}
} else {
this.selectedId = null;
}
this.render();
},
onCanvasMouseMove(event) {
const { lx, ly } = this._canvasToLed(event);
this.hoverInfo = `${lx}, ${ly}`;
// Resize
if (this._resize.active) {
const dx = Math.round((event.clientX - this._resize.startMX) / this.SCALE);
const dy = Math.round((event.clientY - this._resize.startMY) / this.SCALE);
const el = this.elements.find(e => e.id === this._resize.elemId);
if (!el) return;
const h = this._resize.handle;
if (h.includes('e')) el.width = Math.max(1, this._resize.startW + dx);
if (h.includes('s')) el.height = Math.max(1, this._resize.startH + dy);
if (h.includes('w')) {
const nw = Math.max(1, this._resize.startW - dx);
el.x = this._resize.startX + (this._resize.startW - nw);
el.width = nw;
}
if (h.includes('n')) {
const nh = Math.max(1, this._resize.startH - dy);
el.y = this._resize.startY + (this._resize.startH - nh);
el.height = nh;
}
this.isDirty = true;
this.render({ showTooltip: true });
// Cursor
const canvas = document.getElementById('led-canvas');
if (canvas) canvas.style.cursor = window.ComposerCanvas.getCursorForHandle(h);
return;
}
// Move drag
if (this._drag.active) {
const dx = Math.round((event.clientX - this._drag.startMX) / this.SCALE);
const dy = Math.round((event.clientY - this._drag.startMY) / this.SCALE);
const el = this.elements.find(e => e.id === this._drag.elemId);
if (!el) return;
if (el.type === 'line') {
const newX = Math.max(0, Math.min(this.MATRIX_W - 1, this._drag.startEX + dx));
const newY = Math.max(0, Math.min(this.MATRIX_H - 1, this._drag.startEY + dy));
const offX = newX - el.x0, offY = newY - el.y0;
el.x0 = newX; el.y0 = newY;
el.x1 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x1 + offX));
el.y1 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y1 + offY));
this._drag.startEX = el.x0; this._drag.startEY = el.y0;
this._drag.startMX = event.clientX; this._drag.startMY = event.clientY;
} else {
// Store anchor-relative offset
const newStored = {
x: this._drag.startEX + dx,
y: this._drag.startEY + dy,
};
this._setStoredPos(el, newStored);
}
this.isDirty = true;
this.render({ showTooltip: true });
return;
}
// Cursor hints when hovering
const canvas = document.getElementById('led-canvas');
if (canvas) {
let cursor = 'crosshair';
if (this.selectedElement?.type === 'rectangle') {
const handle = window.ComposerCanvas.getResizeHandle(
this.selectedElement, lx, ly, this.MATRIX_W, this.MATRIX_H
);
if (handle) cursor = window.ComposerCanvas.getCursorForHandle(handle);
}
if (cursor === 'crosshair') {
const hit = [...this.elements].reverse().find(
el => window.ComposerCanvas.hitTest(el, lx, ly, this.MATRIX_W, this.MATRIX_H)
);
if (hit) cursor = 'move';
}
canvas.style.cursor = cursor;
}
},
onCanvasMouseUp() {
if (this._resize.active || this._drag.active) {
this._resize = { active: false };
this._drag = { active: false };
this._snapshot();
}
const canvas = document.getElementById('led-canvas');
if (canvas) canvas.style.cursor = 'crosshair';
},
onCanvasMouseLeave() {
this.hoverInfo = '';
this.onCanvasMouseUp();
},
_canvasToLed(event) {
const rect = event.target.getBoundingClientRect();
const lx = Math.max(0, Math.min(this.MATRIX_W - 1, Math.floor((event.clientX - rect.left) / this.SCALE)));
const ly = Math.max(0, Math.min(this.MATRIX_H - 1, Math.floor((event.clientY - rect.top) / this.SCALE)));
return { lx: this._snap(lx), ly: this._snap(ly) };
},
_snap(v) {
if (!this.snapToGrid || this.snapSize < 2) return v;
return Math.round(v / this.snapSize) * this.snapSize;
},
// Anchor-aware position storage: x/y stored as offset from anchor
_getStoredPos(el) {
return { x: el.x ?? el.x0 ?? 0, y: el.y ?? el.y0 ?? 0 };
},
_setStoredPos(el, { x, y }) {
if (this.snapToGrid && this.snapSize > 0) {
x = Math.round(x / this.snapSize) * this.snapSize;
y = Math.round(y / this.snapSize) * this.snapSize;
}
const clampX = v => Math.max(-this.MATRIX_W, Math.min(this.MATRIX_W * 2, v));
const clampY = v => Math.max(-this.MATRIX_H, Math.min(this.MATRIX_H * 2, v));
if (el.type === 'line') {
el.x0 = clampX(x); el.y0 = clampY(y);
} else {
el.x = clampX(x); el.y = clampY(y);
}
},
// ── Alignment ─────────────────────────────────────────────────────
_alignElement(el, axis, mode) {
const bb = window.ComposerCanvas.getBoundingBox(el, this.MATRIX_W, this.MATRIX_H);
if (!bb) return;
if (axis === 'x') {
const newX = mode === 'start' ? 0
: mode === 'center' ? Math.round((this.MATRIX_W - bb.w) / 2)
: this.MATRIX_W - bb.w;
// Clear x-anchor so stored x IS the absolute position
if ('xAnchor' in el) el.xAnchor = null;
el.x = Math.round(newX);
if (el.type === 'line') el.x0 = Math.round(newX);
} else {
const newY = mode === 'start' ? 0
: mode === 'center' ? Math.round((this.MATRIX_H - bb.h) / 2)
: this.MATRIX_H - bb.h;
if ('yAnchor' in el) el.yAnchor = null;
el.y = Math.round(newY);
if (el.type === 'line') el.y0 = Math.round(newY);
}
this._snapshot();
this.isDirty = true;
this.render();
},
alignLeft() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'start'); },
alignHCenter() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'center'); },
alignRight() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'end'); },
alignTop() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'start'); },
alignVCenter() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'center'); },
alignBottom() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'end'); },
// ── Keyboard shortcuts ────────────────────────────────────────────
_onKeyDown(e) {
const tag = document.activeElement?.tagName;
const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
// Ctrl/Cmd combos work everywhere
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); this.undo(); return; }
if ((e.key === 'y') || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); this.redo(); return; }
if (e.key === 'd' && this.selectedId) { e.preventDefault(); this.duplicateElement(this.selectedId); return; }
if (e.key === 'c' && this.selectedId && !e.shiftKey) { e.preventDefault(); this.copyElement(); return; }
if (e.key === 'c' && this.selectedId && e.shiftKey) { e.preventDefault(); this.copyStyle(); return; }
if (e.key === 'v' && this._clipboard && !e.shiftKey) { e.preventDefault(); this.pasteElement(); return; }
if (e.key === 'v' && this._styleClipboard && e.shiftKey) { e.preventDefault(); this.pasteStyle(); return; }
if (e.key === 'a') { e.preventDefault(); if (this.elements.length) { this.selectedId = this.elements[0].id; this.render(); } return; }
}
// Tab cycles through elements regardless of input focus
if (e.key === 'Tab' && this.elements.length) {
e.preventDefault();
const idx = this.elements.findIndex(el => el.id === this.selectedId);
const next = e.shiftKey
? (idx <= 0 ? this.elements.length - 1 : idx - 1)
: (idx >= this.elements.length - 1 ? 0 : idx + 1);
this.selectedId = this.elements[next].id;
this.render();
return;
}
if (inInput) return;
const dist = e.shiftKey ? 5 : (this.snapToGrid && this.snapSize >= 2 ? this.snapSize : 1);
if (e.key === 'ArrowLeft') { e.preventDefault(); this.nudge(-dist, 0); }
if (e.key === 'ArrowRight') { e.preventDefault(); this.nudge(dist, 0); }
if (e.key === 'ArrowUp') { e.preventDefault(); this.nudge(0, -dist); }
if (e.key === 'ArrowDown') { e.preventDefault(); this.nudge(0, dist); }
if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedId) {
e.preventDefault();
this.removeElement(this.selectedId);
}
if (e.key === 'Escape') {
if (this.showHelpModal) { this.showHelpModal = false; return; }
this.selectedId = null; this.render();
}
if (e.key === '?') { this.showHelpModal = !this.showHelpModal; }
if (e.key === 'g' || e.key === 'G') { this.snapToGrid = !this.snapToGrid; }
if (e.key === ']' && this.selectedId) { this.bringToFront(this.selectedId); }
if (e.key === '[' && this.selectedId) { this.sendToBack(this.selectedId); }
},
nudge(dx, dy) {
const el = this.selectedElement;
if (!el) return;
if (el.locked) { this._setStatus('Unlock element to move it', 'info'); return; }
if (el.type === 'line') {
el.x0 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x0 + dx));
el.y0 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y0 + dy));
el.x1 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x1 + dx));
el.y1 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y1 + dy));
} else if (el.type === 'divider') {
if (el.orientation === 'horizontal') el.y = Math.max(0, Math.min(this.MATRIX_H - 1, (el.y ?? 0) + dy));
else el.x = Math.max(0, Math.min(this.MATRIX_W - 1, (el.x ?? 0) + dx));
} else {
el.x = (el.x ?? 0) + dx;
el.y = (el.y ?? 0) + dy;
}
this.isDirty = true;
this._snapshot();
this.render();
},
// ── Element management ────────────────────────────────────────────
addElement(type) {
const defaults = window.ComposerCanvas.ELEMENT_DEFAULTS[type];
if (!defaults) return;
const el = {
id: this._nextId++,
type,
label: '',
conditions: [],
xAnchor: null, yAnchor: null, minWidth: 0,
x: Math.floor(this.MATRIX_W / 4),
y: Math.floor(this.MATRIX_H / 2) - 4,
...structuredClone(defaults),
};
if (type === 'line') {
const cx = Math.floor(this.MATRIX_W / 4), cy = Math.floor(this.MATRIX_H / 2);
el.x0 = cx; el.y0 = cy;
el.x1 = Math.min(this.MATRIX_W - 1, cx + 30); el.y1 = cy;
el.x = cx; el.y = cy;
}
this.elements.push(el);
this.selectedId = el.id;
this.isDirty = true;
this._snapshot();
this.render();
},
duplicateElement(id) {
const el = this.elements.find(e => e.id === id);
if (!el) return;
const copy = {
...structuredClone(el),
id: this._nextId++,
label: el.label ? el.label + ' copy' : '',
};
// Offset slightly so it's visually distinct
if (copy.type === 'line') { copy.x0 += 4; copy.y0 += 4; copy.x1 += 4; copy.y1 += 4; }
else { copy.x = (copy.x ?? 0) + 4; copy.y = (copy.y ?? 0) + 4; }
const idx = this.elements.findIndex(e => e.id === id);
this.elements.splice(idx + 1, 0, copy);
this.selectedId = copy.id;
this.isDirty = true;
this._snapshot();
this.render();
},
copyElement() {
if (!this.selectedElement) return;
this._clipboard = structuredClone(this.selectedElement);
this._setStatus(`Copied: ${this.selectedElement.label || this.selectedElement.type}`, 'info');
},
copyStyle() {
const el = this.selectedElement;
if (!el) return;
this._styleClipboard = { r: el.r, g: el.g, b: el.b, font: el.font };
this._setStatus('Style copied — select another element and paste style', 'info');
},
pasteStyle() {
const el = this.selectedElement;
if (!el || !this._styleClipboard) return;
const { r, g, b, font } = this._styleClipboard;
if (r !== undefined) { el.r = r; el.g = g; el.b = b; }
if (font !== undefined && el.font !== undefined) el.font = font;
this.isDirty = true;
this._snapshot();
this.render();
this._setStatus('Style pasted', 'success');
},
pasteElement() {
if (!this._clipboard) return;
const copy = structuredClone(this._clipboard);
copy.id = this._nextId++;
if (copy.type === 'line') { copy.x0 += 6; copy.y0 += 6; copy.x1 += 6; copy.y1 += 6; }
else { copy.x = (copy.x ?? 0) + 6; copy.y = (copy.y ?? 0) + 6; }
this.elements.push(copy);
this.selectedId = copy.id;
this.isDirty = true;
this._snapshot();
this.render();
this._setStatus('Pasted element', 'success');
},
removeElement(id) {
this.elements = this.elements.filter(e => e.id !== id);
if (this.selectedId === id) this.selectedId = null;
this.isDirty = true;
this._snapshot();
this.render();
},
moveElementUp(id) {
const idx = this.elements.findIndex(e => e.id === id);
if (idx < this.elements.length - 1) {
[this.elements[idx], this.elements[idx + 1]] = [this.elements[idx + 1], this.elements[idx]];
this.isDirty = true;
this._snapshot();
this.render();
}
},
moveElementDown(id) {
const idx = this.elements.findIndex(e => e.id === id);
if (idx > 0) {
[this.elements[idx], this.elements[idx - 1]] = [this.elements[idx - 1], this.elements[idx]];
this.isDirty = true;
this._snapshot();
this.render();
}
},
bringToFront(id) {
const idx = this.elements.findIndex(e => e.id === id);
if (idx < this.elements.length - 1) {
this.elements.push(this.elements.splice(idx, 1)[0]);
this.isDirty = true;
this._snapshot();
this.render();
}
},
sendToBack(id) {
const idx = this.elements.findIndex(e => e.id === id);
if (idx > 0) {
this.elements.unshift(this.elements.splice(idx, 1)[0]);
this.isDirty = true;
this._snapshot();
this.render();
}
},
clearAll() {
if (this.elements.length === 0) return;
if (!confirm('Remove all elements from the canvas?')) return;
this.elements = [];
this.selectedId = null;
this.isDirty = true;
this._snapshot();
this.render();
},
// ── Alignment ─────────────────────────────────────────────────────
alignElement(dir) {
const el = this.selectedElement;
if (!el) return;
const bb = window.ComposerCanvas.getBoundingBox(el, this.MATRIX_W, this.MATRIX_H);
switch (dir) {
case 'left': el.x = 0; break;
case 'center': el.x = Math.round((this.MATRIX_W - bb.w) / 2); break;
case 'right': el.x = this.MATRIX_W - bb.w; break;
case 'top': el.y = 0; break;
case 'middle': el.y = Math.round((this.MATRIX_H - bb.h) / 2); break;
case 'bottom': el.y = this.MATRIX_H - bb.h; break;
}
this.isDirty = true;
this._snapshot();
this.render();
},
// ── Templates ─────────────────────────────────────────────────────
loadTemplate(tmpl) {
if (this.isDirty && this.elements.length > 0) {
if (!confirm('Load template? Your current canvas will be replaced.')) return;
}
this.elements = tmpl.elements.map((el, i) => ({
xAnchor: null, yAnchor: null, minWidth: 0, conditions: [],
...structuredClone(el),
id: i + 1,
}));
this._nextId = this.elements.length + 1;
this.dataModel = structuredClone(tmpl.dataModel);
if (tmpl.preset) this.changePreset(tmpl.preset, { silent: true });
this.selectedId = null;
this.showTemplateModal = false;
this.isDirty = true;
this._snapshot();
this.render();
this._setStatus(`Loaded template: ${tmpl.label}`, 'success');
},
// ── Metadata helpers ──────────────────────────────────────────────
onNameInput() {
if (this._idAutoGenerated) {
this.metadata.id = this.metadata.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
},
onIdInput() { this._idAutoGenerated = false; },
isIdValid() { return /^[a-z][a-z0-9-]{0,62}$/.test(this.metadata.id); },
// ── Config variables ──────────────────────────────────────────────
addConfigVar() {
const key = this.newConfigVar.key.trim();
if (!key) { this._setStatus('Key is required', 'error'); return; }
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
this._setStatus('Key must be a valid Python identifier', 'error'); return;
}
if (this.dataModel.configVars.find(v => v.key === key)) {
this._setStatus(`"${key}" already exists`, 'error'); return;
}
this.dataModel.configVars.push({ ...this.newConfigVar, key });
this.newConfigVar = { key:'', label:'', type:'string', default:'', description:'' };
this.showConfigVarModal = false;
this.isDirty = true;
this._snapshot();
},
removeConfigVar(key) {
const bound = this.elements.filter(
e => e.type === 'dynamic_text' && e.binding?.source === 'config' && e.binding?.key === key
);
if (bound.length && !confirm(`"${key}" is used by ${bound.length} element(s). Remove anyway?`)) return;
this.dataModel.configVars = this.dataModel.configVars.filter(v => v.key !== key);
this.isDirty = true;
this._snapshot();
},
_isBound(key) {
return this.elements.some(
e => e.type === 'dynamic_text' && e.binding?.source === 'config' && e.binding?.key === key
);
},
addPreMadeVar(varDef) {
if (this.dataModel.configVars.find(v => v.key === varDef.key)) {
this._setStatus(`"${varDef.key}" already added`, 'warning'); return;
}
this.dataModel.configVars.push({ ...varDef });
this.isDirty = true;
this._snapshot();
this._setStatus(`Added: ${varDef.key}`, 'success');
},
// ── Open existing plugin ──────────────────────────────────────────
async openPluginModal() {
this.showOpenModal = true;
this.installedPlugins = [];
this.loadPluginsStatus = 'loading';
try {
const resp = await fetch('/composer/api/plugins');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
this.installedPlugins = await resp.json();
this.loadPluginsStatus = 'done';
} catch (e) {
this.loadPluginsStatus = 'error';
this._setStatus('Could not load plugin list: ' + e.message, 'error');
}
},
async loadPlugin(pluginId) {
this.showOpenModal = false;
try {
const resp = await fetch(`/composer/api/load/${pluginId}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || `HTTP ${resp.status}`);
}
const data = await resp.json();
const state = data.state ?? data;
this._applyState(state);
this._idAutoGenerated = false;
this.isDirty = false;
this._snapshot();
this._setStatus(`Loaded: ${pluginId}` + (data.source === 'schema_import' ? ' (config vars only)' : ''), 'success');
this.render();
} catch (e) {
this._setStatus('Failed to load plugin: ' + e.message, 'error');
}
},
// ── Code preview ──────────────────────────────────────────────────
async showPreview() {
if (!this._validateBeforeExport()) return;
this.showCodeModal = true;
this.loadingCode = true;
this.codeFiles = {};
try {
const resp = await fetch('/composer/api/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this._buildPayload()),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.message || `HTTP ${resp.status}`);
this.codeFiles = data.files;
this.codeTab = 'manager.py';
} catch (e) {
this._setStatus('Preview failed: ' + e.message, 'error');
this.showCodeModal = false;
} finally {
this.loadingCode = false;
}
},
// ── Color helpers ─────────────────────────────────────────────────
rgbToHex(r, g, b) {
return '#' + [r, g, b].map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('');
},
onColorChange(el, hexField, hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
if (hexField === 'fill') { el.fillR = r; el.fillG = g; el.fillB = b; }
else if (hexField === 'out') { el.outR = r; el.outG = g; el.outB = b; }
else if (hexField === 'bg') { el.bgR = r; el.bgG = g; el.bgB = b; }
else if (hexField === 'track') { el.trackR = r; el.trackG = g; el.trackB = b; }
else if (hexField === 'empty') { el.emptyR = r; el.emptyG = g; el.emptyB = b; }
else { el.r = r; el.g = g; el.b = b; }
this._trackColor(r, g, b);
this.render();
},
applyPaletteColor(el, hexField, swatch) {
const { r, g, b } = swatch;
if (hexField === 'fill') { el.fillR = r; el.fillG = g; el.fillB = b; }
else if (hexField === 'out') { el.outR = r; el.outG = g; el.outB = b; }
else if (hexField === 'bg') { el.bgR = r; el.bgG = g; el.bgB = b; }
else if (hexField === 'track') { el.trackR = r; el.trackG = g; el.trackB = b; }
else if (hexField === 'empty') { el.emptyR = r; el.emptyG = g; el.emptyB = b; }
else { el.r = r; el.g = g; el.b = b; }
this._trackColor(r, g, b);
this.isDirty = true;
this._snapshot();
this.render();
},
// ── Export & Install ──────────────────────────────────────────────
_buildPayload() {
return {
composer_version: '1.0',
metadata: this.metadata,
elements: this.elements,
dataModel: this.dataModel,
preset: this.currentPreset,
};
},
_validateBeforeExport() {
if (!this.metadata.name.trim()) { this._setStatus('Plugin name is required', 'error'); return false; }
if (!this.isIdValid()) { this._setStatus('Plugin ID is invalid', 'error'); return false; }
if (!this.metadata.author.trim()) { this._setStatus('Author is required', 'error'); return false; }
if (this.elements.length === 0) { this._setStatus('Add at least one element', 'error'); return false; }
const unbound = this.elements.filter(
e => (e.type === 'dynamic_text' || e.type === 'progress_bar') && !e.binding?.key
);
if (unbound.length) { this._setStatus(`${unbound.length} element(s) have no variable bound`, 'error'); return false; }
return true;
},
async generateZip() {
if (!this._validateBeforeExport()) return;
this.generateStatus = 'working';
try {
const resp = await fetch('/composer/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this._buildPayload()),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ message: 'Server error' }));
throw new Error(err.message || `HTTP ${resp.status}`);
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${this.metadata.id}.zip`; a.click();
URL.revokeObjectURL(url);
this.generateStatus = 'done';
this._setStatus('Plugin ZIP downloaded', 'success');
setTimeout(() => { if (this.generateStatus === 'done') this.generateStatus = 'idle'; }, 4000);
} catch (err) {
this.generateStatus = 'error';
this._setStatus(err.message, 'error');
setTimeout(() => { if (this.generateStatus === 'error') this.generateStatus = 'idle'; }, 6000);
}
},
async installLocally(force = false) {
if (!this._validateBeforeExport()) return;
this.installStatus = 'working';
try {
const resp = await fetch('/composer/api/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...this._buildPayload(), _force: force }),
});
const data = await resp.json().catch(() => ({ message: 'Server error' }));
if (resp.status === 409 && !force) {
this.installStatus = 'idle';
if (confirm(`Plugin "${this.metadata.id}" is already installed.\n\nOverwrite it with the current design?`)) {
return this.installLocally(true);
}
return;
}
if (!resp.ok) throw new Error(data.message || `HTTP ${resp.status}`);
this.installStatus = 'done';
this.isDirty = false;
localStorage.removeItem(LS_KEY);
this._setStatus(`"${this.metadata.id}" installed — enable it in the Plugin Manager`, 'success');
setTimeout(() => { if (this.installStatus === 'done') this.installStatus = 'idle'; }, 6000);
} catch (err) {
this.installStatus = 'error';
this._setStatus(err.message, 'error');
setTimeout(() => { if (this.installStatus === 'error') this.installStatus = 'idle'; }, 6000);
}
},
// ── Color themes ──────────────────────────────────────────────────
applyColorTheme(theme) {
// Distribute theme colors round-robin across text-type elements
const textTypes = ['text', 'dynamic_text', 'clock', 'countdown', 'marquee', 'gauge'];
let idx = 0;
for (const el of this.elements) {
if (textTypes.includes(el.type)) {
const c = theme.colors[idx % theme.colors.length];
el.r = c.r; el.g = c.g; el.b = c.b;
idx++;
}
}
this.isDirty = true;
this._snapshot();
this.render();
this._setStatus(`Applied theme: ${theme.label}`, 'success');
},
// ── Status ────────────────────────────────────────────────────────
_setStatus(msg, type = 'info') {
this.statusMsg = msg;
this.statusType = type;
},
statusClass() {
return {
info: 'text-gray-500',
success: 'text-green-600',
error: 'text-red-600',
warning: 'text-yellow-600',
}[this.statusType] || 'text-gray-500';
},
};
}