From d40c7d9f09d9b012837d6060a4c598b23b19646f Mon Sep 17 00:00:00 2001 From: Martin Ashby Date: Thu, 17 May 2018 18:18:11 +0100 Subject: Palette, tools, load & save working --- .gitignore | 1 + package-lock.json | 100 +++++++++++++++ package.json | 1 + server.py | 50 +++++++- src/Actions.js | 51 ++++++++ src/App.css | 33 +++-- src/App.js | 302 +++++++++++++++++++++------------------------ src/App.test.js | 9 -- src/ColorIndicator.js | 30 +++++ src/ConnectedIndicator.js | 11 ++ src/LoadDialog.js | 21 ++++ src/PaintArea.js | 74 +++++++++++ src/Palette.js | 44 +++++++ src/SaveDialog.js | 28 +++++ src/Toolkit.js | 31 +++++ src/Utils.js | 103 ++++++++++++++++ src/Utils.test.js | 81 ++++++++++++ src/fontawesome-all.min.js | 5 + src/logo.svg | 7 -- unicorn_hat_sim.py | 11 +- 20 files changed, 799 insertions(+), 194 deletions(-) create mode 100644 src/Actions.js delete mode 100644 src/App.test.js create mode 100644 src/ColorIndicator.js create mode 100644 src/ConnectedIndicator.js create mode 100644 src/LoadDialog.js create mode 100644 src/PaintArea.js create mode 100644 src/Palette.js create mode 100644 src/SaveDialog.js create mode 100644 src/Toolkit.js create mode 100644 src/Utils.js create mode 100644 src/Utils.test.js create mode 100644 src/fontawesome-all.min.js delete mode 100644 src/logo.svg diff --git a/.gitignore b/.gitignore index d30f40e..3d0f933 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ # production /build +/saves # misc .DS_Store diff --git a/package-lock.json b/package-lock.json index f9b993f..9128614 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,6 +90,22 @@ } } }, + "active-event-stack": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/active-event-stack/-/active-event-stack-1.0.0.tgz", + "integrity": "sha1-a1uS661xmvrpgs1R9Jw4xbaADFA=", + "requires": { + "immutable": "3.8.2", + "lodash": "3.10.1" + }, + "dependencies": { + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=" + } + } + }, "address": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/address/-/address-1.0.3.tgz", @@ -1408,6 +1424,11 @@ "hoek": "4.2.1" } }, + "bowser": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-1.9.3.tgz", + "integrity": "sha512-/gp96UlcFw5DbV2KQPCqTqi0Mb9gZRyDAHiDsGEH+4B/KOQjeoE5lM1PxlVX8DQDvfEfitmC1rW2Oy8fk/XBDg==" + }, "boxen": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-1.3.0.tgz", @@ -1870,6 +1891,11 @@ } } }, + "classnames": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz", + "integrity": "sha1-+zgB1FNGdknvNgPH1hoCvRKb3m0=" + }, "clean-css": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.1.11.tgz", @@ -2271,6 +2297,15 @@ "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz", "integrity": "sha1-gIrcLnnPhHOAabZGyyDsJ762KeA=" }, + "css-in-js-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz", + "integrity": "sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA==", + "requires": { + "hyphenate-style-name": "1.0.2", + "isobject": "3.0.1" + } + }, "css-loader": { "version": "0.28.7", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-0.28.7.tgz", @@ -2812,6 +2847,11 @@ "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" }, + "dynamics.js": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/dynamics.js/-/dynamics.js-1.1.5.tgz", + "integrity": "sha1-uQvcM2Bc7+ZSuEFucB95v27vzjI=" + }, "ecc-jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", @@ -5001,6 +5041,11 @@ "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz", "integrity": "sha1-3QLqYIG9BWjcXQcxhEY5V7qe+ao=" }, + "hyphenate-style-name": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.2.tgz", + "integrity": "sha1-MRYKNpMK2vH8BMYHT360FGXU7Es=" + }, "iconv-lite": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", @@ -5032,6 +5077,11 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.8.tgz", "integrity": "sha512-pUh+xUQQhQzevjRHHFqqcTy0/dP/kS9I8HSrUydhihjuD09W6ldVWFtIrwhXdUJHis3i2rZNqEHpZH/cbinFbg==" }, + "immutable": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", + "integrity": "sha1-wkOZUUVbs5kT2vKBN28VMOEErfM=" + }, "import-lazy": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", @@ -5088,6 +5138,15 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" }, + "inline-style-prefixer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-4.0.2.tgz", + "integrity": "sha512-N8nVhwfYga9MiV9jWlwfdj1UDIaZlBFu4cJSJkIr7tZX7sHpHhGR5su1qdpW+7KPL8ISTvCIkcaFi/JdBknvPg==", + "requires": { + "bowser": "1.9.3", + "css-in-js-utils": "2.0.1" + } + }, "inquirer": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-3.3.0.tgz", @@ -6321,6 +6380,11 @@ "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-1.4.1.tgz", "integrity": "sha1-OGchPo3Xm/Ho8jAMDPwe+xgsDfE=" }, + "keycode": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz", + "integrity": "sha1-PQr1bce4uOXLqNCpfxByBO7CKwQ=" + }, "killable": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.0.tgz", @@ -6831,6 +6895,14 @@ "to-regex": "3.0.2" } }, + "narcissus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/narcissus/-/narcissus-1.0.0.tgz", + "integrity": "sha1-JGKgfEWYzpBl60Gyq72zDQ4w9G4=", + "requires": { + "inline-style-prefixer": "4.0.2" + } + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -8809,6 +8881,14 @@ "prop-types": "15.6.1" } }, + "react-center-component": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-center-component/-/react-center-component-3.0.0.tgz", + "integrity": "sha1-0omGv0NOD46/9jyRJ38b9q0YnHI=", + "requires": { + "lodash": "4.17.10" + } + }, "react-dev-utils": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-5.0.1.tgz", @@ -8850,6 +8930,26 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-4.0.0.tgz", "integrity": "sha512-FlsPxavEyMuR6TjVbSSywovXSEyOg6ZDj5+Z8nbsRl9EkOzAhEIcS+GLoQDC5fz/t9suhUXWmUrOBrgeUvrMxw==" }, + "react-modal-dialog": { + "version": "github:Dean177/react-modal-dialog#9dbc6fe9e1d48d4643714c02ec46c8d991f338ea", + "requires": { + "active-event-stack": "1.0.0", + "classnames": "2.2.5", + "dynamics.js": "1.1.5", + "immutable": "3.8.2", + "keycode": "2.2.0", + "lodash": "3.10.1", + "narcissus": "1.0.0", + "react-center-component": "3.0.0" + }, + "dependencies": { + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=" + } + } + }, "react-scripts": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-1.1.4.tgz", diff --git a/package.json b/package.json index ecde0e2..e60d660 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@fortawesome/react-fontawesome": "0.0.20", "react": "^16.3.2", "react-dom": "^16.3.2", + "react-modal-dialog": "Dean177/react-modal-dialog#support/bump-react", "react-scripts": "1.1.4" }, "scripts": { diff --git a/server.py b/server.py index ea4b814..e610f57 100644 --- a/server.py +++ b/server.py @@ -3,6 +3,9 @@ from flask import Flask, send_from_directory from flask_sockets import Sockets from gevent import pywsgi from geventwebsocket.handler import WebSocketHandler +import os +import os.path +import pickle try: import unicornhathd as unicorn @@ -10,24 +13,31 @@ try: except ImportError: from unicorn_hat_sim import unicornhathd as unicorn +# Actions that can be sent NO_OP = 'NO_OP' SET_PIXEL = 'SET_PIXEL' CLEAR = 'CLEAR' +SAVE = 'SAVE' +LOAD = 'LOAD' +# GLobal references to app & clients all_clients = set() - app = Flask(__name__, static_folder='build/') sockets = Sockets(app) def send_state(): global all_clients - jsonPixels = json.dumps(unicorn.get_pixels()) + state = { + "saves": os.listdir(SAVES_DIR), + "pixels": unicorn.get_pixels() + } + stateJson = json.dumps(state) for client in all_clients: - client.send(jsonPixels) + client.send(stateJson) def execute_command(command): cmd_type = command['type'] - if cmd_type == NO_OP: + if cmd_type == NO_OP: # Used just to get state without doing an action pass elif cmd_type == SET_PIXEL: x = int(command['x']) @@ -40,7 +50,28 @@ def execute_command(command): elif cmd_type == CLEAR: unicorn.clear() unicorn.show() + elif cmd_type == SAVE: + saveName = command["saveName"] + save(saveName) + elif cmd_type == LOAD: + saveName = command["saveName"] + load(saveName) + +# Constants +SAVES_DIR = 'saves/' +def save(saveName): + with open(os.path.join(SAVES_DIR, saveName), "wb") as f: + pickle.dump(unicorn.get_pixels(), f) + +def load(saveName): + with open(os.path.join(SAVES_DIR, saveName), "rb") as f: + pixels = pickle.load(f) + for x, row in enumerate(pixels): + for y, pixel in enumerate(row): + unicorn.set_pixel(x, y, *pixel) + unicorn.show() + @sockets.route('/ws') def do_websocket(websocket): global all_clients @@ -48,6 +79,8 @@ def do_websocket(websocket): try: while not websocket.closed: command = websocket.receive() + if not command: + break cmd = json.loads(command) execute_command(cmd) send_state() @@ -60,12 +93,17 @@ def send_static(path): return send_from_directory('build/', path) def main(): + try: + os.mkdir(SAVES_DIR) + except: + pass try: print("Serving on port 3001") server = pywsgi.WSGIServer(('', 3001), app, handler_class=WebSocketHandler) server.serve_forever() - except: + finally: unicorn.off() if __name__=="__main__": - main() \ No newline at end of file + main() + \ No newline at end of file diff --git a/src/Actions.js b/src/Actions.js new file mode 100644 index 0000000..1f4e7cf --- /dev/null +++ b/src/Actions.js @@ -0,0 +1,51 @@ +const NO_OP = 'NO_OP' +const SET_PIXEL = 'SET_PIXEL' +const CLEAR = 'CLEAR' +const SAVE = 'SAVE' +const LOAD = 'LOAD' + +function sendAction(websocket, action) { + let actionStr = JSON.stringify(action) + websocket.send(actionStr) +} + +function save(websocket, saveName) { + sendAction(websocket, { + type: SAVE, + saveName: saveName + }) +} + +function load(websocket, saveName) { + sendAction(websocket, { + type: LOAD, + saveName: saveName + }) +} + +function setPixel(websocket, x, y, r, g, b) { + sendAction(websocket, { + type: SET_PIXEL, + x: x, + y: y, + r: r, + g: g, + b: b + }) +} + +function clear(websocket) { + sendAction(websocket, { type: CLEAR }) +} + +function noop(websocket) { + sendAction(websocket, { type: NO_OP }) +} + +export { + setPixel, + clear, + noop, + save, + load +} \ No newline at end of file diff --git a/src/App.css b/src/App.css index 9de7f5a..c436008 100644 --- a/src/App.css +++ b/src/App.css @@ -1,19 +1,38 @@ -.paintarea { +.toolkit { + float: right; + background: lightblue; } +.toolkititem { + padding: 5px; +} + +.toolkititem.selected { + background: lightcoral; +} + +/* .paintarea { + +} */ + .paintareacell { - width: 40px; - height: 40px; + width: 20px; + height: 20px; border: 1px solid grey; } .paletteitem { - width: 40px; - height: 40px; - border: 1px solid grey; + width: 30px; + height: 30px; + border: 3px solid black; display: inline-block; } .paletteitem.selected { - border: 1px solid red; + border: 3px solid red; +} + +.colorIndicator { + width: 100px; + height: 100px; } \ No newline at end of file diff --git a/src/App.js b/src/App.js index 221fbd4..68473ce 100644 --- a/src/App.js +++ b/src/App.js @@ -1,150 +1,80 @@ import React, { Component } from 'react' -import logo from './logo.svg' import './App.css' - -const NO_OP = 'NO_OP' -const SET_PIXEL = 'SET_PIXEL' -const CLEAR = 'CLEAR' - -function sendAction(websocket, action) { - let actionStr = JSON.stringify(action) - websocket.send(actionStr) -} - -function setPixel(websocket, x, y, r, g, b) { - sendAction(websocket, { - type: SET_PIXEL, - x: x, - y: y, - r: r, - g: g, - b: b - }) -} - -function clear(websocket) { - sendAction(websocket, { type: CLEAR }) -} - -function noop(websocket) { - sendAction(websocket, { type: NO_OP }) -} - -function rgb(item) { - return { - r: item[0], - g: item[1], - b: item[2] - } -} - -const colorPalette = [ - [0,0,0], - [132,0,0], - [0,132,0], - [132,132,0], - [0,0,132], - [132,0,132], - [0,132,132], - [132,132,132], - [198,198,198], - [255,0,0], - [0,255,0], - [255,255,0], - [0,0,255], - [255,0,255], - [0,255,255], - [255,255,255], -] - - -class Palette extends Component { - render() { - let paletteListItems = colorPalette.map((item) => { - var className = "paletteitem" - let { r, g, b } = rgb(item) - let selected = rgb(this.props.selectedColor) - if (r === selected.r && g === selected.g && b === selected.b) { - className += " selected" - } - return
this.props.onSelectColor([r, g, b])} - style={{background: `rgb(${r},${g},${b})`}} - className={className} - key={r*10000+g*1000+b}/> - }) - return ( -
- {paletteListItems} -
) - } -} - -class PaintArea extends Component { - constructor(props) { - super(props) - this.handleMouseMove = this.handleMouseMove.bind(this) - - this._onMouseDown = this._onMouseDown.bind(this) - this._onMouseUp = this._onMouseUp.bind(this) - this.state = { - mouseDown: false +import { rgb, xy, findContiguousPixels, getPixel } from './Utils' +import { setPixel, clear, noop, save, load } from './Actions' +import Palette from './Palette' +import PaintArea from './PaintArea' +import Toolkit from './Toolkit' +import ColorIndicator from './ColorIndicator' +import ConnectedIndicator from './ConnectedIndicator' +import LoadDialog from './LoadDialog' +import SaveDialog from './SaveDialog' + +const tools = [ + { + name: "paint", + icon: "fas fa-pencil-alt", + action: function (x, y) { + let { r, g, b } = rgb(this.state.selectedColor) + setPixel(this._websocket, x, y, r, g, b) } - } - - _onMouseDown() { - this.setState({ mouseDown: true }) - } - - _onMouseUp() { - this.setState({ mouseDown: false }) - } - - componentWillMount() { - document.addEventListener('mousedown', this._onMouseDown) - document.addEventListener('mouseup', this._onMouseUp) - } - - componentWillUnmount() { - document.removeEventListener('mousedown', this._onMouseDown) - document.removeEventListener('mouseup', this._onMouseUp) - } - - handleMouseMove(x, y) { - if (this.state.mouseDown) { - this.props.onTool(x, y) - } - } - - render() { - let cells = this.props.data.map((row, iy) => { - let rowCells = row.map((cell, ix) => { - let r = cell[0] - let g = cell[1] - let b = cell[2] - return this.handleMouseMove(ix, iy)} - onClick={() => this.props.onTool(ix, iy)} - className="paintareacell" - style={{ - background: `rgb(${r},${g},${b})` - }} - key={(ix * 100000) + iy}/> + }, + { + name: "fill", + icon: "fab fa-bitbucket", + action: function (x, y) { + let pixelsToColor = findContiguousPixels(x, y, this.state.pixels) + pixelsToColor.forEach((coord) => { + let px = { ...xy(coord), ...rgb(this.state.selectedColor)} + setPixel(this._websocket, px.x, px.y, px.r, px.g, px.b) }) - return {rowCells} - }) - - return ( - - - {cells} - -
- ) - } -} + } + }, + { + name: "erase", + icon: "fas fa-eraser", + action: function (x, y) { + setPixel(this._websocket, x, y, 0, 0, 0) + }, + }, + { + name: "pick", + icon: "fas fa-eye-dropper", + action: function (x, y) { + let color = getPixel(x, y, this.state.pixels) + this.setState({ selectedColor: color }) + }, + }, + // { + // name: "lighten", + // icon: "far fa-sun" + // }, + // { + // name: "darken", + // icon: "fas fa-sun" + // }, + { + name: "save", + icon: "fas fa-save", + onSelect: function () { + this.setState({ showingSave: true }) + } + }, + { + name: "load", + icon: "fas fa-save", + onSelect: function () { + this.setState({ showingLoad: true }) + } + }, + { + name: "trash", + icon: "fas fa-trash", + onSelect: function () { + clear(this._websocket) + } + }, +] class App extends Component { constructor(props) { @@ -152,28 +82,31 @@ class App extends Component { this.state = { connected: false, + // Data from server pixels: [], - selectedColor: [0, 0, 0] + saves: [], + // Local data + selectedColor: [0, 0, 0], + selectedTool: tools[0], + showingSave: false, + showingLoad: false, } + this._applyTool = this._applyTool.bind(this) + this._selectTool = this._selectTool.bind(this) this._connectWebsocket = this._connectWebsocket.bind(this) this._onMessage = this._onMessage.bind(this) this._onOpen = this._onOpen.bind(this) this._onClose = this._onClose.bind(this) this._onError = this._onError.bind(this) - - this.paintCell = this.paintCell.bind(this) + this._loadDrawing = this._loadDrawing.bind(this) + this._saveDrawing = this._saveDrawing.bind(this) this._connectWebsocket() } _onMessage({data}) { - let pixels = JSON.parse(data) - pixels.forEach(row => { - let rowNums = row.map((col) => col.some((it) => it > 0)) - console.log(...rowNums) - }); - + let state = JSON.parse(data) this.setState({ - pixels: pixels + ...state // Includes pixels and saves }) } @@ -200,24 +133,71 @@ class App extends Component { this._websocket.onerror = this._onError } - paintCell(x, y) { - let { r, g, b } = rgb(this.state.selectedColor) - setPixel(this._websocket, x, y, r, g, b) + _applyTool(x, y) { + let tool = this.state.selectedTool + if (!tool) { + return + } + let action = tool.action + if (!action) { + return + } + action.bind(this)(x, y) + } + + _selectTool(tool) { + let selectAction = tool.onSelect + if (selectAction) { + selectAction.bind(this)() + } else { + this.setState({selectedTool: tool}) + } + } + + _loadDrawing(name) { + load(this._websocket, name) + this.setState({showingLoad: false}) + } + + _saveDrawing(name) { + save(this._websocket, name) + this.setState({showingSave: false}) } render() { - let connectedText = this.state.connected ? "Connected" : "Not connected" return (
-
- {connectedText} -
+ + + onTool={this._applyTool}/> this.setState({selectedColor: color})} /> + +
+ { + this.state.showingLoad + && this._loadDrawing(drawing)} + onClose={() => this.setState({showingLoad: false})}/> + } +
+
+ { + this.state.showingSave + && this._saveDrawing(name)} + onClose={() => this.setState({showingSave: false})}/> + } +
+
); } diff --git a/src/App.test.js b/src/App.test.js deleted file mode 100644 index a754b20..0000000 --- a/src/App.test.js +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import App from './App'; - -it('renders without crashing', () => { - const div = document.createElement('div'); - ReactDOM.render(, div); - ReactDOM.unmountComponentAtNode(div); -}); diff --git a/src/ColorIndicator.js b/src/ColorIndicator.js new file mode 100644 index 0000000..e84a661 --- /dev/null +++ b/src/ColorIndicator.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react' +import { rgb } from './Utils' + +function toHex(n) { + var s = Number(n).toString(16) + if (s.length === 1) { + s = "0" + s + } + return s +} + +export default class ColorIndicator extends Component { + render() { + let { r, g, b } = rgb(this.props.color) + let colorDesc = `#${toHex(r)}${toHex(g)}${toHex(b)}` + var foreground = "black" + if (r < 133 && g < 133 && b < 133) { + foreground = "white" + } + + return
+ {colorDesc} +
+ } +} \ No newline at end of file diff --git a/src/ConnectedIndicator.js b/src/ConnectedIndicator.js new file mode 100644 index 0000000..d4e1686 --- /dev/null +++ b/src/ConnectedIndicator.js @@ -0,0 +1,11 @@ +import React, { Component } from 'react' + +export default class ConnectedIndicator extends Component { + render() { + let connectedText = this.props.connected ? "Connected" : "Not connected" + let color = this.props.connected ? "green" : "red" + return
{connectedText}
+ } +} \ No newline at end of file diff --git a/src/LoadDialog.js b/src/LoadDialog.js new file mode 100644 index 0000000..8f4d20b --- /dev/null +++ b/src/LoadDialog.js @@ -0,0 +1,21 @@ +import React, { Component } from 'react' +import { ModalContainer, ModalDialog } from 'react-modal-dialog' + +export default class LoadDialog extends Component { + render() { + let savesListItems = this.props.saves.map((save) => { + return this.props.onLoad(save)}> +
  • {save}
  • +
    + }) + + return + +

    Load

    +
      + {savesListItems} +
    +
    +
    + } +} \ No newline at end of file diff --git a/src/PaintArea.js b/src/PaintArea.js new file mode 100644 index 0000000..1ffec83 --- /dev/null +++ b/src/PaintArea.js @@ -0,0 +1,74 @@ +import React, { Component } from 'react' + +/** + * Expects props: + * onTool = (x, y) => {} + * Callback for when a cell is clicked + * + * data = [[]] + * 3d array - rows, cells, RGB value for each cell + */ +export default class PaintArea extends Component { + constructor(props) { + super(props) + this.handleMouseMove = this.handleMouseMove.bind(this) + this._onMouseDown = this._onMouseDown.bind(this) + this._onMouseUp = this._onMouseUp.bind(this) + this.state = { + mouseDown: false + } + } + + _onMouseDown() { + this.setState({ mouseDown: true }) + } + + _onMouseUp() { + this.setState({ mouseDown: false }) + } + + componentWillMount() { + document.addEventListener('mousedown', this._onMouseDown) + document.addEventListener('mouseup', this._onMouseUp) + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this._onMouseDown) + document.removeEventListener('mouseup', this._onMouseUp) + } + + handleMouseMove(x, y) { + if (this.state.mouseDown) { + this.props.onTool(x, y) + } + } + + render() { + let cells = this.props.data.map((row, iy) => { + let rowCells = row.map((cell, ix) => { + let r = cell[0] + let g = cell[1] + let b = cell[2] + return this.handleMouseMove(ix, iy)} + onClick={() => this.props.onTool(ix, iy)} + className="paintareacell" + style={{ + background: `rgb(${r},${g},${b})` + }} + key={(ix * 100000) + iy}/> + }) + return {rowCells} + }) + + return ( + + + {cells} + +
    + ) + } +} \ No newline at end of file diff --git a/src/Palette.js b/src/Palette.js new file mode 100644 index 0000000..ad0ad46 --- /dev/null +++ b/src/Palette.js @@ -0,0 +1,44 @@ +import React, {Component} from 'react' +import { rgb } from './Utils.js' + +const colorPalette = [ + [0,0,0], + [132,0,0], + [0,132,0], + [132,132,0], + [0,0,132], + [132,0,132], + [0,132,132], + [132,132,132], + [198,198,198], + [255,0,0], + [0,255,0], + [255,255,0], + [0,0,255], + [255,0,255], + [0,255,255], + [255,255,255], + ] + + + export default class Palette extends Component { + render() { + let paletteListItems = colorPalette.map((item) => { + var className = "paletteitem" + let { r, g, b } = rgb(item) + let selected = rgb(this.props.selectedColor) + if (r === selected.r && g === selected.g && b === selected.b) { + className += " selected" + } + return
    this.props.onSelectColor([r, g, b])} + style={{background: `rgb(${r},${g},${b})`}} + className={className} + key={r*10000+g*1000+b}/> + }) + return ( +
    + {paletteListItems} +
    ) + } + } \ No newline at end of file diff --git a/src/SaveDialog.js b/src/SaveDialog.js new file mode 100644 index 0000000..d1a80fd --- /dev/null +++ b/src/SaveDialog.js @@ -0,0 +1,28 @@ +import React, { Component } from 'react' +import { ModalContainer, ModalDialog } from 'react-modal-dialog' + +export default class SaveDialog extends Component { + constructor(props) { + super(props) + this.state = { + name: "" + } + } + + render() { + return + +

    Save

    +
    { + this.props.onSave(this.state.name) + event.preventDefault()}}> + this.setState({name: event.target.value})}/> + +
    +
    +
    + } +} diff --git a/src/Toolkit.js b/src/Toolkit.js new file mode 100644 index 0000000..35d8759 --- /dev/null +++ b/src/Toolkit.js @@ -0,0 +1,31 @@ +import React, { Component } from 'react' +import './fontawesome-all.min.js' + +class ToolKitItem extends Component { + render() { + let iconClass = this.props.tool.icon + " fa-2x" + var divClass = "toolkititem" + if (this.props.selected) { + divClass += " selected" + } + return
    this.props.onSelectTool(this.props.tool)}> + +
    + } +} + +export default class Toolkit extends Component { + render() { + let toolComponents = this.props.tools.map((tool) => { + return + }) + return
    + {toolComponents} +
    + } +} diff --git a/src/Utils.js b/src/Utils.js new file mode 100644 index 0000000..f7bba5c --- /dev/null +++ b/src/Utils.js @@ -0,0 +1,103 @@ +/** + * Converts RGB array to named object + * @param {[]} item, a length 3 array containing 8 bit RGB value + */ +function rgb(item) { + return { + r: item[0], + g: item[1], + b: item[2] + } +} + +/** + * Converts XY array to named object + * @param {[]} item, a 2 length array containing x and y values + */ +function xy(item) { + return { + x: item[0], + y: item[1] + } +} + +function colorEqual(item1, item2) { + if (!item1 || !item2) { + return false + } + if (Array.isArray(item1)) { + item1 = rgb(item1) + } + if (Array.isArray(item2)) { + item2 = rgb(item2) + } + return item1.r === item2.r + && item1.g === item2.g + && item1.b === item2.b +} + +function coordsEqual(item1, item2) { + if (!item1 || !item2) { + return false + } + + if (Array.isArray(item1)) { + item1 = xy(item1) + } + if (Array.isArray(item2)) { + item2 = xy(item2) + } + return item1.x === item2.x + && item1.y === item2.y +} + +function getPixel(x, y, pixels) { + let row = pixels[y] + if (!row) { + return + } + return row[x] +} + +function findContiguousPixels(x, y, pixels, targetColor = getPixel(x, y, pixels), contiguousPixels=[[x, y]]) { + let adjescent = [ + [x-1, y], + [x+1, y], + [x, y-1], + [x, y+1] + ] + + adjescent.forEach((coord) => { + let px = xy(coord) + let pxCol = getPixel(px.x, px.y, pixels) + if (!pxCol) { + return + } + + // add adjescents uniquely if they are the target color + let ix = contiguousPixels.findIndex((existingCoord) => coordsEqual(coord, existingCoord)) + if (ix !== -1) { + return + } + + if (!colorEqual(pxCol, targetColor)) { + return + } + contiguousPixels.push(coord) + let morePixels = findContiguousPixels(px.x, px.y, pixels, targetColor, contiguousPixels) + contiguousPixels.concat(morePixels) + }) + + return contiguousPixels +} + + +export { + xy, + rgb, + colorEqual, + coordsEqual, + findContiguousPixels, + getPixel +} + diff --git a/src/Utils.test.js b/src/Utils.test.js new file mode 100644 index 0000000..6c9161b --- /dev/null +++ b/src/Utils.test.js @@ -0,0 +1,81 @@ +import { colorEqual, coordsEqual, xy, rgb, findContiguousPixels } from './Utils' + +test('test colorEqual function', () => { + expect(colorEqual([0, 0, 0], [0, 0, 0])) + .toBe(true) + expect(colorEqual([1, 0, 0], [0, 0, 0])) + .toBe(false) + expect(colorEqual([0, 1, 0], [0, 0, 0])) + .toBe(false) + expect(colorEqual([0, 0, 1], [0, 0, 0])) + .toBe(false) + + expect(colorEqual([0, 0, 1], {r: 0, g: 0, b: 1})) + .toBe(true) + expect(colorEqual({r: 0, g: 0, b: 1}, [0, 0, 1])) + .toBe(true) + + expect(colorEqual({r: 0, g: 0, b: 0}, [0, 0, 1])) + .toBe(false) + expect(colorEqual([0, 0, 1], {r: 0, g: 0, b: 0})) + .toBe(false) + + expect(colorEqual(undefined, {r: 0, g: 0, b: 0})) + .toBe(false) + expect(colorEqual({r: 0, g: 0, b: 0}, undefined)) + .toBe(false) +}) + +test('coordEqual function', () => { + expect(coordsEqual([0, 0], [0, 0])) + .toBe(true) + expect(coordsEqual([0, 1], [0, 0])) + .toBe(false) + expect(coordsEqual([1, 0], [0, 0])) + .toBe(false) + + expect(coordsEqual([1, 0], [0, 0])) + .toBe(false) + + expect(coordsEqual(undefined, [0, 0])) + .toBe(false) + expect(coordsEqual([0, 0], undefined)) + .toBe(false) +}) + +test('contiguousPixels', () => { + let allBlack = [ + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]] + ] + + let contiguousPx = findContiguousPixels(0, 0, allBlack) + expect(contiguousPx.length) + .toBe(16) + + expect(findContiguousPixels(0, 0, [ + [[0, 0, 0], [0, 0, 1], [0, 0, 0], [0, 0, 0]], + [[0, 0, 1], [0, 0, 1], [0, 0, 0], [0, 0, 0]], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]] + ]).length) + .toBe(1) + + expect(findContiguousPixels(1, 1, [ + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]], + [[0, 0, 0], [0, 0, 1], [0, 0, 1], [0, 0, 0]], + [[0, 0, 0], [0, 0, 1], [0, 0, 1], [0, 0, 0]], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]] + ]).length) + .toBe(4) + + expect(findContiguousPixels(1, 1, [ + [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1]], + [[0, 0, 1], [0, 0, 0], [0, 0, 0], [0, 0, 1]], + [[0, 0, 1], [0, 0, 0], [0, 0, 0], [0, 0, 1]], + [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1]] + ]).length) + .toBe(4) +}) \ No newline at end of file diff --git a/src/fontawesome-all.min.js b/src/fontawesome-all.min.js new file mode 100644 index 0000000..a382743 --- /dev/null +++ b/src/fontawesome-all.min.js @@ -0,0 +1,5 @@ +/*! + * Font Awesome Free 5.0.13 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + */ +!function(){"use strict";var c={};try{"undefined"!=typeof window&&(c=window)}catch(c){}var l=(c.navigator||{}).userAgent,h=void 0===l?"":l,v=c,z=(~h.indexOf("MSIE")||h.indexOf("Trident/"),"___FONT_AWESOME___"),e=function(){try{return!0}catch(c){return!1}}(),m=[1,2,3,4,5,6,7,8,9,10],a=m.concat([11,12,13,14,15,16,17,18,19,20]);["xs","sm","lg","fw","ul","li","border","pull-left","pull-right","spin","pulse","rotate-90","rotate-180","rotate-270","flip-horizontal","flip-vertical","stack","stack-1x","stack-2x","inverse","layers","layers-text","layers-counter"].concat(m.map(function(c){return c+"x"})).concat(a.map(function(c){return"w-"+c}));var s=v||{};s[z]||(s[z]={}),s[z].styles||(s[z].styles={}),s[z].hooks||(s[z].hooks={}),s[z].shims||(s[z].shims=[]);var t=s[z],f=Object.assign||function(c){for(var l=1;l>>0;h--;)l[h]=c[h];return l}function X(c){return c.classList?D(c.classList):(c.getAttribute("class")||"").split(" ").filter(function(c){return c})}function Y(c,l){var h,v=l.split("-"),z=v[0],e=v.slice(1).join("-");return z!==c||""===e||(h=e,~d.indexOf(h))?null:e}function U(c){return(""+c).replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(//g,">")}function K(h){return Object.keys(h||{}).reduce(function(c,l){return c+(l+": ")+h[l]+";"},"")}function G(c){return c.size!==I.size||c.x!==I.x||c.y!==I.y||c.rotate!==I.rotate||c.flipX||c.flipY}function J(c){var l=c.transform,h=c.containerWidth,v=c.iconWidth;return{outer:{transform:"translate("+h/2+" 256)"},inner:{transform:"translate("+32*l.x+", "+32*l.y+") "+" "+("scale("+l.size/16*(l.flipX?-1:1)+", "+l.size/16*(l.flipY?-1:1)+") ")+" "+("rotate("+l.rotate+" 0 0)")},path:{transform:"translate("+v/2*-1+" -256)"}}}var Q={x:0,y:0,width:"100%",height:"100%"},Z=function(c){var l=c.children,h=c.attributes,v=c.main,z=c.mask,e=c.transform,m=v.width,a=v.icon,s=z.width,t=z.icon,f=J({transform:e,containerWidth:s,iconWidth:m}),M={tag:"rect",attributes:S({},Q,{fill:"white"})},r={tag:"g",attributes:S({},f.inner),children:[{tag:"path",attributes:S({},a.attributes,f.path,{fill:"black"})}]},H={tag:"g",attributes:S({},f.outer),children:[r]},i="mask-"+B(),n="clip-"+B(),V={tag:"defs",children:[{tag:"clipPath",attributes:{id:n},children:[t]},{tag:"mask",attributes:S({},Q,{id:i,maskUnits:"userSpaceOnUse",maskContentUnits:"userSpaceOnUse"}),children:[M,H]}]};return l.push(V,{tag:"rect",attributes:S({fill:"currentColor","clip-path":"url(#"+n+")",mask:"url(#"+i+")"},Q)}),{children:l,attributes:h}},$=function(c){var l=c.children,h=c.attributes,v=c.main,z=c.transform,e=K(c.styles);if(0"+m.map(uc).join("")+""}var dc=function(){};function pc(c){return"string"==typeof(c.getAttribute?c.getAttribute(g):null)}var bc={replace:function(c){var l=c[0],h=c[1].map(function(c){return uc(c)}).join("\n");if(l.parentNode&&l.outerHTML)l.outerHTML=h+(O.keepOriginalSource&&"svg"!==l.tagName.toLowerCase()?"\x3c!-- "+l.outerHTML+" --\x3e":"");else if(l.parentNode){var v=document.createElement("span");l.parentNode.replaceChild(v,l),v.outerHTML=h}},nest:function(c){var l=c[0],h=c[1];if(~X(l).indexOf(O.replacementClass))return bc.replace(c);var v=new RegExp(O.familyPrefix+"-.*");delete h[0].attributes.style;var z=h[0].attributes.class.split(" ").reduce(function(c,l){return l===O.replacementClass||l.match(v)?c.toSvg.push(l):c.toNode.push(l),c},{toNode:[],toSvg:[]});h[0].attributes.class=z.toSvg.join(" ");var e=h.map(function(c){return uc(c)}).join("\n");l.setAttribute("class",z.toNode.join(" ")),l.setAttribute(g,""),l.innerHTML=e}};function gc(h,c){var v="function"==typeof c?c:dc;0===h.length?v():(a.requestAnimationFrame||function(c){return c()})(function(){var c=!0===O.autoReplaceSvg?bc.replace:bc[O.autoReplaceSvg]||bc.replace,l=sc.begin("mutate");h.map(c),l(),v()})}var wc=!1;var yc=null;var Sc=function(c){var l=c.getAttribute("style"),h=[];return l&&(h=l.split(";").reduce(function(c,l){var h=l.split(":"),v=h[0],z=h.slice(1);return v&&0li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:solid .08em #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scale(-1,1);transform:scale(-1,1)}.fa-flip-vertical{-webkit-transform:scale(1,-1);transform:scale(1,-1)}.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1,-1);transform:scale(-1,-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-rotate-90{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;position:relative;width:2em}.fa-stack-1x,.fa-stack-2x{bottom:0;left:0;margin:auto;position:absolute;right:0;top:0}.svg-inline--fa.fa-stack-1x{height:1em;width:1em}.svg-inline--fa.fa-stack-2x{height:2em;width:2em}.fa-inverse{color:#fff}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}";if("fa"!==l||h!==c){var z=new RegExp("\\.fa\\-","g"),e=new RegExp("\\."+c,"g");v=v.replace(z,"."+l+"-").replace(e,"."+h)}return v};var Qc=function(){function c(){w(this,c),this.definitions={}}return y(c,[{key:"add",value:function(){for(var l=this,c=arguments.length,h=Array(c),v=0;v - - - - - - diff --git a/unicorn_hat_sim.py b/unicorn_hat_sim.py index 9d7d1a0..c2cd8b1 100644 --- a/unicorn_hat_sim.py +++ b/unicorn_hat_sim.py @@ -27,7 +27,8 @@ class UnicornHatSim(object): pygame.init() pygame.display.set_caption("Unicorn HAT simulator") self.screen = pygame.display.set_mode([self.window_width, self.window_height]) - self.clear() + # self.clear() + self.screen.fill((0, 0, 0)) def set_pixel(self, x, y, r, g, b): i = (x * self.width) + y @@ -55,7 +56,8 @@ class UnicornHatSim(object): self.draw_led(x, y) def show(self): - self.clear() + # self.clear() + self.screen.fill((0, 0, 0)) self.draw() pygame.display.flip() @@ -81,8 +83,9 @@ class UnicornHatSim(object): self._rotation = int(round(r/90.0)) % 3 def clear(self): - self.screen.fill((0, 0, 0)) - + # self.screen.fill((0, 0, 0)) + self.pixels = [(0, 0, 0)] * self.width * self.height + def get_rotation(self): return self._rotation * 90 -- cgit v1.2.3-ZIG