Live Painting Mode
This commit is contained in:
		@@ -97,4 +97,6 @@ Apps = [
 | 
				
			|||||||
    # App(guiname="Snake", name="snake", cmd="./snake.py"),
 | 
					    # App(guiname="Snake", name="snake", cmd="./snake.py"),
 | 
				
			||||||
    # App(name="gif", cmd="./gif.sh"),
 | 
					    # App(name="gif", cmd="./gif.sh"),
 | 
				
			||||||
    # App(name="colormap", cmd="./colormap.py"),
 | 
					    # App(name="colormap", cmd="./colormap.py"),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    AppConfig(guiname="Pixel Canvas", name="pixelcanvas", cmd=""),
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										465
									
								
								html/draw.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										465
									
								
								html/draw.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,465 @@
 | 
				
			|||||||
 | 
					<!DOCTYPE html>
 | 
				
			||||||
 | 
					<html lang="en">
 | 
				
			||||||
 | 
					<head>
 | 
				
			||||||
 | 
					    <meta charset="UTF-8">
 | 
				
			||||||
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
				
			||||||
 | 
					    <title>Pixel Art Canvas</title>
 | 
				
			||||||
 | 
					    <style>
 | 
				
			||||||
 | 
					        /* --- Reset and Base Styles --- */
 | 
				
			||||||
 | 
					        :root {
 | 
				
			||||||
 | 
					            --bg-color: #f7fafc;
 | 
				
			||||||
 | 
					            --text-color: #2d3748;
 | 
				
			||||||
 | 
					            --grid-border: #cbd5e0;
 | 
				
			||||||
 | 
					            --header-color: #1a202c;
 | 
				
			||||||
 | 
					            --subheader-color: #718096;
 | 
				
			||||||
 | 
					            --toolbar-bg: #ffffff;
 | 
				
			||||||
 | 
					            --button-bg: #e53e3e;
 | 
				
			||||||
 | 
					            --button-hover-bg: #c53030;
 | 
				
			||||||
 | 
					            --swatch-border: #e2e8f0;
 | 
				
			||||||
 | 
					            --active-swatch-border: #3b82f6;
 | 
				
			||||||
 | 
					            --input-bg: #f7fafc;
 | 
				
			||||||
 | 
					            --input-border: #e2e8f0;
 | 
				
			||||||
 | 
					            --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
 | 
				
			||||||
 | 
					            --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /* --- Dark Mode --- */
 | 
				
			||||||
 | 
					        @media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					            :root {
 | 
				
			||||||
 | 
					                --bg-color: #1a202c;
 | 
				
			||||||
 | 
					                --text-color: #e2e8f0;
 | 
				
			||||||
 | 
					                --grid-border: #4a5568;
 | 
				
			||||||
 | 
					                --header-color: #ffffff;
 | 
				
			||||||
 | 
					                --subheader-color: #a0aec0;
 | 
				
			||||||
 | 
					                --toolbar-bg: #2d3748;
 | 
				
			||||||
 | 
					                --swatch-border: #4a5568;
 | 
				
			||||||
 | 
					                --input-bg: #4a5568;
 | 
				
			||||||
 | 
					                --input-border: #718096;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        body {
 | 
				
			||||||
 | 
					            margin: 0;
 | 
				
			||||||
 | 
					            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
 | 
				
			||||||
 | 
					            background-color: var(--bg-color);
 | 
				
			||||||
 | 
					            color: var(--text-color);
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            flex-direction: column;
 | 
				
			||||||
 | 
					            align-items: center;
 | 
				
			||||||
 | 
					            justify-content: center;
 | 
				
			||||||
 | 
					            min-height: 100vh;
 | 
				
			||||||
 | 
					            padding: 1rem;
 | 
				
			||||||
 | 
					            box-sizing: border-box;
 | 
				
			||||||
 | 
					            touch-action: none; /* Disable browser default touch actions like pan/zoom */
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /* --- Layout and Components --- */
 | 
				
			||||||
 | 
					        .main-container {
 | 
				
			||||||
 | 
					            width: 100%;
 | 
				
			||||||
 | 
					            max-width: 1200px;
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            flex-direction: column;
 | 
				
			||||||
 | 
					            align-items: center;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        header {
 | 
				
			||||||
 | 
					            text-align: center;
 | 
				
			||||||
 | 
					            margin-bottom: 1.5rem;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        h1 {
 | 
				
			||||||
 | 
					            font-size: 2.25rem;
 | 
				
			||||||
 | 
					            font-weight: 700;
 | 
				
			||||||
 | 
					            color: var(--header-color);
 | 
				
			||||||
 | 
					            margin: 0;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        header p {
 | 
				
			||||||
 | 
					            margin-top: 0.25rem;
 | 
				
			||||||
 | 
					            color: var(--subheader-color);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .toolbar {
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            flex-wrap: wrap;
 | 
				
			||||||
 | 
					            align-items: center;
 | 
				
			||||||
 | 
					            justify-content: center;
 | 
				
			||||||
 | 
					            gap: 1.5rem;
 | 
				
			||||||
 | 
					            margin-bottom: 1.5rem;
 | 
				
			||||||
 | 
					            padding: 1rem;
 | 
				
			||||||
 | 
					            background-color: var(--toolbar-bg);
 | 
				
			||||||
 | 
					            border-radius: 0.5rem;
 | 
				
			||||||
 | 
					            box-shadow: var(--shadow-md);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .control-group {
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            align-items: center;
 | 
				
			||||||
 | 
					            gap: 0.75rem;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        label {
 | 
				
			||||||
 | 
					            font-weight: 500;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        #colorPicker {
 | 
				
			||||||
 | 
					            width: 2.5rem;
 | 
				
			||||||
 | 
					            height: 2.5rem;
 | 
				
			||||||
 | 
					            padding: 0;
 | 
				
			||||||
 | 
					            border: none;
 | 
				
			||||||
 | 
					            border-radius: 0.375rem;
 | 
				
			||||||
 | 
					            background: none;
 | 
				
			||||||
 | 
					            cursor: pointer;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        #colorPicker::-webkit-color-swatch-wrapper {
 | 
				
			||||||
 | 
					            padding: 0;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        #colorPicker::-webkit-color-swatch {
 | 
				
			||||||
 | 
					            border: 1px solid var(--swatch-border);
 | 
				
			||||||
 | 
					            border-radius: 0.375rem;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .color-palette {
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            gap: 0.5rem;
 | 
				
			||||||
 | 
					            flex-wrap: wrap;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .color-swatch {
 | 
				
			||||||
 | 
					            width: 2rem;
 | 
				
			||||||
 | 
					            height: 2rem;
 | 
				
			||||||
 | 
					            border-radius: 50%;
 | 
				
			||||||
 | 
					            border: 2px solid var(--swatch-border);
 | 
				
			||||||
 | 
					            cursor: pointer;
 | 
				
			||||||
 | 
					            transition: transform 0.1s ease, border-color 0.1s ease;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .color-swatch:hover {
 | 
				
			||||||
 | 
					            transform: scale(1.1);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .color-swatch.active {
 | 
				
			||||||
 | 
					            border-color: var(--active-swatch-border);
 | 
				
			||||||
 | 
					            transform: scale(1.15);
 | 
				
			||||||
 | 
					            box-shadow: 0 0 0 2px var(--active-swatch-border);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        select {
 | 
				
			||||||
 | 
					            padding: 0.5rem;
 | 
				
			||||||
 | 
					            border-radius: 0.375rem;
 | 
				
			||||||
 | 
					            border: 1px solid var(--input-border);
 | 
				
			||||||
 | 
					            background-color: var(--input-bg);
 | 
				
			||||||
 | 
					            color: var(--text-color);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        #clearCanvas {
 | 
				
			||||||
 | 
					            padding: 0.6rem 1rem;
 | 
				
			||||||
 | 
					            background-color: var(--button-bg);
 | 
				
			||||||
 | 
					            color: white;
 | 
				
			||||||
 | 
					            border: none;
 | 
				
			||||||
 | 
					            border-radius: 0.5rem;
 | 
				
			||||||
 | 
					            font-weight: 500;
 | 
				
			||||||
 | 
					            cursor: pointer;
 | 
				
			||||||
 | 
					            transition: background-color 0.2s ease;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        #clearCanvas:hover {
 | 
				
			||||||
 | 
					            background-color: var(--button-hover-bg);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        #grid-container {
 | 
				
			||||||
 | 
					            display: grid;
 | 
				
			||||||
 | 
					            grid-template-columns: repeat(80, 1fr);
 | 
				
			||||||
 | 
					            border: 1px solid var(--grid-border);
 | 
				
			||||||
 | 
					            width: 100%;
 | 
				
			||||||
 | 
					            box-shadow: var(--shadow-lg);
 | 
				
			||||||
 | 
					            cursor: crosshair;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .pixel {
 | 
				
			||||||
 | 
					            width: 100%;
 | 
				
			||||||
 | 
					            padding-bottom: 100%; /* Creates a square aspect ratio */
 | 
				
			||||||
 | 
					            background-color: #ffffff;
 | 
				
			||||||
 | 
					            transition: background-color 0.1s ease;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    </style>
 | 
				
			||||||
 | 
					</head>
 | 
				
			||||||
 | 
					<body>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="main-container">
 | 
				
			||||||
 | 
					    <header>
 | 
				
			||||||
 | 
					        <h1>Pixel Art Canvas</h1>
 | 
				
			||||||
 | 
					        <p>Click and drag to paint. Your changes are live.</p>
 | 
				
			||||||
 | 
					    </header>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="toolbar">
 | 
				
			||||||
 | 
					        <div class="control-group">
 | 
				
			||||||
 | 
					            <label for="colorPicker">Color:</label>
 | 
				
			||||||
 | 
					            <div id="color-palette" class="color-palette"></div>
 | 
				
			||||||
 | 
					            <input type="color" id="colorPicker" value="#3b82f6">
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="control-group">
 | 
				
			||||||
 | 
					            <label for="brushSize">Brush Size:</label>
 | 
				
			||||||
 | 
					            <select id="brushSize">
 | 
				
			||||||
 | 
					                <option value="1">1x1</option>
 | 
				
			||||||
 | 
					                <option value="3">3x3</option>
 | 
				
			||||||
 | 
					                <option value="5">5x5</option>
 | 
				
			||||||
 | 
					            </select>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="control-group">
 | 
				
			||||||
 | 
					            <button id="clearCanvas">Clear</button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div id="grid-container"></div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script>
 | 
				
			||||||
 | 
					    document.addEventListener('DOMContentLoaded', () => {
 | 
				
			||||||
 | 
					        // --- DOM Elements ---
 | 
				
			||||||
 | 
					        const gridContainer = document.getElementById('grid-container');
 | 
				
			||||||
 | 
					        const colorPicker = document.getElementById('colorPicker');
 | 
				
			||||||
 | 
					        const paletteContainer = document.getElementById('color-palette');
 | 
				
			||||||
 | 
					        const brushSizeSelect = document.getElementById('brushSize');
 | 
				
			||||||
 | 
					        const clearCanvasBtn = document.getElementById('clearCanvas');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // --- Grid Configuration ---
 | 
				
			||||||
 | 
					        const GRID_ROWS = 40;
 | 
				
			||||||
 | 
					        const GRID_COLS = 80;
 | 
				
			||||||
 | 
					        const DEFAULT_COLOR = '#ffffff';
 | 
				
			||||||
 | 
					        const PRESET_COLORS = [
 | 
				
			||||||
 | 
					            '#ffffff', '#c0c0c0', '#ff0000', '#ffa500', '#ffff00',
 | 
				
			||||||
 | 
					            '#008000', '#0000ff', '#4b0082', '#ee82ee', '#000000'
 | 
				
			||||||
 | 
					        ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // --- State Management ---
 | 
				
			||||||
 | 
					        let pixelData = [];
 | 
				
			||||||
 | 
					        let isPainting = false;
 | 
				
			||||||
 | 
					        let currentColor = colorPicker.value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // --- Functions ---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function initializePalette() {
 | 
				
			||||||
 | 
					            paletteContainer.innerHTML = '';
 | 
				
			||||||
 | 
					            PRESET_COLORS.forEach(color => {
 | 
				
			||||||
 | 
					                const swatch = document.createElement('div');
 | 
				
			||||||
 | 
					                swatch.classList.add('color-swatch');
 | 
				
			||||||
 | 
					                swatch.style.backgroundColor = color;
 | 
				
			||||||
 | 
					                swatch.dataset.color = color;
 | 
				
			||||||
 | 
					                paletteContainer.appendChild(swatch);
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function setActiveColor(color) {
 | 
				
			||||||
 | 
					            currentColor = color;
 | 
				
			||||||
 | 
					            colorPicker.value = color;
 | 
				
			||||||
 | 
					            document.querySelectorAll('.color-swatch').forEach(sw => {
 | 
				
			||||||
 | 
					                sw.classList.toggle('active', sw.dataset.color === color);
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function initializeGrid() {
 | 
				
			||||||
 | 
					            gridContainer.innerHTML = '';
 | 
				
			||||||
 | 
					            pixelData = Array.from({length: GRID_ROWS}, () => Array(GRID_COLS).fill(DEFAULT_COLOR));
 | 
				
			||||||
 | 
					            for (let r = 0; r < GRID_ROWS; r++) {
 | 
				
			||||||
 | 
					                for (let c = 0; c < GRID_COLS; c++) {
 | 
				
			||||||
 | 
					                    const pixel = document.createElement('div');
 | 
				
			||||||
 | 
					                    pixel.classList.add('pixel');
 | 
				
			||||||
 | 
					                    pixel.dataset.row = r;
 | 
				
			||||||
 | 
					                    pixel.dataset.col = c;
 | 
				
			||||||
 | 
					                    gridContainer.appendChild(pixel);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * Applies the current brush to the grid centered at the given coordinates.
 | 
				
			||||||
 | 
					         * @param {number} centerRow - The center row for the brush.
 | 
				
			||||||
 | 
					         * @param {number} centerCol - The center column for the brush.
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        function applyBrush(centerRow, centerCol) {
 | 
				
			||||||
 | 
					            const size = parseInt(brushSizeSelect.value, 10);
 | 
				
			||||||
 | 
					            const offset = Math.floor((size - 1) / 2);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for (let r_offset = 0; r_offset < size; r_offset++) {
 | 
				
			||||||
 | 
					                for (let c_offset = 0; c_offset < size; c_offset++) {
 | 
				
			||||||
 | 
					                    const targetRow = centerRow - offset + r_offset;
 | 
				
			||||||
 | 
					                    const targetCol = centerCol - offset + c_offset;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Check if the target pixel is within the grid bounds
 | 
				
			||||||
 | 
					                    if (targetRow >= 0 && targetRow < GRID_ROWS && targetCol >= 0 && targetCol < GRID_COLS) {
 | 
				
			||||||
 | 
					                        if (pixelData[targetRow][targetCol] !== currentColor) {
 | 
				
			||||||
 | 
					                            pixelData[targetRow][targetCol] = currentColor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                            // This could be slow on very large brushes, but is fine for this size.
 | 
				
			||||||
 | 
					                            // A more optimized approach might update the DOM after the loop.
 | 
				
			||||||
 | 
					                            const pixelEl = gridContainer.querySelector(`[data-row='${targetRow}'][data-col='${targetCol}']`);
 | 
				
			||||||
 | 
					                            if (pixelEl) {
 | 
				
			||||||
 | 
					                                pixelEl.style.backgroundColor = currentColor;
 | 
				
			||||||
 | 
					                                const [r, g, b] = hexToRGB(currentColor);
 | 
				
			||||||
 | 
					                                if (ws)
 | 
				
			||||||
 | 
					                                    ws.send(JSON.stringify({
 | 
				
			||||||
 | 
					                                        ty: "pixel",
 | 
				
			||||||
 | 
					                                        x: targetCol, y: targetRow,
 | 
				
			||||||
 | 
					                                        r, g, b,
 | 
				
			||||||
 | 
					                                    }));
 | 
				
			||||||
 | 
					                                else
 | 
				
			||||||
 | 
					                                    fetch(`/pixel/${targetCol}/${targetRow}/${r}/${g}/${b}/0`);
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function rgbToHex(r, g, b, w) {
 | 
				
			||||||
 | 
					            let hex = ((((r << 8) + g) << 8) + b).toString(16);
 | 
				
			||||||
 | 
					            while (hex.length < 6)
 | 
				
			||||||
 | 
					                hex = "0" + hex;
 | 
				
			||||||
 | 
					            return "#" + hex;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function hexToRGB(hex) {
 | 
				
			||||||
 | 
					            const dec = parseInt(hex.substring(1), 16);
 | 
				
			||||||
 | 
					            const b = dec & 0xff;
 | 
				
			||||||
 | 
					            const g = (dec >> 8) & 0xff;
 | 
				
			||||||
 | 
					            const r = (dec >> 16) & 0xff;
 | 
				
			||||||
 | 
					            return [r, g, b];
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        async function loadFrame() {
 | 
				
			||||||
 | 
					            let data = await (await fetch("/frame")).json();
 | 
				
			||||||
 | 
					            setFrame(data);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function setFrame(data) {
 | 
				
			||||||
 | 
					            for (const pixel of document.getElementsByClassName("pixel")) {
 | 
				
			||||||
 | 
					                const row = parseInt(pixel.dataset.row, 10);
 | 
				
			||||||
 | 
					                const col = parseInt(pixel.dataset.col, 10);
 | 
				
			||||||
 | 
					                const data_base = (row * GRID_COLS + col) * data.channels;
 | 
				
			||||||
 | 
					                let color = rgbToHex(
 | 
				
			||||||
 | 
					                    data.data[data_base],
 | 
				
			||||||
 | 
					                    data.data[data_base + 1],
 | 
				
			||||||
 | 
					                    data.data[data_base + 2],
 | 
				
			||||||
 | 
					                    data.data[data_base + 3]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                pixelData[row][col] = color;
 | 
				
			||||||
 | 
					                pixel.style.backgroundColor = color;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // --- Event Handlers ---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function handlePaint(event) {
 | 
				
			||||||
 | 
					            const targetPixel = event.target.closest('.pixel');
 | 
				
			||||||
 | 
					            if (targetPixel) {
 | 
				
			||||||
 | 
					                const row = parseInt(targetPixel.dataset.row, 10);
 | 
				
			||||||
 | 
					                const col = parseInt(targetPixel.dataset.col, 10);
 | 
				
			||||||
 | 
					                applyBrush(row, col);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function handleTouchPaint(touch) {
 | 
				
			||||||
 | 
					            const targetPixel = document.elementFromPoint(touch.clientX, touch.clientY);
 | 
				
			||||||
 | 
					            if (targetPixel && targetPixel.classList.contains('pixel')) {
 | 
				
			||||||
 | 
					                const row = parseInt(targetPixel.dataset.row, 10);
 | 
				
			||||||
 | 
					                const col = parseInt(targetPixel.dataset.col, 10);
 | 
				
			||||||
 | 
					                applyBrush(row, col);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // --- Event Listeners ---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        colorPicker.addEventListener('input', (e) => setActiveColor(e.target.value));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        paletteContainer.addEventListener('click', (e) => {
 | 
				
			||||||
 | 
					            const swatch = e.target.closest('.color-swatch');
 | 
				
			||||||
 | 
					            if (swatch) setActiveColor(swatch.dataset.color);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        gridContainer.addEventListener('mousedown', (e) => {
 | 
				
			||||||
 | 
					            e.preventDefault();
 | 
				
			||||||
 | 
					            isPainting = true;
 | 
				
			||||||
 | 
					            handlePaint(e);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        gridContainer.addEventListener('touchstart', (e) => {
 | 
				
			||||||
 | 
					            e.preventDefault();
 | 
				
			||||||
 | 
					            isPainting = true;
 | 
				
			||||||
 | 
					            if (e.touches.length > 0) handleTouchPaint(e.touches[0]);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        document.addEventListener('mouseup', () => isPainting = false);
 | 
				
			||||||
 | 
					        document.addEventListener('touchend', () => isPainting = false);
 | 
				
			||||||
 | 
					        document.addEventListener('touchcancel', () => isPainting = false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        gridContainer.addEventListener('mousemove', (e) => {
 | 
				
			||||||
 | 
					            if (isPainting) handlePaint(e);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        gridContainer.addEventListener('touchmove', (e) => {
 | 
				
			||||||
 | 
					            if (isPainting) {
 | 
				
			||||||
 | 
					                e.preventDefault(); // Prevent scrolling while painting
 | 
				
			||||||
 | 
					                if (e.touches.length > 0) handleTouchPaint(e.touches[0]);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        clearCanvasBtn.addEventListener('click', () => {
 | 
				
			||||||
 | 
					            initializeGrid();
 | 
				
			||||||
 | 
					            ws.send(JSON.stringify({
 | 
				
			||||||
 | 
					                ty: "fill",
 | 
				
			||||||
 | 
					                r: 0, g: 0, b: 0,
 | 
				
			||||||
 | 
					            }))
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // --- Initial Load ---
 | 
				
			||||||
 | 
					        initializePalette();
 | 
				
			||||||
 | 
					        initializeGrid();
 | 
				
			||||||
 | 
					        setActiveColor(colorPicker.value);
 | 
				
			||||||
 | 
					        // loadFrame();
 | 
				
			||||||
 | 
					        // setInterval(loadFrame, 500);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let ws = ({
 | 
				
			||||||
 | 
					            connect() {
 | 
				
			||||||
 | 
					                if (this.ws)
 | 
				
			||||||
 | 
					                    this.ws.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                this.ws = new WebSocket("/frame_ws");
 | 
				
			||||||
 | 
					                this.ws.onopen = () => {
 | 
				
			||||||
 | 
					                    clearInterval(this.frame_interval);
 | 
				
			||||||
 | 
					                    this.frame_interval = setInterval(() => {
 | 
				
			||||||
 | 
					                        console.log(this);
 | 
				
			||||||
 | 
					                        this.ws.send(JSON.stringify({ty: "frame", time: this.frame_time, x: 2}))
 | 
				
			||||||
 | 
					                    }, 100);
 | 
				
			||||||
 | 
					                };
 | 
				
			||||||
 | 
					                this.ws.onclose = () => {
 | 
				
			||||||
 | 
					                    clearInterval(this.frame_interval);
 | 
				
			||||||
 | 
					                    this.ws = undefined;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                this.ws.onmessage = this.onmessage.bind(this);
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            onmessage(msg) {
 | 
				
			||||||
 | 
					                const data = JSON.parse(msg.data)
 | 
				
			||||||
 | 
					                switch (data.ty) {
 | 
				
			||||||
 | 
					                    case "frame":
 | 
				
			||||||
 | 
					                        this.frame_time = data.time;
 | 
				
			||||||
 | 
					                        console.log(this);
 | 
				
			||||||
 | 
					                        setFrame(data);
 | 
				
			||||||
 | 
					                        break;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            send(data) {
 | 
				
			||||||
 | 
					                if (!this.ws)
 | 
				
			||||||
 | 
					                    this.connect();
 | 
				
			||||||
 | 
					                this.ws.send(data);
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        ws.connect();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					</body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
							
								
								
									
										123
									
								
								main.py
									
									
									
									
									
								
							
							
						
						
									
										123
									
								
								main.py
									
									
									
									
									
								
							@@ -12,6 +12,11 @@ from collections import OrderedDict
 | 
				
			|||||||
import bottle
 | 
					import bottle
 | 
				
			||||||
import numpy as np
 | 
					import numpy as np
 | 
				
			||||||
import serial
 | 
					import serial
 | 
				
			||||||
 | 
					# noinspection PyUnresolvedReferences
 | 
				
			||||||
 | 
					import bottle.ext.websocket as bottle_ws
 | 
				
			||||||
 | 
					# noinspection PyUnresolvedReferences
 | 
				
			||||||
 | 
					from bottle.ext.websocket import GeventWebSocketServer
 | 
				
			||||||
 | 
					import geventwebsocket.websocket
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import config
 | 
					import config
 | 
				
			||||||
import filters
 | 
					import filters
 | 
				
			||||||
@@ -27,7 +32,7 @@ class DataSource:
 | 
				
			|||||||
        self.data = initial
 | 
					        self.data = initial
 | 
				
			||||||
        self.listeners = []
 | 
					        self.listeners = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def getData(self):
 | 
					    def getData(self) -> "Frame":
 | 
				
			||||||
        return self.data
 | 
					        return self.data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def addListener(self, listener):
 | 
					    def addListener(self, listener):
 | 
				
			||||||
@@ -89,7 +94,7 @@ class LogReader(threading.Thread):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class Frame:
 | 
					class Frame:
 | 
				
			||||||
    def __init__(self, buffer, channels=3):
 | 
					    def __init__(self, buffer, channels=3):
 | 
				
			||||||
        self.buffer = buffer
 | 
					        self.buffer: np.ndarray = buffer
 | 
				
			||||||
        self.created = time.time()
 | 
					        self.created = time.time()
 | 
				
			||||||
        self.channels = channels
 | 
					        self.channels = channels
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -223,13 +228,14 @@ class SerialWriter(threading.Thread):
 | 
				
			|||||||
#                                   App                                #
 | 
					#                                   App                                #
 | 
				
			||||||
########################################################################
 | 
					########################################################################
 | 
				
			||||||
class App(threading.Thread):
 | 
					class App(threading.Thread):
 | 
				
			||||||
    def __init__(self, cmd, param, listener, is_persistent, is_white=False, path="."):
 | 
					    def __init__(self, name, cmd, param, listener, is_persistent, is_white=False, path="."):
 | 
				
			||||||
        super().__init__(daemon=True)
 | 
					        super().__init__(daemon=True)
 | 
				
			||||||
        # start app
 | 
					        # start app
 | 
				
			||||||
 | 
					        self.name = name
 | 
				
			||||||
        args = cmd + [str(config.ScreenX), str(config.ScreenY), param]
 | 
					        args = cmd + [str(config.ScreenX), str(config.ScreenY), param]
 | 
				
			||||||
        self.app = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True, cwd=path)
 | 
					        self.app = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True, cwd=path)
 | 
				
			||||||
        self.last_update = time.time()
 | 
					        self.last_update = time.time()
 | 
				
			||||||
        self.cv = threading.Condition()
 | 
					        # self.cv = threading.Condition()
 | 
				
			||||||
        self.watchdog = WatchDog(lambda: self.isAppTimedOut(), lambda: self.terminateApp())
 | 
					        self.watchdog = WatchDog(lambda: self.isAppTimedOut(), lambda: self.terminateApp())
 | 
				
			||||||
        self.watchdog.start()
 | 
					        self.watchdog.start()
 | 
				
			||||||
        self.logreader = LogReader(self)
 | 
					        self.logreader = LogReader(self)
 | 
				
			||||||
@@ -253,8 +259,8 @@ class App(threading.Thread):
 | 
				
			|||||||
                frame = Frame(buffer, channels=channels)
 | 
					                frame = Frame(buffer, channels=channels)
 | 
				
			||||||
                self.last_update = time.time()
 | 
					                self.last_update = time.time()
 | 
				
			||||||
                self.datasource.pushData(frame)
 | 
					                self.datasource.pushData(frame)
 | 
				
			||||||
            except:
 | 
					            except Exception as ex:
 | 
				
			||||||
                logging.debug("Exception in App.run")
 | 
					                logging.debug(f"Exception in App.run: {ex}")
 | 
				
			||||||
        with self.listener:
 | 
					        with self.listener:
 | 
				
			||||||
            self.listener.notify_all()
 | 
					            self.listener.notify_all()
 | 
				
			||||||
        self.watchdog.stop()
 | 
					        self.watchdog.stop()
 | 
				
			||||||
@@ -288,6 +294,35 @@ class App(threading.Thread):
 | 
				
			|||||||
        return time.time() - self.last_update > config.NoDataTimeout
 | 
					        return time.time() - self.last_update > config.NoDataTimeout
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PixelCanvas(App):
 | 
				
			||||||
 | 
					    # noinspection PyMissingConstructor
 | 
				
			||||||
 | 
					    def __init__(self):
 | 
				
			||||||
 | 
					        threading.Thread.__init__(self, daemon=True)
 | 
				
			||||||
 | 
					        self.name = "pixelcanvas"
 | 
				
			||||||
 | 
					        self.running = True
 | 
				
			||||||
 | 
					        self.is_persistent = True
 | 
				
			||||||
 | 
					        self.datasource = DataSource(Frame(np.zeros((config.ScreenY, config.ScreenX, 3))))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def run(self):
 | 
				
			||||||
 | 
					        while running and self.running:
 | 
				
			||||||
 | 
					            time.sleep(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def alive(self):
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def stop(self):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def getLog(self):
 | 
				
			||||||
 | 
					        return ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def terminateApp(self):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def isAppTimedOut(self):
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
########################################################################
 | 
					########################################################################
 | 
				
			||||||
#                                  Main                                #
 | 
					#                                  Main                                #
 | 
				
			||||||
########################################################################
 | 
					########################################################################
 | 
				
			||||||
@@ -322,7 +357,10 @@ class AppRunner(threading.Thread):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def startApp(self, i, param=""):
 | 
					    def startApp(self, i, param=""):
 | 
				
			||||||
        app = config.Apps[i]
 | 
					        app = config.Apps[i]
 | 
				
			||||||
        newapp = App(app.cmd, param, self.cv, is_persistent=app.persistent, is_white=app.white, path=app.path)
 | 
					        if app.name == "pixelcanvas":
 | 
				
			||||||
 | 
					            newapp = PixelCanvas()
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            newapp = App(app.name, app.cmd, param, self.cv, is_persistent=app.persistent, is_white=app.white, path=app.path)
 | 
				
			||||||
        newapp.datasource.addListener(self.cv)
 | 
					        newapp.datasource.addListener(self.cv)
 | 
				
			||||||
        newapp.start()
 | 
					        newapp.start()
 | 
				
			||||||
        if app.persistent:
 | 
					        if app.persistent:
 | 
				
			||||||
@@ -451,6 +489,75 @@ def apps_running():
 | 
				
			|||||||
    return config.Apps[runner.currentApp].name
 | 
					    return config.Apps[runner.currentApp].name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bottle.route("/frame")
 | 
				
			||||||
 | 
					def frame():
 | 
				
			||||||
 | 
					    data = runner.datasource.getData()
 | 
				
			||||||
 | 
					    return {"data": data.buffer.flatten().tolist(), "channels": data.channels}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bottle.route("/pixel/<x:int>/<y:int>/<r:int>/<g:int>/<b:int>/<w:int>")
 | 
				
			||||||
 | 
					def pixel(x, y, r, g, b, w):
 | 
				
			||||||
 | 
					    if runner.app.name != "pixelcanvas":
 | 
				
			||||||
 | 
					        startApp("pixelcanvas")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    data = runner.app.datasource.getData().clone()
 | 
				
			||||||
 | 
					    data.created = time.time()
 | 
				
			||||||
 | 
					    data.buffer[y][x][0] = r
 | 
				
			||||||
 | 
					    data.buffer[y][x][1] = g
 | 
				
			||||||
 | 
					    data.buffer[y][x][2] = b
 | 
				
			||||||
 | 
					    if data.channels == 4:
 | 
				
			||||||
 | 
					        data.buffer[y][x][3] = w
 | 
				
			||||||
 | 
					    runner.datasource.pushData(data)
 | 
				
			||||||
 | 
					    return "ok"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bottle.get("/frame_ws", apply=[bottle_ws.websocket])
 | 
				
			||||||
 | 
					def frame_ws(ws: geventwebsocket.websocket.WebSocket):
 | 
				
			||||||
 | 
					    while not ws.closed:
 | 
				
			||||||
 | 
					        msg = json.loads(ws.receive())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if msg["ty"] == "frame":
 | 
				
			||||||
 | 
					            data = runner.datasource.getData()
 | 
				
			||||||
 | 
					            if msg.get("time", None) == str(data.created):
 | 
				
			||||||
 | 
					                ws.send(json.dumps({
 | 
				
			||||||
 | 
					                    "type": "frame_unchanged",
 | 
				
			||||||
 | 
					                }, separators=(',', ':')))
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                ws.send(json.dumps({
 | 
				
			||||||
 | 
					                    "ty": "frame",
 | 
				
			||||||
 | 
					                    "data": data.buffer.flatten().tolist(),
 | 
				
			||||||
 | 
					                    "channels": data.channels,
 | 
				
			||||||
 | 
					                    "time": str(data.created),
 | 
				
			||||||
 | 
					                }, separators=(',', ':')))
 | 
				
			||||||
 | 
					        elif msg["ty"] == "pixel":
 | 
				
			||||||
 | 
					            if runner.app.name != "pixelcanvas":
 | 
				
			||||||
 | 
					                startApp("pixelcanvas")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            x = msg["x"]
 | 
				
			||||||
 | 
					            y = msg["y"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            data = runner.app.datasource.getData().clone()
 | 
				
			||||||
 | 
					            data.created = time.time()
 | 
				
			||||||
 | 
					            data.buffer[y, x, 0] = msg["r"]
 | 
				
			||||||
 | 
					            data.buffer[y, x, 1] = msg["g"]
 | 
				
			||||||
 | 
					            data.buffer[y, x, 2] = msg["b"]
 | 
				
			||||||
 | 
					            if data.channels == 4 and "w" in msg:
 | 
				
			||||||
 | 
					                data.buffer[y, x, 3] = msg["w"]
 | 
				
			||||||
 | 
					            runner.app.datasource.pushData(data)
 | 
				
			||||||
 | 
					        elif msg["ty"] == "fill":
 | 
				
			||||||
 | 
					            if runner.app.name != "pixelcanvas":
 | 
				
			||||||
 | 
					                startApp("pixelcanvas")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            data = runner.app.datasource.getData().clone()
 | 
				
			||||||
 | 
					            data.created = time.time()
 | 
				
			||||||
 | 
					            data.buffer[..., 0] = msg["r"]
 | 
				
			||||||
 | 
					            data.buffer[..., 1] = msg["g"]
 | 
				
			||||||
 | 
					            data.buffer[..., 2] = msg["b"]
 | 
				
			||||||
 | 
					            if data.channels == 4 and "w" in msg:
 | 
				
			||||||
 | 
					                data.buffer[..., 3] = msg["w"]
 | 
				
			||||||
 | 
					            runner.app.datasource.pushData(data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@bottle.route("/")
 | 
					@bottle.route("/")
 | 
				
			||||||
def index():
 | 
					def index():
 | 
				
			||||||
    return bottle.static_file("index.html", root='html')
 | 
					    return bottle.static_file("index.html", root='html')
 | 
				
			||||||
@@ -523,7 +630,7 @@ def main():
 | 
				
			|||||||
    runner.start()
 | 
					    runner.start()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # runner.setFilter("5_crazy", MakeBrightnessExprFilter("0.5+0.25*sin(x/3)/x"))
 | 
					    # runner.setFilter("5_crazy", MakeBrightnessExprFilter("0.5+0.25*sin(x/3)/x"))
 | 
				
			||||||
    bottle.run(host=config.WebHost, port=config.WebPort)
 | 
					    bottle.run(host=config.WebHost, port=config.WebPort, server=GeventWebSocketServer)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    ########################################################################
 | 
					    ########################################################################
 | 
				
			||||||
    #                              Shutdown                                #
 | 
					    #                              Shutdown                                #
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,8 @@
 | 
				
			|||||||
bottle
 | 
					bottle
 | 
				
			||||||
 | 
					bottle-websocket
 | 
				
			||||||
numpy
 | 
					numpy
 | 
				
			||||||
scipy
 | 
					scipy
 | 
				
			||||||
pygame
 | 
					pygame
 | 
				
			||||||
pyserial
 | 
					pyserial
 | 
				
			||||||
paho-mqtt
 | 
					paho-mqtt
 | 
				
			||||||
pillow
 | 
					pillow
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user