mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
feat: add dev preview server and CLI render script (#264)
* fix(web): wire up "Check & Update All" plugins button window.updateAllPlugins was never assigned, so the button always showed "Bulk update handler unavailable." Wire it to PluginInstallManager.updateAll(), add per-plugin progress feedback in the button text, show a summary notification on completion, and skip redundant plugin list reloads. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add dev preview server, CLI render script, and visual test display manager Adds local development tools for rapid plugin iteration without deploying to RPi: - VisualTestDisplayManager: renders real pixels via PIL (same fonts/interface as production) - Dev preview server (Flask): interactive web UI with plugin picker, auto-generated config forms, zoom/grid controls, and mock data support for API-dependent plugins - CLI render script: render any plugin to PNG for AI-assisted visual feedback loops - Updated test runner and conftest to auto-detect plugin-repos/ directory Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dev-preview): address code review issues - Use get_logger() from src.logging_config instead of logging.getLogger() in visual_display_manager.py to match project logging conventions - Eliminate duplicate public/private weather draw methods — public draw_sun/ draw_cloud/draw_rain/draw_snow now delegate to the private _draw_* variants so plugins get consistent pixel output in tests vs production - Default install_deps=False in dev_server.py and render_plugin.py — dev scripts don't need to run pip install; developers are expected to have plugin deps installed in their venv already - Guard plugins_dir fixture against PermissionError during directory iteration - Fix PluginInstallManager.updateAll() to fall back to window.installedPlugins when PluginStateManager.installedPlugins is empty (plugins_manager.js populates window.installedPlugins independently of PluginStateManager) - Remove 5 debug console.log statements from plugins_manager.js button setup and initialization code Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(scroll): fix scroll completion to prevent multi-pass wrapping Change required_total_distance from total_scroll_width + display_width to total_scroll_width alone. The scrolling image already contains display_width pixels of blank initial padding, so reaching total_scroll_width means all content has scrolled off-screen. The extra display_width term was causing 1-2+ unnecessary wrap-arounds, making the same games appear multiple times and producing a black flicker between passes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(dev-preview): address PR #264 code review findings - docs/DEV_PREVIEW.md: add bash language tag to fenced code block - scripts/dev_server.py: add MAX/MIN_WIDTH/HEIGHT constants and validate width/height in render endpoint; add structured logger calls to discover_plugins (missing dirs, hidden entries, missing manifest, JSON/OS errors, duplicate ids); add type annotations to all helpers - scripts/render_plugin.py: add MIN/MAX_DIMENSION validation after parse_args; replace prints with get_logger() calls; narrow broad Exception catches to ImportError/OSError/ValueError in plugin load block; add type annotations to all helpers and main(); rename unused module binding to _module - scripts/run_plugin_tests.py: wrap plugins_path.iterdir() in try/except PermissionError with fallback to plugin-repos/ - scripts/templates/dev_preview.html: replace non-focusable div toggles with button role="switch" + aria-checked; add keyboard handlers (Enter/Space); sync aria-checked in toggleGrid/toggleAutoRefresh - src/common/scroll_helper.py: early-guard zero total_scroll_width to keep scroll_position at 0 and skip completion/wrap logic - src/plugin_system/testing/visual_display_manager.py: forward color arg in draw_cloud -> _draw_cloud; add color param to _draw_cloud; restore _scrolling_state in reset(); narrow broad Exception catches in _load_fonts to FileNotFoundError/OSError/ImportError; add explicit type annotations to draw_text - test/plugins/test_visual_rendering.py: use context manager for Image.open in test_save_snapshot - test/plugins/conftest.py: add return type hints to all fixtures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add bandit and gitleaks pre-commit hooks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
302
scripts/dev_server.py
Normal file
302
scripts/dev_server.py
Normal file
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LEDMatrix Dev Preview Server
|
||||
|
||||
A standalone lightweight Flask app for rapid plugin development.
|
||||
Pick a plugin, tweak its config, and instantly see the rendered display.
|
||||
|
||||
Usage:
|
||||
python scripts/dev_server.py
|
||||
python scripts/dev_server.py --port 5001
|
||||
python scripts/dev_server.py --extra-dir /path/to/custom-plugin
|
||||
|
||||
Opens at http://localhost:5001
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import argparse
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# Prevent hardware imports
|
||||
os.environ['EMULATOR'] = 'true'
|
||||
|
||||
from flask import Flask, render_template, request, jsonify
|
||||
|
||||
app = Flask(__name__, template_folder=str(Path(__file__).parent / 'templates'))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Will be set from CLI args
|
||||
_extra_dirs: List[str] = []
|
||||
|
||||
# Render endpoint resource guards
|
||||
MAX_WIDTH = 512
|
||||
MAX_HEIGHT = 512
|
||||
MIN_WIDTH = 1
|
||||
MIN_HEIGHT = 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Plugin discovery
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def get_search_dirs() -> List[Path]:
|
||||
"""Get all directories to search for plugins."""
|
||||
dirs = [
|
||||
PROJECT_ROOT / 'plugins',
|
||||
PROJECT_ROOT / 'plugin-repos',
|
||||
]
|
||||
for d in _extra_dirs:
|
||||
dirs.append(Path(d))
|
||||
return dirs
|
||||
|
||||
|
||||
def discover_plugins() -> List[Dict[str, Any]]:
|
||||
"""Discover all available plugins across search directories."""
|
||||
plugins: List[Dict[str, Any]] = []
|
||||
seen_ids: set = set()
|
||||
|
||||
for search_dir in get_search_dirs():
|
||||
if not search_dir.exists():
|
||||
logger.debug("[Dev Server] Search dir missing, skipping: %s", search_dir)
|
||||
continue
|
||||
for item in sorted(search_dir.iterdir()):
|
||||
if item.name.startswith('.') or not item.is_dir():
|
||||
logger.debug("[Dev Server] Skipping non-plugin entry: %s", item)
|
||||
continue
|
||||
manifest_path = item / 'manifest.json'
|
||||
if not manifest_path.exists():
|
||||
logger.debug("[Dev Server] No manifest.json in %s, skipping", item)
|
||||
continue
|
||||
try:
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest: Dict[str, Any] = json.load(f)
|
||||
plugin_id: str = manifest.get('id', item.name)
|
||||
if plugin_id in seen_ids:
|
||||
logger.debug("[Dev Server] Duplicate plugin_id '%s' at %s, skipping", plugin_id, item)
|
||||
continue
|
||||
seen_ids.add(plugin_id)
|
||||
logger.debug("[Dev Server] Discovered plugin id=%s name=%s", plugin_id, manifest.get('name', plugin_id))
|
||||
plugins.append({
|
||||
'id': plugin_id,
|
||||
'name': manifest.get('name', plugin_id),
|
||||
'description': manifest.get('description', ''),
|
||||
'author': manifest.get('author', ''),
|
||||
'version': manifest.get('version', ''),
|
||||
'source_dir': str(search_dir),
|
||||
'plugin_dir': str(item),
|
||||
})
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("[Dev Server] JSON decode error in %s: %s", manifest_path, e)
|
||||
continue
|
||||
except OSError as e:
|
||||
logger.warning("[Dev Server] OS error reading %s: %s", manifest_path, e)
|
||||
continue
|
||||
|
||||
return plugins
|
||||
|
||||
|
||||
def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
||||
"""Find a plugin directory by ID."""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
loader = PluginLoader()
|
||||
for search_dir in get_search_dirs():
|
||||
if not search_dir.exists():
|
||||
continue
|
||||
result = loader.find_plugin_directory(plugin_id, search_dir)
|
||||
if result:
|
||||
return Path(result)
|
||||
return None
|
||||
|
||||
|
||||
def load_config_defaults(plugin_dir: 'str | Path') -> Dict[str, Any]:
|
||||
"""Extract default values from config_schema.json."""
|
||||
schema_path = Path(plugin_dir) / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return {}
|
||||
with open(schema_path, 'r') as f:
|
||||
schema = json.load(f)
|
||||
defaults: Dict[str, Any] = {}
|
||||
for key, prop in schema.get('properties', {}).items():
|
||||
if 'default' in prop:
|
||||
defaults[key] = prop['default']
|
||||
return defaults
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Routes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Serve the dev preview page."""
|
||||
return render_template('dev_preview.html')
|
||||
|
||||
|
||||
@app.route('/api/plugins')
|
||||
def api_plugins():
|
||||
"""List all available plugins."""
|
||||
return jsonify({'plugins': discover_plugins()})
|
||||
|
||||
|
||||
@app.route('/api/plugins/<plugin_id>/schema')
|
||||
def api_plugin_schema(plugin_id):
|
||||
"""Get a plugin's config_schema.json."""
|
||||
plugin_dir = find_plugin_dir(plugin_id)
|
||||
if not plugin_dir:
|
||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||
|
||||
schema_path = plugin_dir / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return jsonify({'schema': {'type': 'object', 'properties': {}}})
|
||||
|
||||
with open(schema_path, 'r') as f:
|
||||
schema = json.load(f)
|
||||
return jsonify({'schema': schema})
|
||||
|
||||
|
||||
@app.route('/api/plugins/<plugin_id>/defaults')
|
||||
def api_plugin_defaults(plugin_id):
|
||||
"""Get default config values from the schema."""
|
||||
plugin_dir = find_plugin_dir(plugin_id)
|
||||
if not plugin_dir:
|
||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||
|
||||
defaults = load_config_defaults(plugin_dir)
|
||||
defaults['enabled'] = True
|
||||
return jsonify({'defaults': defaults})
|
||||
|
||||
|
||||
@app.route('/api/render', methods=['POST'])
|
||||
def api_render():
|
||||
"""Render a plugin and return the display as base64 PNG."""
|
||||
data = request.get_json()
|
||||
if not data or 'plugin_id' not in data:
|
||||
return jsonify({'error': 'plugin_id is required'}), 400
|
||||
|
||||
plugin_id = data['plugin_id']
|
||||
user_config = data.get('config', {})
|
||||
mock_data = data.get('mock_data', {})
|
||||
skip_update = data.get('skip_update', False)
|
||||
|
||||
try:
|
||||
width = int(data.get('width', 128))
|
||||
height = int(data.get('height', 32))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'width and height must be integers'}), 400
|
||||
|
||||
if not (MIN_WIDTH <= width <= MAX_WIDTH):
|
||||
return jsonify({'error': f'width must be between {MIN_WIDTH} and {MAX_WIDTH}'}), 400
|
||||
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
||||
|
||||
# Find plugin
|
||||
plugin_dir = find_plugin_dir(plugin_id)
|
||||
if not plugin_dir:
|
||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||
|
||||
# Load manifest
|
||||
manifest_path = plugin_dir / 'manifest.json'
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Build config: schema defaults + user overrides
|
||||
config_defaults = load_config_defaults(plugin_dir)
|
||||
config = {'enabled': True}
|
||||
config.update(config_defaults)
|
||||
config.update(user_config)
|
||||
|
||||
# Create display manager and mocks
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
|
||||
# Pre-populate cache with mock data
|
||||
for key, value in mock_data.items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
# Load plugin
|
||||
loader = PluginLoader()
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
plugin_instance, module = loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
plugin_manager=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Failed to load plugin: {e}'}), 500
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Run update()
|
||||
if not skip_update:
|
||||
try:
|
||||
plugin_instance.update()
|
||||
except Exception as e:
|
||||
warnings.append(f"update() raised: {e}")
|
||||
|
||||
# Run display()
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
except Exception as e:
|
||||
errors.append(f"display() raised: {e}")
|
||||
|
||||
render_time_ms = round((time.time() - start_time) * 1000, 1)
|
||||
|
||||
return jsonify({
|
||||
'image': f'data:image/png;base64,{display_manager.get_image_base64()}',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'render_time_ms': render_time_ms,
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Main
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='LEDMatrix Dev Preview Server')
|
||||
parser.add_argument('--port', type=int, default=5001, help='Port to run on (default: 5001)')
|
||||
parser.add_argument('--host', default='127.0.0.1', help='Host to bind to (default: 127.0.0.1)')
|
||||
parser.add_argument('--extra-dir', action='append', default=[],
|
||||
help='Extra plugin directory to search (can be repeated)')
|
||||
parser.add_argument('--debug', action='store_true', help='Enable Flask debug mode')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
global _extra_dirs
|
||||
_extra_dirs = args.extra_dir
|
||||
|
||||
print(f"LEDMatrix Dev Preview Server")
|
||||
print(f"Open http://{args.host}:{args.port} in your browser")
|
||||
print(f"Plugin search dirs: {[str(d) for d in get_search_dirs()]}")
|
||||
print()
|
||||
|
||||
app.run(host=args.host, port=args.port, debug=args.debug)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
199
scripts/render_plugin.py
Normal file
199
scripts/render_plugin.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Plugin Visual Renderer
|
||||
|
||||
Loads a plugin, calls update() + display(), and saves the resulting
|
||||
display as a PNG image for visual inspection.
|
||||
|
||||
Usage:
|
||||
python scripts/render_plugin.py --plugin hello-world --output /tmp/hello.png
|
||||
python scripts/render_plugin.py --plugin clock-simple --plugin-dir plugin-repos/ --output /tmp/clock.png
|
||||
python scripts/render_plugin.py --plugin hello-world --config '{"message":"Test!"}' --output /tmp/test.png
|
||||
python scripts/render_plugin.py --plugin football-scoreboard --mock-data mock_scores.json --output /tmp/football.png
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Sequence, Union
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# Prevent hardware imports
|
||||
os.environ['EMULATOR'] = 'true'
|
||||
|
||||
# Import logger after path setup so src.logging_config is importable
|
||||
from src.logging_config import get_logger # noqa: E402
|
||||
logger = get_logger("[Render Plugin]")
|
||||
|
||||
MIN_DIMENSION = 1
|
||||
MAX_DIMENSION = 512
|
||||
|
||||
|
||||
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
|
||||
"""Find a plugin directory by searching multiple paths."""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
loader = PluginLoader()
|
||||
for search_dir in search_dirs:
|
||||
search_path = Path(search_dir)
|
||||
if not search_path.exists():
|
||||
continue
|
||||
result = loader.find_plugin_directory(plugin_id, search_path)
|
||||
if result:
|
||||
return Path(result)
|
||||
return None
|
||||
|
||||
|
||||
def load_manifest(plugin_dir: Path) -> Dict[str, Any]:
|
||||
"""Load and return manifest.json from plugin directory."""
|
||||
manifest_path = plugin_dir / 'manifest.json'
|
||||
if not manifest_path.exists():
|
||||
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_config_defaults(plugin_dir: Path) -> Dict[str, Any]:
|
||||
"""Extract default values from config_schema.json."""
|
||||
schema_path = plugin_dir / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return {}
|
||||
with open(schema_path, 'r') as f:
|
||||
schema = json.load(f)
|
||||
defaults: Dict[str, Any] = {}
|
||||
for key, prop in schema.get('properties', {}).items():
|
||||
if 'default' in prop:
|
||||
defaults[key] = prop['default']
|
||||
return defaults
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Load a plugin, call update() + display(), and save the result as a PNG image."""
|
||||
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
|
||||
parser.add_argument('--plugin', '-p', required=True, help='Plugin ID to render')
|
||||
parser.add_argument('--plugin-dir', '-d', default=None,
|
||||
help='Directory to search for plugins (default: auto-detect)')
|
||||
parser.add_argument('--config', '-c', default='{}',
|
||||
help='Plugin config as JSON string')
|
||||
parser.add_argument('--mock-data', '-m', default=None,
|
||||
help='Path to JSON file with mock cache data')
|
||||
parser.add_argument('--output', '-o', default='/tmp/plugin_render.png',
|
||||
help='Output PNG path (default: /tmp/plugin_render.png)')
|
||||
parser.add_argument('--width', type=int, default=128, help='Display width (default: 128)')
|
||||
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
|
||||
parser.add_argument('--skip-update', action='store_true',
|
||||
help='Skip calling update() (render display only)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not (MIN_DIMENSION <= args.width <= MAX_DIMENSION):
|
||||
print(f"Error: --width must be between {MIN_DIMENSION} and {MAX_DIMENSION} (got {args.width})")
|
||||
raise SystemExit(1)
|
||||
if not (MIN_DIMENSION <= args.height <= MAX_DIMENSION):
|
||||
print(f"Error: --height must be between {MIN_DIMENSION} and {MAX_DIMENSION} (got {args.height})")
|
||||
raise SystemExit(1)
|
||||
|
||||
# Determine search directories
|
||||
if args.plugin_dir:
|
||||
search_dirs = [args.plugin_dir]
|
||||
else:
|
||||
search_dirs = [
|
||||
str(PROJECT_ROOT / 'plugins'),
|
||||
str(PROJECT_ROOT / 'plugin-repos'),
|
||||
]
|
||||
|
||||
# Find plugin
|
||||
plugin_dir = find_plugin_dir(args.plugin, search_dirs)
|
||||
if not plugin_dir:
|
||||
logger.error("Plugin '%s' not found in: %s", args.plugin, search_dirs)
|
||||
return 1
|
||||
|
||||
logger.info("Found plugin at: %s", plugin_dir)
|
||||
|
||||
# Load manifest
|
||||
manifest = load_manifest(Path(plugin_dir))
|
||||
|
||||
# Parse config: start with schema defaults, then apply overrides
|
||||
config_defaults = load_config_defaults(Path(plugin_dir))
|
||||
try:
|
||||
user_config = json.loads(args.config)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Invalid JSON config: %s", e)
|
||||
return 1
|
||||
|
||||
config = {'enabled': True}
|
||||
config.update(config_defaults)
|
||||
config.update(user_config)
|
||||
|
||||
# Load mock data if provided
|
||||
mock_data = {}
|
||||
if args.mock_data:
|
||||
mock_data_path = Path(args.mock_data)
|
||||
if not mock_data_path.exists():
|
||||
logger.error("Mock data file not found: %s", args.mock_data)
|
||||
return 1
|
||||
with open(mock_data_path, 'r') as f:
|
||||
mock_data = json.load(f)
|
||||
|
||||
# Create visual display manager and mocks
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=args.width, height=args.height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
|
||||
# Pre-populate cache with mock data
|
||||
for key, value in mock_data.items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
# Load and instantiate plugin
|
||||
loader = PluginLoader()
|
||||
|
||||
try:
|
||||
plugin_instance, _module = loader.load_plugin(
|
||||
plugin_id=args.plugin,
|
||||
manifest=manifest,
|
||||
plugin_dir=Path(plugin_dir),
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
plugin_manager=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
except (ImportError, OSError, ValueError) as e:
|
||||
logger.error("Error loading plugin '%s': %s", args.plugin, e)
|
||||
return 1
|
||||
|
||||
logger.info("Plugin '%s' loaded successfully", args.plugin)
|
||||
|
||||
# Run update() then display()
|
||||
if not args.skip_update:
|
||||
try:
|
||||
plugin_instance.update()
|
||||
logger.debug("update() completed")
|
||||
except Exception as e:
|
||||
logger.warning("update() raised: %s — continuing to display()", e)
|
||||
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
logger.debug("display() completed")
|
||||
except Exception as e:
|
||||
logger.error("Error in display(): %s", e)
|
||||
return 1
|
||||
|
||||
# Save the rendered image
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
display_manager.save_snapshot(str(output_path))
|
||||
logger.info("Rendered image saved to: %s (%dx%d)", output_path, args.width, args.height)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -142,8 +142,8 @@ def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description='Run LEDMatrix plugin tests')
|
||||
parser.add_argument('--plugin', '-p', help='Test specific plugin ID')
|
||||
parser.add_argument('--plugins-dir', '-d', default='plugins',
|
||||
help='Plugins directory (default: plugins)')
|
||||
parser.add_argument('--plugins-dir', '-d', default=None,
|
||||
help='Plugins directory (default: auto-detect plugins/ or plugin-repos/)')
|
||||
parser.add_argument('--runner', '-r', choices=['unittest', 'pytest', 'auto'],
|
||||
default='auto', help='Test runner to use (default: auto)')
|
||||
parser.add_argument('--verbose', '-v', action='store_true',
|
||||
@@ -153,7 +153,27 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
plugins_dir = Path(args.plugins_dir)
|
||||
if args.plugins_dir:
|
||||
plugins_dir = Path(args.plugins_dir)
|
||||
else:
|
||||
# Auto-detect: prefer plugins/ if it has content, then plugin-repos/
|
||||
plugins_path = PROJECT_ROOT / 'plugins'
|
||||
plugin_repos_path = PROJECT_ROOT / 'plugin-repos'
|
||||
try:
|
||||
has_plugins = plugins_path.exists() and any(
|
||||
p for p in plugins_path.iterdir()
|
||||
if p.is_dir() and not p.name.startswith('.')
|
||||
)
|
||||
except PermissionError:
|
||||
print(f"Warning: cannot read {plugins_path}, falling back to plugin-repos/")
|
||||
has_plugins = False
|
||||
if has_plugins:
|
||||
plugins_dir = plugins_path
|
||||
elif plugin_repos_path.exists():
|
||||
plugins_dir = plugin_repos_path
|
||||
else:
|
||||
plugins_dir = plugins_path
|
||||
|
||||
if not plugins_dir.exists():
|
||||
print(f"Error: Plugins directory not found: {plugins_dir}")
|
||||
return 1
|
||||
|
||||
595
scripts/templates/dev_preview.html
Normal file
595
scripts/templates/dev_preview.html
Normal file
@@ -0,0 +1,595 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LEDMatrix Dev Preview</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--border-color: #475569;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--border-color: #e2e8f0;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
/* JSON Editor theme overrides */
|
||||
.je-object__container, .je-indented-panel {
|
||||
background: var(--bg-tertiary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
padding: 0.75rem !important;
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
.je-header, .je-object__title {
|
||||
color: var(--text-primary) !important;
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
|
||||
.je-form-input-label {
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
div[data-schematype] input[type="text"],
|
||||
div[data-schematype] input[type="number"],
|
||||
div[data-schematype] select,
|
||||
div[data-schematype] textarea {
|
||||
background: var(--bg-primary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 0.375rem !important;
|
||||
padding: 0.375rem 0.5rem !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
div[data-schematype] input[type="text"]:focus,
|
||||
div[data-schematype] input[type="number"]:focus,
|
||||
div[data-schematype] select:focus,
|
||||
div[data-schematype] textarea:focus {
|
||||
outline: none !important;
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Hide JSON Editor action buttons we don't need */
|
||||
.je-object__controls .json-editor-btn-collapse,
|
||||
.je-object__controls .json-editor-btn-edit_properties,
|
||||
.json-editor-btn-edit {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.json-editor-btn-add, .json-editor-btn-delete,
|
||||
.json-editor-btn-moveup, .json-editor-btn-movedown {
|
||||
background: var(--bg-tertiary) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 0.25rem !important;
|
||||
padding: 0.125rem 0.375rem !important;
|
||||
font-size: 0.7rem !important;
|
||||
}
|
||||
|
||||
/* Display preview */
|
||||
#displayPreview {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
background: repeating-conic-gradient(#1a1a2e 0% 25%, #16162a 0% 50%) 50% / 20px 20px;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Grid overlay */
|
||||
#gridCanvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.25rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch.active {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.toggle-switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: white;
|
||||
border-radius: 9999px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch.active::after {
|
||||
transform: translateX(1.25rem);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-primary); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="border-b" style="border-color: var(--border-color); background: var(--bg-secondary);">
|
||||
<div class="max-w-[1800px] mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<h1 class="text-lg font-semibold" style="color: var(--text-primary);">LEDMatrix Dev Preview</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs" style="color: var(--text-secondary);" id="statusText">Ready</span>
|
||||
<button onclick="toggleTheme()" class="px-3 py-1.5 rounded-lg text-xs font-medium"
|
||||
style="background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border-color);">
|
||||
Theme
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main layout -->
|
||||
<div class="max-w-[1800px] mx-auto px-4 py-4 flex gap-4" style="height: calc(100vh - 57px);">
|
||||
|
||||
<!-- Left panel: Plugin selection + Config -->
|
||||
<div class="w-[420px] flex-shrink-0 flex flex-col gap-4 overflow-y-auto" style="max-height: 100%;">
|
||||
|
||||
<!-- Plugin selector -->
|
||||
<div class="panel p-4">
|
||||
<label class="block text-xs font-medium mb-2" style="color: var(--text-secondary);">Plugin</label>
|
||||
<select id="pluginSelect" onchange="onPluginChange()"
|
||||
class="w-full px-3 py-2 rounded-lg text-sm"
|
||||
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);">
|
||||
<option value="">Select a plugin...</option>
|
||||
</select>
|
||||
<p id="pluginDescription" class="mt-2 text-xs" style="color: var(--text-secondary);"></p>
|
||||
</div>
|
||||
|
||||
<!-- Dimensions -->
|
||||
<div class="panel p-4">
|
||||
<label class="block text-xs font-medium mb-2" style="color: var(--text-secondary);">Display Dimensions</label>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input type="number" id="displayWidth" value="128" min="1" max="512"
|
||||
class="w-20 px-2 py-1.5 rounded text-sm text-center"
|
||||
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);"
|
||||
onchange="onConfigChange()">
|
||||
<span class="text-sm" style="color: var(--text-secondary);">x</span>
|
||||
<input type="number" id="displayHeight" value="32" min="1" max="256"
|
||||
class="w-20 px-2 py-1.5 rounded text-sm text-center"
|
||||
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);"
|
||||
onchange="onConfigChange()">
|
||||
<span class="text-xs ml-2" style="color: var(--text-secondary);">px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config form -->
|
||||
<div class="panel p-4 flex-1">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="text-xs font-medium" style="color: var(--text-secondary);">Configuration</label>
|
||||
<button onclick="resetConfig()" class="px-2 py-1 rounded text-xs"
|
||||
style="background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border-color);">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div id="configEditor"></div>
|
||||
<p id="configPlaceholder" class="text-xs italic" style="color: var(--text-secondary);">
|
||||
Select a plugin to load its configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mock data -->
|
||||
<details class="panel">
|
||||
<summary class="px-4 py-3 cursor-pointer text-xs font-medium" style="color: var(--text-secondary);">
|
||||
Mock Data (for API-dependent plugins)
|
||||
</summary>
|
||||
<div class="px-4 pb-4">
|
||||
<textarea id="mockDataInput" rows="6" placeholder='{"cache_key": {"data": "value"}}'
|
||||
class="w-full px-3 py-2 rounded-lg text-xs font-mono"
|
||||
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); resize: vertical;"
|
||||
onchange="onConfigChange()"></textarea>
|
||||
<p class="mt-1 text-xs" style="color: var(--text-secondary);">
|
||||
JSON object with cache keys. Find keys by searching plugin's manager.py for cache_manager.set() calls.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Render button -->
|
||||
<div class="flex gap-2">
|
||||
<button onclick="renderPlugin()" id="renderBtn"
|
||||
class="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style="background: var(--accent);">
|
||||
Render
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right panel: Display preview -->
|
||||
<div class="flex-1 flex flex-col gap-4 min-w-0">
|
||||
<!-- Preview -->
|
||||
<div class="panel p-4 flex-1 flex flex-col">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xs font-medium" style="color: var(--text-secondary);">Display Preview</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs" style="color: var(--text-secondary);" id="renderTimeText"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview image -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="preview-container w-full" id="previewWrapper">
|
||||
<div style="position: relative; display: inline-block;" id="previewFrame">
|
||||
<img id="displayPreview" alt="Plugin display preview"
|
||||
style="display: none; border: 1px solid var(--border-color);">
|
||||
<canvas id="gridCanvas" style="display: none;"></canvas>
|
||||
<p id="previewPlaceholder" class="text-sm" style="color: var(--text-secondary);">
|
||||
Select a plugin and click Render to preview.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex items-center gap-6 mt-4 pt-3" style="border-top: 1px solid var(--border-color);">
|
||||
<!-- Zoom -->
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<label class="text-xs whitespace-nowrap" style="color: var(--text-secondary);">Zoom</label>
|
||||
<input type="range" id="zoomSlider" min="1" max="16" value="8" step="1"
|
||||
oninput="updateZoom()" class="flex-1" style="accent-color: var(--accent);">
|
||||
<span class="text-xs w-8 text-right" style="color: var(--text-primary);" id="zoomLabel">8x</span>
|
||||
</div>
|
||||
|
||||
<!-- Grid toggle -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs" for="gridToggle" style="color: var(--text-secondary);">Grid</label>
|
||||
<button role="switch" aria-checked="false" aria-label="Toggle grid overlay"
|
||||
class="toggle-switch" id="gridToggle"
|
||||
onclick="toggleGrid()"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleGrid();}"></button>
|
||||
</div>
|
||||
|
||||
<!-- Auto-refresh toggle -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs" for="autoRefreshToggle" style="color: var(--text-secondary);">Auto</label>
|
||||
<button role="switch" aria-checked="true" aria-label="Toggle auto-refresh"
|
||||
class="toggle-switch active" id="autoRefreshToggle"
|
||||
onclick="toggleAutoRefresh()"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleAutoRefresh();}"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings/Errors -->
|
||||
<div id="messagesPanel" class="panel p-3 hidden">
|
||||
<div id="messagesList" class="text-xs font-mono space-y-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ---------- State ----------
|
||||
let jsonEditor = null;
|
||||
let currentPluginId = null;
|
||||
let autoRefresh = true;
|
||||
let showGrid = false;
|
||||
let debounceTimer = null;
|
||||
let currentImageWidth = 128;
|
||||
let currentImageHeight = 32;
|
||||
|
||||
// ---------- Init ----------
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Load theme
|
||||
const saved = localStorage.getItem('devPreviewTheme');
|
||||
if (saved) document.documentElement.dataset.theme = saved;
|
||||
|
||||
// Load plugins
|
||||
const res = await fetch('/api/plugins');
|
||||
const data = await res.json();
|
||||
const select = document.getElementById('pluginSelect');
|
||||
data.plugins.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${p.name} (${p.id})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Plugin selection ----------
|
||||
async function onPluginChange() {
|
||||
const pluginId = document.getElementById('pluginSelect').value;
|
||||
if (!pluginId) {
|
||||
if (jsonEditor) { jsonEditor.destroy(); jsonEditor = null; }
|
||||
document.getElementById('configPlaceholder').style.display = 'block';
|
||||
document.getElementById('pluginDescription').textContent = '';
|
||||
currentPluginId = null;
|
||||
return;
|
||||
}
|
||||
currentPluginId = pluginId;
|
||||
|
||||
// Load schema and defaults
|
||||
const [schemaRes, defaultsRes, pluginsRes] = await Promise.all([
|
||||
fetch(`/api/plugins/${pluginId}/schema`),
|
||||
fetch(`/api/plugins/${pluginId}/defaults`),
|
||||
fetch('/api/plugins'),
|
||||
]);
|
||||
|
||||
const schemaData = await schemaRes.json();
|
||||
const defaultsData = await defaultsRes.json();
|
||||
const pluginsData = await pluginsRes.json();
|
||||
|
||||
// Show description
|
||||
const plugin = pluginsData.plugins.find(p => p.id === pluginId);
|
||||
document.getElementById('pluginDescription').textContent =
|
||||
plugin ? plugin.description : '';
|
||||
|
||||
// Build config editor
|
||||
document.getElementById('configPlaceholder').style.display = 'none';
|
||||
if (jsonEditor) jsonEditor.destroy();
|
||||
|
||||
const schema = schemaData.schema || { type: 'object', properties: {} };
|
||||
|
||||
// Remove properties we don't want in the dev form
|
||||
const excluded = ['enabled', 'update_interval', 'display_duration'];
|
||||
excluded.forEach(k => { if (schema.properties) delete schema.properties[k]; });
|
||||
|
||||
jsonEditor = new JSONEditor(document.getElementById('configEditor'), {
|
||||
schema: schema,
|
||||
startval: defaultsData.defaults || {},
|
||||
theme: 'barebones',
|
||||
iconlib: null,
|
||||
disable_collapse: true,
|
||||
disable_edit_json: true,
|
||||
disable_properties: true,
|
||||
disable_array_reorder: false,
|
||||
no_additional_properties: true,
|
||||
show_errors: 'change',
|
||||
compact: true,
|
||||
});
|
||||
|
||||
jsonEditor.on('change', () => {
|
||||
if (autoRefresh) onConfigChange();
|
||||
});
|
||||
|
||||
// Auto-render on plugin change
|
||||
if (autoRefresh) renderPlugin();
|
||||
}
|
||||
|
||||
// ---------- Config change (debounced) ----------
|
||||
function onConfigChange() {
|
||||
if (!autoRefresh) return;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(renderPlugin, 500);
|
||||
}
|
||||
|
||||
function resetConfig() {
|
||||
if (!currentPluginId) return;
|
||||
onPluginChange(); // Reload defaults
|
||||
}
|
||||
|
||||
// ---------- Render ----------
|
||||
async function renderPlugin() {
|
||||
if (!currentPluginId) return;
|
||||
|
||||
const btn = document.getElementById('renderBtn');
|
||||
const statusText = document.getElementById('statusText');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Rendering...';
|
||||
statusText.textContent = 'Rendering...';
|
||||
|
||||
const config = jsonEditor ? jsonEditor.getValue() : {};
|
||||
config.enabled = true;
|
||||
|
||||
// Parse mock data
|
||||
let mockData = {};
|
||||
const mockInput = document.getElementById('mockDataInput').value.trim();
|
||||
if (mockInput) {
|
||||
try { mockData = JSON.parse(mockInput); }
|
||||
catch (e) { showMessages([], [`Mock data JSON error: ${e.message}`]); }
|
||||
}
|
||||
|
||||
const width = parseInt(document.getElementById('displayWidth').value) || 128;
|
||||
const height = parseInt(document.getElementById('displayHeight').value) || 32;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/render', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
plugin_id: currentPluginId,
|
||||
config: config,
|
||||
width: width,
|
||||
height: height,
|
||||
mock_data: mockData,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
showMessages([data.error], []);
|
||||
statusText.textContent = 'Error';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update preview
|
||||
const img = document.getElementById('displayPreview');
|
||||
img.src = data.image;
|
||||
img.style.display = 'block';
|
||||
document.getElementById('previewPlaceholder').style.display = 'none';
|
||||
currentImageWidth = data.width;
|
||||
currentImageHeight = data.height;
|
||||
updateZoom();
|
||||
|
||||
// Show render time
|
||||
document.getElementById('renderTimeText').textContent =
|
||||
`${data.render_time_ms}ms`;
|
||||
|
||||
// Show warnings/errors
|
||||
showMessages(data.errors || [], data.warnings || []);
|
||||
statusText.textContent = data.errors?.length ? 'Errors' : 'Rendered';
|
||||
|
||||
} catch (e) {
|
||||
showMessages([`Network error: ${e.message}`], []);
|
||||
statusText.textContent = 'Error';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Render';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Zoom ----------
|
||||
function updateZoom() {
|
||||
const zoom = parseInt(document.getElementById('zoomSlider').value);
|
||||
document.getElementById('zoomLabel').textContent = `${zoom}x`;
|
||||
|
||||
const img = document.getElementById('displayPreview');
|
||||
if (img.style.display !== 'none') {
|
||||
img.style.width = `${currentImageWidth * zoom}px`;
|
||||
img.style.height = `${currentImageHeight * zoom}px`;
|
||||
}
|
||||
updateGrid();
|
||||
}
|
||||
|
||||
// ---------- Grid overlay ----------
|
||||
function toggleGrid() {
|
||||
showGrid = !showGrid;
|
||||
const btn = document.getElementById('gridToggle');
|
||||
btn.classList.toggle('active', showGrid);
|
||||
btn.setAttribute('aria-checked', showGrid ? 'true' : 'false');
|
||||
updateGrid();
|
||||
}
|
||||
|
||||
function updateGrid() {
|
||||
const canvas = document.getElementById('gridCanvas');
|
||||
const img = document.getElementById('displayPreview');
|
||||
|
||||
if (!showGrid || img.style.display === 'none') {
|
||||
canvas.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const zoom = parseInt(document.getElementById('zoomSlider').value);
|
||||
const w = currentImageWidth * zoom;
|
||||
const h = currentImageHeight * zoom;
|
||||
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
|
||||
ctx.lineWidth = 0.5;
|
||||
|
||||
// Vertical lines
|
||||
for (let x = 0; x <= currentImageWidth; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * zoom, 0);
|
||||
ctx.lineTo(x * zoom, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Horizontal lines
|
||||
for (let y = 0; y <= currentImageHeight; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * zoom);
|
||||
ctx.lineTo(w, y * zoom);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Auto-refresh toggle ----------
|
||||
function toggleAutoRefresh() {
|
||||
autoRefresh = !autoRefresh;
|
||||
const btn = document.getElementById('autoRefreshToggle');
|
||||
btn.classList.toggle('active', autoRefresh);
|
||||
btn.setAttribute('aria-checked', autoRefresh ? 'true' : 'false');
|
||||
}
|
||||
|
||||
// ---------- Theme ----------
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const current = html.dataset.theme || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
html.dataset.theme = next;
|
||||
localStorage.setItem('devPreviewTheme', next);
|
||||
}
|
||||
|
||||
// ---------- Messages ----------
|
||||
function showMessages(errors, warnings) {
|
||||
const panel = document.getElementById('messagesPanel');
|
||||
const list = document.getElementById('messagesList');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!errors.length && !warnings.length) {
|
||||
panel.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
panel.classList.remove('hidden');
|
||||
errors.forEach(msg => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'text-red-400';
|
||||
div.textContent = `Error: ${msg}`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
warnings.forEach(msg => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'text-yellow-400';
|
||||
div.textContent = `Warning: ${msg}`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user