├── .gitignore ├── .travis.yml ├── AUTHORS ├── ChangeLog ├── LICENSE ├── README.md ├── examples ├── counter.py ├── simpletodo.py └── timer.py ├── flybywire ├── __init__.py ├── core.py ├── dom.py ├── misc.py ├── static │ ├── flybywire.js │ └── main.html └── ui.py ├── setup.cfg ├── setup.py └── test └── test_dom.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | - "3.5" 6 | - "pypy" 7 | 8 | # command to install dependencies 9 | install: 10 | - python -m pip install -U pip 11 | - python -m pip install -U setuptools 12 | - pip install . 13 | 14 | # command to run tests 15 | script: python setup.py test 16 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Thomas Antony 2 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | * Cleaned up dom.py and fixed bugs in test_dom.py 5 | * Added @Application class decorator 6 | * Fixed bug in timer.py for load/close events. Refactored coroutine decorator is no longer needed 7 | * Created new Component class separate from Application class. Added observer framework for monitoring state in component 8 | * Minor changes 9 | * Updated README.md with new demo and gif. Fixed typo in setup.cfg 10 | * Fixed bug in test_dom.py 11 | * Very rudimentary (and buggy) todo app example 12 | * Removed @component decorator as it is not really needed in Python due to keyword args 13 | * Added @component decorator for functional components 14 | * Simplified DOM event notation 15 | * Added composable DOM. Added tests for callbacks and composable DOMs 16 | * Removed a few debugging statements 17 | * Added graceful exit in timer app 18 | * Fixed bug related to bound methods. DOM click event now working 19 | * DOM event working but triggering wrong callbacks 20 | * Rudimentary buggy implmentation of DOM events 21 | * Added set_interval helper function. Pending task error still exists 22 | * Removed extra heading from README.md 23 | * Added badges to README.md 24 | * Removed Enum from dom.py 25 | * Updated .travis.yml to update setuptools and pip 26 | * Added .travis.yml 27 | * Generated AUTHORS and ChangeLog using pbr 28 | * Switched to setup.cfg for use with pbr 29 | * Added dom module for easy construction of DOM trees 30 | * Counter working from python. Weird issues when multiple clients open 31 | * Updated dom over websocket 32 | * Added rough shutdown command 33 | * Made server persistent and two-way communication verified 34 | * Got vdom counter running in pure JS 35 | * Initial commit 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Thomas Antony 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `flybywire` is an OS-agnostic, declarative UI library for Python based on [Sofi](https://github.com/tryexceptpass/sofi) and inspired by Facebook's [React](https://facebook.github.io/react/) framework. The main goal behind this experiment was to build elegant, interactive UIs in pure Python while leveraging web technologies. Eventually, `flybywire` will use something like [Electron](http://electron.atom.io/) rather than a web browser for greater control over what is rendered. 2 | 3 | [![Build Status](https://travis-ci.org/thomasantony/flybywire.svg?branch=master)](https://travis-ci.org/thomasantony/flybywire) 4 | [![PyPI version](https://badge.fury.io/py/flybywire.svg)](https://badge.fury.io/py/flybywire) 5 | 6 | Overview 7 | -------- 8 | The interface is built using a virtual-DOM layer and sent to the browser over WebSockets. All the view logic is written in pure Python. Instead of providing a set of widgets, `flybywire` allows components to be defined in a declarative manner out of standard HTML tags and CSS using an easy, readable syntax. 9 | 10 | As in React, the view is defined as functions of the state. Any changes to the state automatically triggers a redraw of the entire DOM. `flybywire` uses the [virtual-dom](https://github.com/Matt-Esch/virtual-dom) library to update only those parts of the DOM that were actually modified. 11 | 12 | Example 13 | ------- 14 | This is a really simple example to demonstrate the library in action. It shows simple counter whose value can be controlled by two buttons. 15 | 16 | ```python 17 | from flybywire.ui import Application, Component 18 | from flybywire.dom import h 19 | 20 | 21 | def CounterView(count): 22 | """A simple functional stateless component.""" 23 | return h('h1', str(count)) 24 | 25 | @Application 26 | class CounterApp(Component): 27 | def __init__(self): 28 | """Initialize the application.""" 29 | # super(CounterApp, self).__init__() # Python 2.7 30 | super().__init__() 31 | self.set_initial_state(0) 32 | 33 | def render(self): 34 | """Renders view given application state.""" 35 | return h('div', 36 | [CounterView(count=self.state), 37 | h('button', '+', onclick = self.increment), 38 | h('button', '-', onclick = self.decrement)] 39 | ) 40 | 41 | def increment(self, e): 42 | """Increments counter.""" 43 | self.set_state(self.state + 1) 44 | 45 | def decrement(self, e): 46 | """Decrements counter.""" 47 | self.set_state(self.state - 1) 48 | 49 | 50 | app = CounterApp() 51 | app.start() 52 | ``` 53 | 54 | ![flybywire counter demo](https://giant.gfycat.com/HilariousCarefreeAnchovy.gif) 55 | The example can also be found in `examples/counter.py`. 56 | 57 | 58 | Bugs, features and caveats 59 | -------------------------- 60 | - As of now, there is a system in place for some rudimentary DOM event communications. However, slightly more "advanced" features such as doing "event.preventDefault()" for specific events for example, is not available at present. Ideas for exposing this functionality are welcome! I might possibly add back some of the imperative command framework from [Sofi](https://github.com/tryexceptpass/sofi) that I had originally removed from the code. 61 | 62 | - Some simple functionality such as focusing a textbox or clearing it after pressing enter cannot be done right now. There might be some simple way of solving this, possibly by injecting some javascript at render-time. 63 | 64 | - The server will shut down as soon you close your browser window. This is because there is no option to reset the applicaton state right now without restarting the program. 65 | 66 | - Opening multiple browser windows also results in some weirdness. This is again caused by the application state being shared by the all clients. However, this may not be an issue in the future once we move to an architecture based on Electron. Once that happens, there will only ever be one client connected to the server and the server lifecycle will be be tied to that of the actual application window. 67 | 68 | About the author 69 | ---------------- 70 | [Thomas Antony's LinkedIn Profile](https://www.linkedin.com/in/thomasantony) 71 | -------------------------------------------------------------------------------- /examples/counter.py: -------------------------------------------------------------------------------- 1 | from flybywire.ui import Application, Component 2 | from flybywire.dom import h 3 | 4 | 5 | def CounterView(count): 6 | """A simple functional stateless component.""" 7 | return h('h1', str(count)) 8 | 9 | @Application 10 | class CounterApp(Component): 11 | def __init__(self): 12 | """Initialize the application.""" 13 | # super(CounterApp, self).__init__() # Python 2.7 14 | super().__init__() 15 | self.set_initial_state(0) 16 | 17 | def render(self): 18 | """Renders view given application state.""" 19 | return h('div', 20 | [CounterView(count=self.state), 21 | h('button', '+', onclick = self.increment), 22 | h('button', '-', onclick = self.decrement)] 23 | ) 24 | 25 | def increment(self, e): 26 | """Increments counter.""" 27 | self.set_state(self.state + 1) 28 | 29 | def decrement(self, e): 30 | """Decrements counter.""" 31 | self.set_state(self.state - 1) 32 | 33 | 34 | app = CounterApp() 35 | app.start() 36 | -------------------------------------------------------------------------------- /examples/simpletodo.py: -------------------------------------------------------------------------------- 1 | from flybywire.ui import Application, Component 2 | from flybywire.dom import h 3 | import asyncio 4 | 5 | def TodoItem(item): 6 | return h('div', str(item)) 7 | 8 | def NewTodoItem(onAddItem=None): 9 | def handleKeyDown(event): 10 | text = event['target']['value'] 11 | which = event.get('which', '0') 12 | if int(which) == 13 and len(text) > 0: 13 | onAddItem(text) 14 | 15 | return h('input', type='text', autoFocus=True, onKeyDown=handleKeyDown) 16 | 17 | @Application 18 | class TodoApp(Component): 19 | def __init__(self): 20 | """Initialize the application.""" 21 | super().__init__() 22 | self.set_initial_state({'todos': ['Foo','Bar'], 'new_todo': ''}) 23 | 24 | def render(self): 25 | """Renders view given application state.""" 26 | todos = self.state['todos'] 27 | todo_items = [TodoItem(item=item) for item in todos] 28 | return h('div', 29 | [ 30 | h('div', todo_items), 31 | NewTodoItem(onAddItem = self.addTodo) 32 | ] 33 | ) 34 | 35 | def addTodo(self, item): 36 | """Add a todo item.""" 37 | todos = self.state.get('todos', []) 38 | todos.append(item); 39 | self.set_state({'todos': todos, 'new_todo': ''}) 40 | 41 | 42 | app = TodoApp() 43 | app.start() 44 | -------------------------------------------------------------------------------- /examples/timer.py: -------------------------------------------------------------------------------- 1 | from flybywire.ui import Application, Component 2 | from flybywire.dom import h 3 | from flybywire.misc import set_interval, clear_interval 4 | 5 | @Application 6 | class TimerApp(Component): 7 | def __init__(self): 8 | """Initialize the application.""" 9 | super().__init__() 10 | 11 | self.set_initial_state({'secondsElapsed': 0}) 12 | self.task = None 13 | 14 | def render(self): 15 | """Renders view given application state.""" 16 | count = self.state['secondsElapsed'] 17 | return h('div', 'Seconds Elapsed: '+str(count)) 18 | 19 | def tick(self): 20 | """Increments counter.""" 21 | count = self.state['secondsElapsed'] 22 | self.set_state({'secondsElapsed': count + 1}) 23 | 24 | def on_load(self): 25 | """ 26 | Triggers when the application first loads in the browser 27 | """ 28 | self.task = set_interval(self.tick, 1) 29 | 30 | def on_close(self): 31 | """ 32 | Triggers when the application window is closed 33 | """ 34 | clear_interval(self.task) 35 | 36 | app = TimerApp() 37 | app.start() 38 | -------------------------------------------------------------------------------- /flybywire/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.0.2' 2 | __author__ = 'Thomas Antony' 3 | __license__ = 'MIT' 4 | -------------------------------------------------------------------------------- /flybywire/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: flybywire.core 3 | 4 | Contains the 'App' class which forms the base class for any Application built 5 | using this framework. 6 | """ 7 | import os 8 | import abc 9 | import json 10 | import asyncio 11 | import logging 12 | import webbrowser 13 | 14 | from autobahn.asyncio.websocket import WebSocketServerFactory, WebSocketServerProtocol 15 | 16 | class FBWApp(object): 17 | def __init__(self, root): 18 | self.interface = FBWEventProcessor() 19 | self.server = FBWEventServer(processor=self.interface) 20 | self._state = None 21 | self._root = root 22 | self._callbacks = {} 23 | 24 | # Setup callback to initialize DOM when the client connects 25 | self.register('init', self._oninit) 26 | self.register('domevent', self._process_domevent) 27 | self.register('close', self._onclose) 28 | # self.register('shutdown', self._shutdown) 29 | 30 | # Trigger render function when state is updated 31 | self._root.add_observer(self.remote_render) 32 | 33 | logging.basicConfig( 34 | format="%(asctime)s [%(levelname)s] - %(funcName)s: %(message)s", 35 | level=logging.INFO 36 | ) 37 | 38 | def remote_render(self): 39 | """Converts given vdom to JSON and sends it to browser for rendering.""" 40 | content = self._root.render().to_dict() 41 | self._callbacks.update(content['callbacks']) 42 | self.interface.dispatch({ 'name': 'render', 43 | 'vdom': json.dumps(content['dom'])}) 44 | 45 | def update_callbacks(self, callbacks): 46 | """Updates internal list with callbacks found in the dom.""" 47 | if callbacks != self._callbacks: 48 | self._callbacks.update(callbacks) 49 | 50 | @asyncio.coroutine 51 | def _oninit(self, event): 52 | """Trigger render() when app initializes.""" 53 | content = self._root.render().to_dict() 54 | self.update_callbacks(content['callbacks']) 55 | # Send init command to create initial DOM 56 | self.interface.dispatch({ 'name': 'init', 57 | 'vdom': json.dumps(content['dom'])}) 58 | 59 | self._root.on_load() 60 | 61 | @asyncio.coroutine 62 | def _onclose(self, event): 63 | """Trigger the close event handler.""" 64 | self._root.on_close() 65 | 66 | @asyncio.coroutine 67 | def _process_domevent(self, event): 68 | """Routes DOM events to the right callback function.""" 69 | if event['callback'] in self._callbacks: 70 | cb_func, cb_self = self._callbacks[event['callback']] 71 | if cb_self is not None: 72 | cb_func(cb_self, event['event_obj']) 73 | else: 74 | cb_func(event['event_obj']) 75 | else: 76 | logging.error('Callback '+event['callback']+' not found.') 77 | 78 | def start(self, autobrowse=True): 79 | """Start the application.""" 80 | self.server.start(autobrowse) 81 | 82 | def register(self, event, callback, selector=None): 83 | """Register event callback.""" 84 | 85 | self.interface.register(event, callback, selector) 86 | 87 | def unregister(self, event, callback, selector=None): 88 | """Register event callback.""" 89 | self.interface.unregister(event, callback, selector) 90 | 91 | class FBWEventProcessor(object): 92 | """Event handler providing hooks for callback functions""" 93 | 94 | handlers = { 'init': { '_': [] }, 95 | 'load': { '_': [] }, 96 | 'close': { '_': [] }, 97 | 'domevent': {'_': []}, 98 | } 99 | 100 | def register(self, event, callback, selector=None): 101 | if event not in self.handlers: 102 | self.handlers[event] = { '_': [] } 103 | 104 | if selector: 105 | key = str(id(callback)) 106 | else: 107 | key = '_' 108 | 109 | if key not in self.handlers[event]: 110 | self.handlers[event][key] = list() 111 | 112 | self.handlers[event][key].append(callback) 113 | 114 | # if (event not in ('init', 'load', 'close', 'shutdown') 115 | # and len(self.handlers[event].keys()) > 1): 116 | # capture = False 117 | # if selector is None: 118 | # selector = 'html' 119 | # capture = True 120 | # 121 | # self.dispatch({ 'name': 'subscribe', 'event': event, 'selector': selector, 'capture': capture, 'key': str(id(callback)) }) 122 | 123 | def unregister(self, event, callback, selector=None): 124 | if event not in self.handlers: 125 | return 126 | 127 | if selector is None: 128 | self.handlers[event]['_'].remove(callback) 129 | else: 130 | self.handlers[event].pop(str(id(callback))) 131 | 132 | # if event not in ('init', 'load', 'close'): 133 | # self.dispatch({ 'name': 'unsubscribe', 'event': event, 'selector': selector, 'key': str(id(callback)) }) 134 | 135 | def dispatch(self, command): 136 | self.protocol.sendMessage(bytes(json.dumps(command), 'utf-8'), False) 137 | 138 | @asyncio.coroutine 139 | def process(self, protocol, event): 140 | self.protocol = protocol 141 | eventtype = event['event'] 142 | logging.info('Event triggered : '+eventtype) 143 | if eventtype in self.handlers: 144 | # Check for local handler 145 | if 'key' in event: 146 | key = event['key'] 147 | 148 | if key in self.handlers[eventtype]: 149 | for handler in self.handlers[eventtype][key]: 150 | if callable(handler): 151 | yield from handler(event) 152 | 153 | # Check for global handler 154 | for handler in self.handlers[eventtype]['_']: 155 | if callable(handler): 156 | yield from handler(event) 157 | 158 | 159 | class FBWEventProtocol(WebSocketServerProtocol): 160 | """Websocket event handler which dispatches events to FBWEventProcessor""" 161 | 162 | def onConnect(self, request): 163 | logging.info("Client connecting: %s" % request.peer) 164 | 165 | def onOpen(self): 166 | logging.info("WebSocket connection open") 167 | 168 | @asyncio.coroutine 169 | def onMessage(self, payload, isBinary): 170 | if isBinary: 171 | logging.debug("Binary message received: {} bytes".format(len(payload))) 172 | else: 173 | logging.debug("Text message received: {}".format(payload.decode('utf-8'))) 174 | body = json.loads(payload.decode('utf-8')) 175 | 176 | if 'event' in body: 177 | yield from self.processor.process(self, body) 178 | 179 | def onClose(self, wasClean, code, reason): 180 | logging.info("WebSocket connection closed: {}".format(reason)) 181 | 182 | # Stop server when browser exists 183 | loop = asyncio.get_event_loop() 184 | loop.stop() 185 | # Stop all pending tasks 186 | for task in asyncio.Task.all_tasks(): 187 | task.cancel() 188 | exit(0) 189 | 190 | 191 | class FBWEventServer(object): 192 | """Websocket event server""" 193 | 194 | def __init__(self, hostname=u"127.0.0.1", port=9000, processor=None): 195 | 196 | self.hostname = hostname 197 | self.port = port 198 | self.processor = processor 199 | 200 | factory = WebSocketServerFactory(u"ws://" + hostname + u":" + str(port)) 201 | protocol = FBWEventProtocol 202 | protocol.processor = processor 203 | protocol.app = self 204 | 205 | factory.protocol = protocol 206 | 207 | self.loop = asyncio.get_event_loop() 208 | self.server = self.loop.create_server(factory, '0.0.0.0', port) 209 | 210 | def stop(self): 211 | self.loop.stop() 212 | 213 | def start(self, autobrowse=True): 214 | self.loop.run_until_complete(self.server) 215 | 216 | try: 217 | path = os.path.dirname(os.path.realpath(__file__)) 218 | if autobrowse: 219 | webbrowser.open('file:///' + os.path.join(path, 'static/main.html')) 220 | self.loop.run_forever() 221 | 222 | except KeyboardInterrupt: 223 | pass 224 | 225 | finally: 226 | self.server.close() 227 | self.loop.close() 228 | 229 | def __repr__(self): 230 | return "" % (self.hostname, self.port) 231 | 232 | def __str__(self): 233 | return repr(self) 234 | -------------------------------------------------------------------------------- /flybywire/dom.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: dom 3 | 4 | Provides classes and helper functions for easily defining virtual DOM trees. 5 | """ 6 | from collections import Iterable, defaultdict 7 | from functools import wraps 8 | 9 | class NodeType(object): 10 | """Node types as defined by the vdom-as-json library.""" 11 | Text = 1 12 | Patch = 2 13 | Node = 3 14 | Hook = 4 15 | 16 | dom_events = ['onclick', 17 | 'onmousedown', 18 | 'onmouseup', 19 | 'onkeydown', 20 | 'onkeyup', 21 | 'onkeypress', 22 | 'onchange'] 23 | 24 | # Attributes to be saved directly as DOM element properties 25 | attributes_as_props = ['style'] 26 | 27 | class DomNode(object): 28 | def __init__(self, tag, attr, events=None): 29 | """Initializes a DOM node.""" 30 | self.tag = tag 31 | self.attr = attr 32 | self.children = attr.get('children', []) 33 | self.events = events if events is not None else {} 34 | 35 | def to_dict(self): 36 | """Converts to dict compatible with vdom-as-json.""" 37 | 38 | if callable(self.tag): 39 | return self.tag(self.attr).to_dict() 40 | 41 | node = defaultdict(dict) 42 | node['t'] = NodeType.Node 43 | node['tn'] = self.tag.upper() 44 | 45 | callbacks = {} 46 | if len(self.children) > 0: 47 | node['c'] = [] 48 | for c in self.children: 49 | if isinstance(c, str): 50 | node['c'].append({'t': NodeType.Text, 'x': c}) 51 | else: 52 | child_node_dict = c.to_dict() 53 | node['c'].append(child_node_dict['dom']) 54 | if len(child_node_dict['callbacks']) > 0: 55 | callbacks.update(child_node_dict['callbacks']) 56 | 57 | # Check for special attributes 58 | if 'key' in self.attr: 59 | node['k'] = self.attr['key'] 60 | del self.attr['key'] 61 | 62 | if 'namespace' in self.attr: 63 | node['n'] = self.attr['namespace'] 64 | del self.attr['namespace'] 65 | 66 | event_attributes, new_callbacks = self.get_dom_callbacks() 67 | callbacks.update(new_callbacks) 68 | self.attr.update(event_attributes) 69 | 70 | attributes, properties = self.get_attributes_and_props() 71 | 72 | if len(attributes) > 0: 73 | properties['attributes'] = attributes 74 | 75 | if len(properties) > 0: 76 | node['p'] = properties 77 | 78 | return {'dom': node, 'callbacks': callbacks} 79 | 80 | def get_dom_callbacks(self): 81 | attributes = {} 82 | # attributes['fbwHasCallback'] = True 83 | callbacks = {} 84 | events = [] 85 | 86 | for e, cb in self.events.items(): 87 | # Check for bounded functions 88 | if cb is None: 89 | continue 90 | if hasattr(cb, '__self__'): 91 | cb_func = cb.__func__ 92 | cb_self = cb.__self__ 93 | else: 94 | cb_func = cb 95 | cb_self = None 96 | 97 | # Set attributes like fbwCLICK, fbwKEYUP etc. 98 | # Remove 'on' from the event name 99 | attributes['fbw'+e[2:].upper()+'Callback'] = str(id(cb_func)) 100 | events.append(e[2:]) 101 | callbacks[str(id(cb_func))] = (cb_func, cb_self) 102 | 103 | if len(events) > 0: 104 | attributes['fbwEvents'] = ' '.join(events) 105 | 106 | return attributes, callbacks 107 | 108 | def get_attributes_and_props(self): 109 | attributes = {} 110 | properties = {} 111 | for k, val in self.attr.items(): 112 | # Special case 113 | if k == 'children': 114 | continue 115 | 116 | if k not in attributes_as_props: 117 | # Convert all attributes to strings 118 | attributes[k] = str(val) 119 | else: 120 | properties[k] = val 121 | 122 | return attributes, properties 123 | 124 | def __str__(self): 125 | """String representation of the tag.""" 126 | # TODO: Fix this to show full tag with all attributes 127 | return '<'+str(self.tag)+' />' 128 | 129 | def __repr__(self): 130 | """Shortened description of the tag.""" 131 | num_children = len(self.children) 132 | if num_children > 0: 133 | inner_txt = (' with '+str(num_children)+' child' 134 | +('ren' if num_children > 1 else '')) 135 | return '<'+str(self.tag)+inner_txt+'/>' 136 | else: 137 | return '<'+str(self.tag)+' />' 138 | 139 | 140 | def h(tag_name, children=None, **attr_and_events): 141 | """Helper function for building DOM trees.""" 142 | 143 | if children is None: 144 | # If attr is a DomNode, a string or a list/tuple 145 | # assume no attributes are given 146 | children = [] 147 | 148 | if not isinstance(children, list): 149 | children = [children] 150 | 151 | attr_and_events['children'] = children 152 | 153 | if callable(tag_name): 154 | return tag_name(**attr_and_events) 155 | 156 | # Separate events from attributes 157 | attributes = {} 158 | events = {} 159 | for k, val in attr_and_events.items(): 160 | if k.lower() in dom_events: 161 | events[k.lower()] = val 162 | else: 163 | attributes[k] = val 164 | 165 | return DomNode(tag_name, attributes, events) 166 | -------------------------------------------------------------------------------- /flybywire/misc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from functools import wraps 3 | 4 | def set_interval(fn, interval, args=()): 5 | """ 6 | Calls a function periodically (assuming there is already an asyncio event 7 | loop running) 8 | 9 | fn: Function to be called 10 | interval: Period in seconds 11 | args: Tuple of arguments to be passed in 12 | 13 | Returns an asyncio.Future object 14 | 15 | Ref: http://stackoverflow.com/a/37512537/538379 16 | """ 17 | @wraps(fn) 18 | @asyncio.coroutine 19 | def repeater(): 20 | while True: 21 | yield from asyncio.sleep(interval) 22 | fn(*args) 23 | 24 | loop = asyncio.get_event_loop() 25 | task = asyncio.Task(repeater()) 26 | return task 27 | 28 | def clear_interval(task): 29 | """ 30 | Stops a periodic function call setup using set_inteval() 31 | """ 32 | def stopper(): 33 | task.cancel() 34 | 35 | loop = asyncio.get_event_loop() 36 | loop.call_soon(stopper) 37 | -------------------------------------------------------------------------------- /flybywire/static/flybywire.js: -------------------------------------------------------------------------------- 1 | // Author: Thomas Antony 2 | var v = require('virtual-dom') 3 | var h = v.h 4 | var diff = v.diff 5 | var patch = v.patch 6 | var createElement = v.create 7 | 8 | var vdomjson = require('vdom-as-json'); 9 | var toJson = vdomjson.toJson; 10 | var fromJson = vdomjson.fromJson; 11 | 12 | var SOCKET_URL = "ws://127.0.0.1:9000" 13 | var socket 14 | 15 | function init() { 16 | rootDOM = null; // Represents current virtual DOM tree 17 | rootElement = null; // Represents current real DOM tree 18 | // rootEvents = {} // Stores a list of all bound DOM events by node ID 19 | 20 | function render_from_json(dom_json) { 21 | newTree = fromJson(JSON.parse(dom_json)); 22 | var patches = diff(rootDOM, newTree); 23 | rootElement = patch(rootElement, patches); 24 | rootDOM = newTree; 25 | bind_events(); 26 | } 27 | function initialize_dom(dom_json) { 28 | rootDOM = fromJson(JSON.parse(dom_json)); 29 | rootElement = createElement(rootDOM); // Create DOM node ... 30 | document.body.appendChild(rootElement); // add it to document 31 | bind_events(); 32 | } 33 | function bind_events(){ 34 | event_nodes = document.querySelectorAll('[fbwEvents]') 35 | event_nodes.forEach(function(el) { 36 | el.getAttribute('fbwEvents').split(' ').forEach(function(evt) { 37 | evtPrefix = 'fbw'+evt.toUpperCase(); 38 | if (!el[evtPrefix+'Bound']) 39 | { 40 | el[evtPrefix+'Listener'] = function(e){ 41 | cb = el.getAttribute(evtPrefix+'Callback'); 42 | if(cb) 43 | send_dom_event(cb, e); 44 | // e.preventDefault(); 45 | }; 46 | el.addEventListener(evt, el[evtPrefix+'Listener'], false); 47 | el[evtPrefix+'Bound'] = true; 48 | } 49 | }); 50 | }); 51 | 52 | } 53 | function send_dom_event(callback_id, evt_obj){ 54 | // console.log('Triggering callback id'+String(callback_id)); 55 | socket.send(JSON.stringify({ "event": "domevent", 56 | "callback": String(callback_id), 57 | "event_obj": getProperties(evt_obj), 58 | })) 59 | } 60 | socket = new WebSocket(SOCKET_URL) 61 | socket.onopen = function(event) { 62 | console.log("Connected to websocket server at " + SOCKET_URL) 63 | socket.send(JSON.stringify({ "event": "init" })) 64 | } 65 | 66 | socket.onmessage = function(event) { 67 | // console.log("Received: " + event.data) 68 | command = JSON.parse(event.data) 69 | if (command.name == "init") { 70 | initialize_dom(command.vdom) 71 | socket.send(JSON.stringify({ "event": "load" })) 72 | } else if (command.name == "render") { 73 | // Pull vdom data out of the event and render 74 | render_from_json(command.vdom) 75 | } 76 | } 77 | } 78 | 79 | function getAllPropertyNames(obj) { 80 | var props = []; 81 | 82 | do { 83 | props = props.concat(Object.getOwnPropertyNames(obj)) 84 | } while (obj = Object.getPrototypeOf(obj)) 85 | 86 | return props 87 | } 88 | 89 | function getProperties(obj) { 90 | newObj = {} 91 | props = getAllPropertyNames(obj) 92 | // console.log(obj) 93 | props.forEach(function(p) { 94 | propType = typeof obj[p] 95 | if (propType == "object") { 96 | if (obj[p] instanceof HTMLElement) { 97 | newObj[p] = { } 98 | newObj[p]['innerText'] = obj[p].innerText 99 | newObj[p]['outterText'] = obj[p].outterText 100 | newObj[p]['innerHTML'] = obj[p].innerHTML 101 | newObj[p]['outterHTML'] = obj[p].outterHTML 102 | newObj[p]['textContent'] = obj[p].textContent 103 | newObj[p]['value'] = obj[p].value 104 | 105 | for (var i = 0; i < obj[p].attributes.length; i++) { 106 | newObj[p][obj[p].attributes[i].name] = obj[p].attributes[i].value 107 | } 108 | } 109 | } 110 | else if (propType != "function") { 111 | if (obj[p] != null) 112 | newObj[p] = obj[p].toString() 113 | else { 114 | newObj[p] = null 115 | } 116 | } 117 | }) 118 | 119 | return newObj 120 | } 121 | 122 | window.onload = function(event) { 123 | init() 124 | } 125 | window.addEventListener("beforeunload", function (e) { 126 | socket.send(JSON.stringify({ "event": "close" })) 127 | return null; 128 | }); 129 | -------------------------------------------------------------------------------- /flybywire/static/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /flybywire/ui.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from .core import FBWApp 3 | 4 | def Application(cls): 5 | def create_fbw_app(*args, **kwargs): 6 | root = cls(*args, **kwargs) 7 | return FBWApp(root) 8 | 9 | #TODO: Figure out way to fix __name__ and __doc__ in resulting instance 10 | return create_fbw_app 11 | 12 | class Component(object): 13 | """Class defining a UI component.""" 14 | __metaclass__ = abc.ABCMeta 15 | def __init__(self): 16 | self._state = None 17 | self._observers = [] 18 | 19 | @abc.abstractmethod 20 | def render(): 21 | """Applications must implement this method.""" 22 | raise NotImplementedError() 23 | 24 | @property 25 | def state(self): 26 | return self._state 27 | 28 | @state.setter 29 | def state(self, _): 30 | raise RuntimeError('Use the set_state() or set_initial_state() '+\ 31 | 'to modify the state') 32 | 33 | def set_initial_state(self, state): 34 | """Sets the application state without triggering a redraw.""" 35 | # Helps initialize state before client is connected 36 | self._state = state 37 | 38 | def set_state(self, new_state): 39 | """Set new state and trigger redraw.""" 40 | # Merge into dictionary if state is a dictionary (similar to React) 41 | if isinstance(self.state, dict) and isinstance(new_state, dict): 42 | self._state.update(new_state) 43 | else: 44 | self._state = new_state 45 | 46 | self.notify_observers() 47 | 48 | def add_observer(self, observer): 49 | """ 50 | Sets up observer who is triggered whenever the state is changed 51 | """ 52 | self._observers.append(observer) 53 | 54 | def notify_observers(self): 55 | """ 56 | Notifies observers that the component state has changed 57 | """ 58 | for obs in self._observers: 59 | obs() 60 | 61 | def remove_observer(self, callback): 62 | """ 63 | Removes an observer 64 | """ 65 | self._observers.remove(observer) 66 | 67 | def on_load(self): 68 | """ 69 | Load event handler 70 | """ 71 | pass 72 | 73 | def on_close(self): 74 | """ 75 | Close event handler 76 | """ 77 | pass 78 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = flybywire 3 | author = Thomas Antony 4 | author-email = tantony.purdue@gmail.com 5 | summary = A declarative library for building virtual-DOM user interfaces in pure Python. 6 | description-file = README.md 7 | home-page = https://github.com/thomasantony/flybywire 8 | 9 | license = MIT 10 | keywords = 11 | react 12 | web 13 | framework 14 | javascript 15 | asynchronous 16 | gui 17 | declarative 18 | websockets 19 | 20 | 21 | classifiers = 22 | Development Status :: 2 - Pre-Alpha 23 | Topic :: Software Development :: Libraries 24 | Intended Audience :: Developers 25 | Programming Language :: Python 26 | Programming Language :: Python :: 2.7 27 | Programming Language :: Python :: 3.4 28 | Programming Language :: Python :: 3.5 29 | License :: OSI Approved :: MIT License 30 | 31 | [files] 32 | packages = flybywire 33 | 34 | extra_files = 35 | setup.py 36 | README.md 37 | flybywire/static/main.html 38 | flybywire/static/flybywire.js 39 | 40 | [bdist_wheel] 41 | universal = 1 42 | 43 | [aliases] 44 | test=pytest 45 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | setup( 5 | install_requires=['autobahn'], 6 | tests_require=['pytest'], 7 | setup_requires=['pbr>=1.9', 'setuptools>=17.1','pytest-runner'], 8 | pbr=True, 9 | ) 10 | 11 | # import os 12 | # from setuptools import setup, find_packages 13 | 14 | # import flybywire 15 | # # Utility function to read the README file. 16 | # # Used for the long_description. It's nice, because now 1) we have a top level 17 | # # README file and 2) it's easier to type in the README file than to put a raw 18 | # # string in below ... 19 | # def read(fname): 20 | # return open(os.path.join(os.path.dirname(__file__), fname)).read() 21 | # 22 | # setup( 23 | # name = "flybywire", 24 | # version = flybywire.__version__, 25 | # author = "Thomas Antony", 26 | # author_email = "tantony.purdue@gmail.com", 27 | # 28 | # description = "A library for building virtual-DOM user interfaces in pure Python.", 29 | # long_description=read('README.md'), 30 | # 31 | # license = "MIT", 32 | # keywords = ['react', 'web framework', 'javascript', 33 | # 'asynchronous', 'gui', 'websockets'], 34 | # 35 | # url = "https://github.com/thomasantony/flybywire", 36 | # packages = find_packages(), 37 | # package_data = { 38 | # 'flybywire': ['static/main.html', 'static/flybywire.js'] 39 | # }, 40 | # 41 | # install_requires=['autobahn'], 42 | # setup_requires=['pbr>=1.9','setuptools>=17.1','pytest-runner'], 43 | # tests_require=['pytest'], 44 | # pbr=True, 45 | # classifiers=[ 46 | # "Development Status :: 2 - Pre-Alpha", 47 | # "Topic :: Software Development :: Libraries", 48 | # "Intended Audience :: Developers", 49 | # "Programming Language :: Python", 50 | # "Programming Language :: Python :: 2.7", 51 | # "Programming Language :: Python :: 3.4", 52 | # "Programming Language :: Python :: 3.5", 53 | # "License :: OSI Approved :: MIT License", 54 | # ], 55 | # 56 | # ) 57 | -------------------------------------------------------------------------------- /test/test_dom.py: -------------------------------------------------------------------------------- 1 | from flybywire.dom import h 2 | def test_dom(): 3 | count = 0 4 | node1 = h('div', str(count), 5 | style = { 6 | 'textAlign': 'center', 7 | 'lineHeight': str(100 + count) + 'px', 8 | 'border': '1px solid red', 9 | 'width': str(100 + count) + 'px', 10 | 'height': str(100 + count) + 'px' 11 | }) 12 | 13 | node1_dict = {'dom': {'c': [{'t': 1, 'x': '0'}], 14 | 'tn': 'DIV', 't': 3, 15 | 'p': {'style': { 16 | 'width': '100px', 17 | 'height': '100px', 18 | 'border': '1px solid red', 19 | 'lineHeight': '100px', 20 | 'textAlign': 'center' 21 | }} 22 | }, 23 | 'callbacks': {}} 24 | 25 | node2 = h('div', h('span','foobar')) 26 | node2_dict = {'dom': {'t': 3, 'tn': 'DIV', 27 | 'c': [{'t': 3, 'tn': 'SPAN', 28 | 'c': [{'x': 'foobar', 't': 1}] 29 | }] 30 | }, 31 | 'callbacks': {}} 32 | 33 | node3 = h('ul', [h('li',str(i),key=i) for i in range(5)]) 34 | node3_dict = {'dom': {'t': 3, 'tn': 'UL', 'c': [ 35 | {'t': 3, 'tn': 'LI', 'c': [{'x': '0', 't': 1}], 'k': 0}, 36 | {'t': 3, 'tn': 'LI', 'c': [{'x': '1', 't': 1}], 'k': 1}, 37 | {'t': 3, 'tn': 'LI', 'c': [{'x': '2', 't': 1}], 'k': 2}, 38 | {'t': 3, 'tn': 'LI', 'c': [{'x': '3', 't': 1}], 'k': 3}, 39 | {'t': 3, 'tn': 'LI', 'c': [{'x': '4', 't': 1}], 'k': 4}]}, 40 | 'callbacks': {}} 41 | 42 | assert node1.to_dict() == node1_dict 43 | assert node2.to_dict() == node2_dict 44 | assert node3.to_dict() == node3_dict 45 | 46 | def test_callback(): 47 | def click_callback(): 48 | pass 49 | callback_dict = {'dom': {'t': 3, 50 | 'p': { 51 | 'attributes': { 52 | 'fbwCLICKCallback': str(id(click_callback)), 53 | 'fbwEvents': 'click', 54 | }}, 'tn': 'BUTTON'}, 55 | 'callbacks': {str(id(click_callback)): (click_callback, None)}} 56 | 57 | callback_test = h('button', onclick=click_callback) 58 | assert callback_test.to_dict() == callback_dict 59 | 60 | def test_composed_dom(): 61 | def Counter(count): 62 | return h('h1', str(count)) 63 | 64 | composed_dom = h('div',[Counter(count=10), h('button','FooBar')]) 65 | composed_dict = {'dom': {'tn': 'DIV', 't': 3, 'c': 66 | [{'c': [{'t': 1, 'x': '10'}], 't': 3, 'tn': 'H1'}, 67 | {'c': [{'t': 1, 'x': 'FooBar'}], 't': 3, 'tn': 'BUTTON'} 68 | ]}, 'callbacks': {}} 69 | assert composed_dom.to_dict() == composed_dict 70 | --------------------------------------------------------------------------------