├── .gitignore ├── .gitmodules ├── Makefile ├── README.rst ├── deframed ├── __init__.py ├── default.py ├── remi │ ├── __init__.py │ ├── gui.py │ └── server.py ├── server.py ├── static │ ├── main.js │ └── site.css ├── templates │ └── layout.mustache └── worker.py ├── example ├── hello.py ├── minefield_app.py ├── minefield_app.py.orig ├── minefield_embed.py └── minefield_res │ ├── doubt.png │ ├── flag.png │ └── mine.png └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | /deframed.egg-info/ 3 | /deframed/static/ext/ 4 | /dist/ 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "deframed/util"] 2 | path = deframed/util 3 | url = https://github.com/smurfix/util-py.git 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | .PHONY: doc test update all tag pypi upload 4 | 5 | all: dirs\ 6 | deframed/static/ext/msgpack.min.js \ 7 | deframed/static/ext/mustache.min.js \ 8 | deframed/static/ext/jquery.min.js \ 9 | deframed/static/ext/poppler.min.js \ 10 | deframed/static/ext/bootstrap.min.js \ 11 | deframed/static/ext/bootstrap.min.css 12 | 13 | dirs: deframed/static/ext 14 | deframed/static/ext: 15 | mkdir $@ 16 | deframed/static/ext/msgpack.min.js: 17 | wget -O $@ "https://github.com/ygoe/msgpack.js/raw/master/msgpack.min.js" 18 | 19 | deframed/static/ext/mustache.min.js: 20 | wget -O $@ "https://github.com/janl/mustache.js/raw/master/mustache.min.js" 21 | 22 | deframed/static/ext/jquery.min.js: 23 | wget -O $@ "https://code.jquery.com/jquery-3.4.1.slim.min.js" 24 | 25 | deframed/static/ext/poppler.min.js: 26 | wget -O $@ "https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" 27 | 28 | deframed/static/ext/bootstrap.min.js: 29 | wget -O $@ "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" 30 | 31 | 32 | #deframed/static/ext/cash.min.js: 33 | # wget -O $@ "https://github.com/fabiospampinato/cash/raw/master/dist/cash.min.js" 34 | 35 | deframed/static/ext/bootstrap.min.css: 36 | wget -O $@ "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" 37 | 38 | # need to use python3 sphinx-build 39 | PATH := /usr/share/sphinx/scripts/python3:${PATH} 40 | 41 | PACKAGE = calltest 42 | PYTHON ?= python3 43 | export PYTHONPATH=$(shell pwd) 44 | 45 | PYTEST ?= ${PYTHON} $(shell which pytest-3) 46 | TEST_OPTIONS ?= -xvvv --full-trace 47 | PYLINT_RC ?= .pylintrc 48 | 49 | BUILD_DIR ?= build 50 | INPUT_DIR ?= docs/source 51 | 52 | # Sphinx options (are passed to build_docs, which passes them to sphinx-build) 53 | # -W : turn warning into errors 54 | # -a : write all files 55 | # -b html : use html builder 56 | # -i [pat] : ignore pattern 57 | 58 | SPHINXOPTS ?= -a -W -b html 59 | AUTOSPHINXOPTS := -i *~ -i *.sw* -i Makefile* 60 | 61 | SPHINXBUILDDIR ?= $(BUILD_DIR)/sphinx/html 62 | ALLSPHINXOPTS ?= -d $(BUILD_DIR)/sphinx/doctrees $(SPHINXOPTS) docs 63 | 64 | doc: 65 | sphinx3-build -a $(INPUT_DIR) $(BUILD_DIR) 66 | 67 | livehtml: docs 68 | sphinx-autobuild $(AUTOSPHINXOPTS) $(ALLSPHINXOPTS) $(SPHINXBUILDDIR) 69 | 70 | test: 71 | $(PYTEST) $(PACKAGE) $(TEST_OPTIONS) 72 | 73 | 74 | tagged: 75 | git describe --tags --exact-match 76 | test $$(git ls-files -m | wc -l) = 0 77 | 78 | pypi: tagged 79 | python3 setup.py sdist upload 80 | 81 | upload: pypi 82 | git push-all --tags 83 | 84 | update: 85 | pip install -r ci/test-requirements.txt 86 | 87 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | DeFramed 3 | ======== 4 | 5 | What the … 6 | ++++++++++ 7 | 8 | Deframed is a non-framework for web programming. In fact it is the very 9 | antithesis of a web framework. 10 | 11 | Huh? 12 | ---- 13 | 14 | The basic idea of building web pages, these days, is to delegate as much as 15 | possible to the client. The problem is that if you don't want to do that, 16 | but still like to offer a single-page site to your user, you're on your 17 | own. 18 | 19 | Why? 20 | ---- 21 | 22 | Well, maybe Javascript is a truly annoying language. To you, anyway. Maybe 23 | your site logic is Secret Sauce and shouldn't end up in the browser. Maybe 24 | your API shouldn't be exposed to the outside world. Maybe you want to tell 25 | the browser what to display. Maybe you just want to build a Web UI that 26 | behaves like any other UI, i.e. read events from the user and tell the 27 | screen what to display, period end of story. 28 | 29 | Whatever your reason, DeFramed's purpose is to make sure that you won't 30 | have to deal with programming on the browser side. No more than absolutely 31 | necessary, anyway. 32 | 33 | Principle of operation 34 | ++++++++++++++++++++++ 35 | 36 | Client 37 | ------ 38 | 39 | DeFramed displays a generic initial page and starts a small Javascript 40 | handler that connects to a web socket on your server. It then proxies a 41 | handful of DOM manipulation functions and exports a few calls which your 42 | user-facing interface elements can use to send events or data to the 43 | server. 44 | 45 | There's also basic support for a client-side spinner, a simple way to show 46 | alerts if/when the connection breaks, templating (with Mustache) so you 47 | don't need to send redundant data, and rudimentary access to local data to 48 | store the equivalent of a cookie and to stash templates on the client. Oh 49 | yes, and some rudimentary DOM manipulation, like adding a class to some 50 | element. 51 | 52 | DeFramed also auto-adds "onclick" handlers to each button and "onsubmit"s 53 | to each form (assuming they have an ID and no existing handler), so you 54 | don't have to. 55 | 56 | Note the absence of anything that could be interpreted as client-side 57 | logic, which is why DeFramed is a non-framework. 58 | 59 | Server 60 | ------ 61 | 62 | If there is zero client-side logic, the server needs to handle everything. 63 | (Which it has to do anyway.) Thus, DeFramed includes classes to support all 64 | of this. 65 | 66 | The DeFramed server is based on Quart-Trio, thus it natively supports async 67 | operations. It uses Trio instead of asyncio: cleanly shutting down a 68 | complex asyncio application is a debugging exercise nobody should undergo. 69 | You can ignore the async stuff, but as soon as you call out to a database 70 | you probably don't want to. 71 | 72 | Each client's events are processed sequentially, though it's easy to run a 73 | background task – which is guaranteed to get terminated when the client 74 | disconnects or times out. 75 | 76 | -------------------------------------------------------------------------------- /deframed/__init__.py: -------------------------------------------------------------------------------- 1 | from .server import App 2 | from .worker import Worker 3 | 4 | from importlib.metadata import version as _version, PackageNotFoundError 5 | try: 6 | __version__ = _version('deframed') 7 | except PackageNotFoundError: 8 | import subprocess 9 | import io 10 | c = subprocess.run("git describe --tags".split(" "), capture_output=True) 11 | __version__ = c.stdout.decode("utf-8").strip() 12 | 13 | 14 | -------------------------------------------------------------------------------- /deframed/default.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains the default values for configuring DeFramed. 3 | """ 4 | 5 | from .util import attrdict 6 | 7 | __all__ = ["CFG"] 8 | 9 | CFG = attrdict( 10 | logging=attrdict( # a magic incantation 11 | version=1, 12 | loggers=attrdict( 13 | #"asyncari": {"level":"INFO"}, 14 | ), 15 | root=attrdict( 16 | handlers= ["stderr",], 17 | level="INFO", 18 | ), 19 | handlers=attrdict( 20 | logfile={ 21 | "class":"logging.FileHandler", 22 | "filename":"/var/log/deframed.log", 23 | "level":"INFO", 24 | "formatter":"std", 25 | }, 26 | stderr={ 27 | "class":"logging.StreamHandler", 28 | "level":"INFO", 29 | "formatter":"std", 30 | "stream":"ext://sys.stderr", 31 | }, 32 | ), 33 | formatters=attrdict( 34 | std={ 35 | "class":"deframed.util.TimeOnlyFormatter", 36 | "format":'%(asctime)s %(levelname)s:%(name)s:%(message)s', 37 | }, 38 | ), 39 | disable_existing_loggers=False, 40 | ), 41 | server=attrdict( # used to setup the hypercorn toy server 42 | host="127.0.0.1", 43 | port=8080, 44 | prio=0, 45 | name="test me", 46 | use_reloader=False, 47 | ca_certs=None, 48 | certfile=None, 49 | keyfile=None, 50 | ), 51 | mainpage="templates/layout.mustache", 52 | debug=False, 53 | data=attrdict( # passed to main template 54 | title="Test page. Do not test!", 55 | loc=attrdict( 56 | #msgpack="https://github.com/ygoe/msgpack.js/raw/master/msgpack.min.js", 57 | #mustache="https://github.com/janl/mustache.js/raw/master/mustache.min.js", 58 | msgpack="https://unpkg.com/@msgpack/msgpack", 59 | mustache="/static/ext/mustache.min.js", 60 | bootstrap_css="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css", 61 | bootstrap_js="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js", 62 | poppler="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js", 63 | jquery="https://code.jquery.com/jquery-3.4.1.slim.min.js", 64 | ), 65 | static="static", # path 66 | ), 67 | ) 68 | -------------------------------------------------------------------------------- /deframed/remi/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Worker mix-in class that supports Remi. 3 | """ 4 | 5 | import os 6 | import trio 7 | import weakref 8 | 9 | from . import gui as remi 10 | from .server import runtimeInstances 11 | from ..worker import SubWorker 12 | 13 | import deframed 14 | 15 | import logging 16 | logger = logging.getLogger(__name__) 17 | 18 | class _Remi: 19 | """ 20 | A mix-in with various default handlers. 21 | 22 | A class using this mix-in needs to have valid 'gui' and 'worker' attributes. 23 | """ 24 | 25 | _update_new = None 26 | 27 | def __init__(self,*a,**k): 28 | self._update_evt = trio.Event() 29 | super().__init__(*a,**k) 30 | 31 | def _need_update(self): 32 | """Callback for updating the client""" 33 | self._update_evt.set() 34 | 35 | def onload(self, emitter): 36 | """ WebPage Event that occurs on webpage loaded 37 | """ 38 | logger.debug('App.onload event occurred') 39 | 40 | def onerror(self, emitter, message, source, lineno, colno): 41 | """ WebPage Event that occurs on webpage errors 42 | """ 43 | logger.debug("""App.onerror event occurred in webpage: 44 | \nMESSAGE:%s\nSOURCE:%s\nLINENO:%s\nCOLNO:%s\n"""%(message, source, lineno, colno)) 45 | 46 | def ononline(self, emitter): 47 | """ WebPage Event that occurs on webpage goes online after a disconnection 48 | """ 49 | logger.debug('App.ononline event occurred') 50 | 51 | def onpagehide(self, emitter): 52 | """ WebPage Event that occurs on webpage when the user navigates away 53 | """ 54 | logger.debug('App.onpagehide event occurred') 55 | 56 | def onpageshow(self, emitter, width, height): 57 | """ WebPage Event that occurs on webpage gets shown 58 | """ 59 | logger.debug('App.onpageshow event occurred') 60 | 61 | def onresize(self, emitter, width, height): 62 | """ WebPage Event that occurs on webpage gets resized 63 | """ 64 | logger.debug('App.onresize event occurred. Width:%s Height:%s'%(width, height)) 65 | 66 | def set_root_widget(self,w): 67 | self._update_new = w 68 | self._update_evt.set() 69 | 70 | async def update_loop(self): 71 | while True: 72 | await self._update_evt.wait() 73 | self._update_evt = trio.Event() 74 | logger.debug("Update") 75 | if self._update_new is None: 76 | changed = {} 77 | self.gui.repr(changed) 78 | for widget,html in changed.items(): 79 | logger.debug("Updating %s",widget) 80 | __id = str(widget.identifier) 81 | await self.worker.set_element(str(widget.identifier),html) 82 | else: 83 | self.gui = self._update_new 84 | await self.worker.set_content(self.gui_id, self.gui.repr({})) 85 | self._update_new = None 86 | 87 | 88 | async def talk(self): 89 | pass 90 | 91 | 92 | class RemiSupport: 93 | """ 94 | A mix-in which forwards Remi events to its handler. 95 | 96 | Use this in your app. 97 | """ 98 | 99 | async def msg_remi_event(self, data): 100 | widget_id, function_name, params = data 101 | callback = getattr(runtimeInstances[widget_id], function_name, None) 102 | if not params: 103 | params = {} 104 | if callback is not None: 105 | callback(**params) 106 | else: 107 | logger.debug("Unknown callback: %s %s %r", *data) 108 | 109 | 110 | class RemiHandler(_Remi): 111 | """ 112 | The class controlling a Remi GUI instance when attached to a standard 113 | element. 114 | 115 | Args: 116 | worker: 117 | The worker to attach to. 118 | gui: 119 | The Remi GUI to display. 120 | """ 121 | 122 | def __init__(self, worker): 123 | super().__init__() 124 | self.worker = worker 125 | self.gui = self.main() 126 | worker._remi = weakref.ref(self) 127 | 128 | @property 129 | def root(self): 130 | return self.gui 131 | 132 | async def show(self, id:str="df_main"): 133 | """ 134 | Show this Remi GUI (the object you'd usually return from ``main`` 135 | in your Remi ``App`` subclass) in this element (it should be a 136 | DIV). 137 | 138 | Call "hide_embed" when you're done. 139 | """ 140 | self._task = await self.worker.spawn(self._show,id, persistent=False) 141 | 142 | async def _show(self,id): 143 | w = self.worker 144 | self.gui._parent = self 145 | self.gui_id = id 146 | 147 | await w.add_class(id, 'remi-main') 148 | await w.set_content(id, self.gui.repr({})) 149 | 150 | async with trio.open_nursery() as n: 151 | n.start_soon(self.update_loop) 152 | inf = await w.elem_info(id) 153 | self.onload(None) 154 | self.onpageshow(None, inf['width'],inf['height']) 155 | await self.talk() 156 | # TODO: hook up self.onresize() 157 | 158 | async def hide(self, id:str="df_main"): 159 | """ 160 | Stop showing this Remi GUI. 161 | 162 | This removes the content and the REMI class. 163 | """ 164 | w = self.worker 165 | self._task.cancel() 166 | await w.set_content(id, '') 167 | await w.remove_class(id, 'remi-main') 168 | 169 | 170 | class _RemiWorker(_Remi, SubWorker): 171 | """ 172 | The internal worker used for IFRAMEs. 173 | """ 174 | def __init__(self, worker, gui, name): 175 | self.gui = gui 176 | self._update_evt = trio.Event() 177 | self._update_loop = None 178 | 179 | _Remi.init(worker,gui) 180 | SubWorker.__init__(worker) 181 | 182 | head = remi.HEAD(name) 183 | # use the default css, but append a version based on its hash, to stop browser caching 184 | head.add_child('internal_css', "\n") 185 | 186 | body = remi.BODY() 187 | body.onload.connect(self.onload) 188 | body.onerror.connect(self.onerror) 189 | body.ononline.connect(self.ononline) 190 | body.onpagehide.connect(self.onpagehide) 191 | body.onpageshow.connect(self.onpageshow) 192 | body.onresize.connect(self.onresize) 193 | self.page = remi.HTML() 194 | self.page.add_child('head', head) 195 | self.page.add_child('body', body) 196 | 197 | js = js.replace("@UUID@", str(self.uuid)) 198 | js = js.replace("@MUUID@", str(self.master.uuid)) 199 | 200 | head.add_child("uuid_js", """ 201 | 205 | """.format(uuid=self.uuid, master=self.master.uuid)) 206 | head.add_child('internal_js', " 76 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /deframed/worker.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module specifies the worker that's responsible for handling 3 | communication with the client. 4 | """ 5 | 6 | from uuid import uuid1,UUID 7 | import trio 8 | from collections.abc import Mapping 9 | from typing import Optional,Dict,List,Union,Any 10 | from .util import packer, unpacker, Proxy 11 | from functools import partial 12 | from pprint import pformat 13 | 14 | from contextvars import ContextVar 15 | processing = ContextVar("processing", default=None) 16 | 17 | import logging 18 | logger = logging.getLogger(__name__) 19 | 20 | async def _spawn(task, *args, task_status=trio.TASK_STATUS_IGNORED): 21 | processing.set(None) 22 | 23 | with trio.CancelScope() as sc: 24 | task_status.started(sc) 25 | await task(*args) 26 | 27 | 28 | class UnknownActionError(RuntimeError): 29 | """ 30 | The client sent a message which I don't understand. 31 | """ 32 | pass 33 | 34 | 35 | class ClientError(RuntimeError): 36 | def __init__(self, _error, **kw): 37 | self.error = _error 38 | self.args = kw 39 | 40 | def __repr__(self): 41 | try: 42 | return 'ClientError(%r,%r)' % (self.error, self.args) 43 | except Exception as exc: 44 | return 'ClientError(??)' 45 | 46 | def __str__(self): 47 | try: 48 | return 'ClientError(%r %s)' % (self.error, " ".join(str(x) for x in self.args)) 49 | except Exception as exc: 50 | return 'ClientError(??)' 51 | 52 | 53 | 54 | _talk_id = 0 55 | 56 | class Talker: 57 | """ 58 | This class encapsulates the client's websocket connection. 59 | 60 | It is instantiated by the server. You probably should not touch it. 61 | """ 62 | w = None # Worker 63 | _scope = None 64 | 65 | def __init__(self, websocket): 66 | self.ws = websocket 67 | 68 | global _talk_id 69 | self._id = _talk_id 70 | _talk_id += 1 71 | 72 | async def run(self, *, task_status=trio.TASK_STATUS_IGNORED): 73 | """ 74 | Runs the read/write loop on this websocket. 75 | parallel to some client code. 76 | 77 | This sends the worker's ``fatal_msg`` attribute to the server 78 | in a ``fatal`` message if the message reader raises an exception. 79 | """ 80 | logger.debug("START %d", self._id) 81 | try: 82 | async with trio.open_nursery() as n: 83 | self._scope = n.cancel_scope 84 | await n.start(self.ws_in) 85 | await n.start(self.ws_out) 86 | task_status.started() 87 | finally: 88 | with trio.fail_after(2) as sc: 89 | sc.shield=True 90 | if self.w is not None: 91 | await self.w.maybe_disconnect(self) 92 | 93 | async def died(self, msg): 94 | """ 95 | Send a "the server has died" message to the client. 96 | 97 | Called from the worker when the connection terminates due to an 98 | uncaught error. 99 | """ 100 | logger.exception("Owch") 101 | with trio.move_on_after(2) as s: 102 | s.shield = True 103 | try: 104 | if self.w: 105 | await self._send(["info",dict(level="warning", text=msg)]) 106 | except Exception: 107 | logger.exception("Terminal message") 108 | pass 109 | 110 | def cancel(self): 111 | if self._scope is None: 112 | return 113 | self._scope.cancel() 114 | 115 | def attach(self, worker): 116 | """ 117 | Attach this websocket to an existing worker. Used when a client 118 | reconnects and presents an existing session ID. 119 | """ 120 | if isinstance(self.w, trio.Event): 121 | self.w.set() 122 | self.w = worker 123 | 124 | async def ws_in(self, *, task_status=trio.TASK_STATUS_IGNORED): 125 | """ 126 | Background task for reading from the web socket. 127 | """ 128 | task_status.started() 129 | if isinstance(self.w, trio.Event): 130 | await self.w.wait() 131 | while True: 132 | data = await self.ws.receive() 133 | try: 134 | data = unpacker(data) 135 | except TypeError: 136 | logger.error("IN X %r",data) 137 | raise 138 | logger.debug("IN %s",pformat(data)) 139 | await self.w.data_in(data) 140 | 141 | async def ws_out(self, *, task_status=trio.TASK_STATUS_IGNORED): 142 | """ 143 | Background task for sending to the web socket 144 | """ 145 | self._send_q, send_q = trio.open_memory_channel(10) 146 | task_status.started() 147 | if isinstance(self.w, trio.Event): 148 | await self.w.wait() 149 | while True: 150 | data = await send_q.receive() 151 | await self._send(data) 152 | 153 | async def _send(self, data): 154 | try: 155 | msg = packer(data) 156 | except TypeError: 157 | logger.exception("OUT F %s", pformat(data)) 158 | raise 159 | logger.debug("OUT %s", pformat(data)) 160 | await self.ws.send(msg) 161 | 162 | async def send(self, data:Any): 163 | """ 164 | Send a message to the client. 165 | """ 166 | await self._send_q.send(data) 167 | 168 | 169 | class BaseWorker: 170 | """ 171 | This is the base class for a client session. It might be interrupted 172 | (page reload, network glitch, or whatever). 173 | 174 | The talker's read loop calls :meth:`data_in` with the incoming message. 175 | """ 176 | _talker = None 177 | _scope = None 178 | _nursery = None 179 | 180 | title = "You forgot to set a title" 181 | fatal_msg = "The server had a fatal error.
It was logged and will be fixed soon." 182 | version = None # The server uses DeFramed's version if not set here 183 | 184 | def __init__(self, app): 185 | self._p_lock = trio.Lock() 186 | self._app = app 187 | self.uuid = uuid1() 188 | app.clients[self.uuid] = self 189 | 190 | @property 191 | def app(self): 192 | return self._app 193 | 194 | async def init(self): 195 | """ 196 | Setup code. Called by the server for async initialization. 197 | 198 | Note that you can't yet talk to the client here! 199 | """ 200 | pass 201 | 202 | def attach(self, talker): 203 | """ 204 | Use this socket to talk. Called by the app. 205 | 206 | The worker's previous websocket is cancelled. 207 | """ 208 | if self._talker is not None: 209 | self._talker.cancel() 210 | self._talker = talker 211 | talker.attach(self) 212 | 213 | async def _monitor(self, *, task_status=trio.TASK_STATUS_IGNORED): 214 | """ 215 | Background monitor. Don't override externally. 216 | """ 217 | task_status.started() 218 | pass 219 | 220 | async def run(self, websocket): 221 | """ 222 | Main entry point. Takes a websocket, sets up everything, then calls 223 | ".talk". 224 | 225 | You don't want to override this. Your main code should be in 226 | `talk`, your setup code (called by the server) in `init`. 227 | """ 228 | t = Talker(websocket) 229 | 230 | try: 231 | async with trio.open_nursery() as n: 232 | self._nursery = n 233 | await n.start(self._monitor) 234 | await n.start(t.run) 235 | self.attach(t) 236 | with trio.CancelScope() as sc: 237 | self._scope = sc 238 | try: 239 | await self._talk() 240 | finally: 241 | self._scope = None 242 | except Exception: 243 | await t.died(self.fatal_msg) 244 | raise 245 | # not on BaseException, as in that case we close the connection and 246 | # expect the client to try reconnecting 247 | else: 248 | await t.died(self.fatal_msg) 249 | 250 | async def _talk(self): 251 | await self.talk() 252 | 253 | async def talk(self): 254 | """ 255 | Connection-specific main code. The default does nothing. 256 | 257 | This task will get cancelled when the websocket terminates. 258 | """ 259 | pass 260 | 261 | def cancel(self): 262 | if self._scope is not None: 263 | self._scope.cancel() 264 | 265 | if self._scope is None: 266 | return 267 | self._scope.cancel() 268 | 269 | async def spawn(self, task, *args): 270 | """ 271 | Start a new task. Returns a cancel scope which you can use to stop 272 | the task. 273 | 274 | By default, errors get logged but don't propagate. Set ``log_exc=False`` 275 | if you don't want that. Or set "log_exc" to the exception, or list of 276 | exceptions, you want to have logged. 277 | """ 278 | return await self._nursery.start(_spawn,task,*args) 279 | 280 | async def maybe_disconnect(self, talker): 281 | """internal method, called by the Talker""" 282 | if self._talker is talker: 283 | logger.debug("DISCONNECT") 284 | await self.disconnect() 285 | 286 | async def disconnect(self): 287 | """ 288 | The client websocket disconnected. This method might not be called 289 | when a client reattaches. 290 | """ 291 | self.cancel() 292 | 293 | async def send(self, data:Any): 294 | """ 295 | Send a message to the client. 296 | """ 297 | await self._talker.send(data) 298 | 299 | async def data_in(self, data): 300 | """ 301 | Process incoming data 302 | """ 303 | pass 304 | 305 | 306 | class Worker(BaseWorker): 307 | """ 308 | This is the main class for a client session. 309 | 310 | The talker's read loop calls ``.msg_{action}`` methods with the 311 | incoming message. Code in those methods must not call ``.request`` 312 | because that would cause a deadlock. (Don't worry, DeFramed catches 313 | those.) Start a separate task with ``.spawn`` if you need to do this. 314 | """ 315 | _persistent_nursery = None 316 | _kill_exc = None 317 | _kill_flag = None 318 | 319 | def __init__(self,*a,**k): 320 | super().__init__(*a,**k) 321 | self._n = 1 322 | self._req = {} 323 | self.main_showing = trio.Event() 324 | 325 | async def data_in(self, data): 326 | """ 327 | Process incoming data. Must be structured [type,data]. 328 | 329 | Replies are special:["reply",[assoc_nr,data]]. 330 | """ 331 | action,data = data 332 | if action == "reply": 333 | await self._reply(*data) 334 | return 335 | try: 336 | res = getattr(self, 'msg_'+action) 337 | except AttributeError: 338 | res = partial(self.any_msg,action) 339 | tk = processing.set((action,data)) 340 | try: 341 | await res(data) 342 | finally: 343 | processing.reset(tk) 344 | 345 | async def _reply(self, n, data): 346 | if isinstance(data,Mapping) and '_error' in data: 347 | data = ClientError(**data) 348 | try: 349 | evt,self._req[n] = self._req[n],data 350 | except KeyError: 351 | return # stale or whatever 352 | evt.set() 353 | 354 | def cancel(self, persistent=False): 355 | if persistent and self._persistent_nursery is not None: 356 | self._persistent_nursery.cancel_scope.cancel() 357 | super().cancel() 358 | 359 | async def spawn(self, task, *args, persistent=True): 360 | """ 361 | Start a new task. Returns a cancel scope which you can use to stop 362 | the task. 363 | 364 | By default, the task persists even if the client websocket 365 | reconnects. Set ``persistent=False`` if you don't want that. 366 | 367 | By default, errors get logged but don't propagate. Set ``log_exc=False`` 368 | if you don't want that. Or set "log_exc" to the exception, or list of 369 | exceptions, you want to have logged. 370 | """ 371 | if persistent: 372 | return await self._run(_spawn,task,*args) 373 | else: 374 | return await self._nursery.start(_spawn,task,*args) 375 | 376 | async def _talk(self): 377 | # wait for show_main before talking 378 | await self.main_showing.wait() 379 | await super()._talk() 380 | 381 | async def get_attr(self, **a: Dict[str,List[str]]) -> Dict[str,Optional[List[str]]]: 382 | """ 383 | Returns a list of attributes for some element. 384 | The element is identified by its ID. 385 | 386 | Returns: a dict with corresponding values, or None if the element 387 | does not exist. 388 | """ 389 | 390 | async def exists(self, id) -> bool: 391 | """ 392 | Check whether the DOM element with this ID exists. 393 | """ 394 | return (await self.get_attr({id:{}}))[id] is not None 395 | 396 | async def elem_info(self, id) -> dict: 397 | """ 398 | Return information about this element (size, position) 399 | """ 400 | return await self.request("elem_info", id) 401 | 402 | async def msg_setup(self, data) -> bool: 403 | """ 404 | Called when the client is connected and says Hello. 405 | 406 | This calls ``.show_main``, thus you should override that instead. 407 | 408 | Returns True when the client either has a version mismatch (and is 409 | reloaded) or sends a known UUID (and is reassigned). 410 | """ 411 | v = data.get('version') 412 | if v is not None and v != self._app.version: 413 | await self.send('reload',True) 414 | return True 415 | 416 | await self._setup(); 417 | 418 | uuid = data.get('uuid') 419 | if uuid is None or not self.set_uuid(uuid): 420 | await self.show_main(token=data.get('token', None)) 421 | else: 422 | return True 423 | 424 | async def msg_form(self, data): 425 | """ 426 | Process form submissions. 427 | 428 | The default calls ``.form_{name}(**data)`` or ``.any_form(name,data)``. 429 | """ 430 | name, data = data 431 | try: 432 | p = getattr(self,"form_"+name) 433 | except AttributeError: 434 | await self.any_form(name, data) 435 | else: 436 | await p(**data) 437 | 438 | async def msg_button(self, name): 439 | """ 440 | Process button presses. 441 | 442 | The default calls ``.button_{name}()`` or ``.any_button(name)``. 443 | """ 444 | try: 445 | p = getattr(self,"button_"+name) 446 | except AttributeError: 447 | await self.any_button(name) 448 | else: 449 | await p() 450 | 451 | async def any_button(self, name): 452 | """ 453 | Handle unknown buttons. 454 | 455 | If you don't override it, this method raises an `UnknownActionError`. 456 | """ 457 | raise UnknownActionError(name) 458 | 459 | async def any_form(self, name, data): 460 | """ 461 | Handle unknown forms. 462 | 463 | If you don't override it, this method raises an `UnknownActionError`. 464 | """ 465 | raise UnknownActionError(name, data) 466 | 467 | async def _setup(self): 468 | """ 469 | Send initial data to the client. 470 | 471 | Called by `msg_setup`. 472 | You probably should not override this. 473 | """ 474 | await self.send("setup", version=self._app.version, uuid=str(self.uuid)) 475 | 476 | async def alert(self, level, text, **kw): 477 | """ 478 | Send a pop-up message to the client. 479 | 480 | Levels correspond to Bootstrap contexts: 481 | primary/secondary/success/danger/warning/info/light/dark. 482 | 483 | The ID of the message is "df_ann_{id}". ``id`` defaults to the 484 | message type, but you can supply your own. 485 | 486 | A message with any given ID replaces the previous one. 487 | Messages without text are deleted. 488 | 489 | Text may contain HTML. 490 | 491 | Pass ``timeout`` in seconds to make the message go away by itself. 492 | Messages can also be closed by the user unless you pass a negative 493 | timeout. 494 | """ 495 | await self.send("info", level=level, text=text, **kw) 496 | 497 | async def modal_show(self, id: str = "df_modal", **opts): 498 | """ 499 | Show a modal window. 500 | 501 | If the ID is not given, use the built-in ``df_modal``. 502 | 503 | Any further parameters are as described in 504 | https://getbootstrap.com/docs/4.4/components/modal/. 505 | """ 506 | if not opts: 507 | opts = True 508 | 509 | await self.send("modal",[id,opts]) 510 | 511 | async def modal_hide(self, id: str = "df_modal"): 512 | """ 513 | Hide a modal window. 514 | 515 | If the ID is not given, use the built-in ``df_modal``. 516 | """ 517 | await self.send("modal",[id,False]) 518 | 519 | 520 | async def set_content(self, id: str, html: str, prepend: Optional[bool]=None): 521 | """ 522 | Set or extend an element's innerHTML content. 523 | 524 | If you set ``prepend`` to `True`, ``html`` will be inserted as 525 | the first child node(s) of the modified element. Set to `False`, 526 | it will be added at the end. 527 | 528 | Otherwise the old content will be replaced. 529 | 530 | Args: 531 | id: 532 | the modified HTML element's ID. 533 | html: 534 | the element's new HTML content. 535 | prepend: 536 | Flag whether to add the data in front (``True``), back (``False``) or 537 | instead of (``None``) the existing content. The default is 538 | ``None``. 539 | """ 540 | await self.send("set", [id, html, prepend]); 541 | 542 | async def set_element(self, id: str, html: str): 543 | """ 544 | Replace an element. 545 | 546 | Args: 547 | id: 548 | the old element's ID. 549 | html: 550 | the element's replacement. 551 | """ 552 | await self.send("elem", [id, html]); 553 | 554 | async def load_style(self, id, url): 555 | """ 556 | Load a stylesheet. 557 | 558 | The ID is used for disambiguation. 559 | """ 560 | await self.send("load_style", [id, url]) 561 | 562 | async def set_attr(self, id: str, **attrs): 563 | """ 564 | Set or extend an element's attribute(s). 565 | Args: 566 | id: 567 | the modified HTML element's ID. 568 | attr=value: 569 | the element's changed attributes. 570 | """ 571 | await self.send("set_attr", [id, attrs]); 572 | 573 | async def add_class(self, id: str, *cls): 574 | """ 575 | Add the given classes to an element. 576 | 577 | Args: 578 | id: 579 | the modified HTML element's ID. 580 | cls: 581 | the class to add. 582 | """ 583 | await self.send("add_class", [id, cls]); 584 | 585 | async def remove_class(self, id: str, *cls): 586 | """ 587 | Remove the given classes from an element. 588 | 589 | Args: 590 | id: 591 | the modified HTML element's ID. 592 | cls: 593 | the class(es) to remove. 594 | """ 595 | await self.send("remove_class", [id, cls]); 596 | 597 | async def busy(self, busy: bool): 598 | """ 599 | Show/hide the client's spinner. 600 | 601 | Args: 602 | busy: 603 | Flag whether to show the spinner. 604 | """ 605 | await self.send("busy", busy) 606 | 607 | async def debug(self, val: Union[bool,str]): 608 | """ 609 | Set/clear the client's debug flag, or log a message on its console. 610 | 611 | While the flag is set, all WebSocket messages are logged on the 612 | client's console. 613 | 614 | Args: 615 | val: 616 | either a `bool` to control the server's debug flag, or a `str` 617 | to log a message there. 618 | """ 619 | await self.send("debug", val) 620 | 621 | async def set_token(self, token): 622 | """ 623 | Send a token to the client. The token represents the client's current state; 624 | it will send it in its `setup` message if/when it reconnects. 625 | 626 | Args: 627 | token: 628 | Anything packable. 629 | Returns: 630 | the previous token, or `None`. 631 | 632 | Security announcement: For anything nontrivial, you should store the actual 633 | data on the server and use a random string as token. 634 | 635 | There is no `get_token`. This is intentional. 636 | 637 | """ 638 | return await self.request("token", token) 639 | 640 | async def ping(self, data: str) -> Optional[str]: 641 | """ 642 | Send a 'ping' to the client, which reacts by calling `msg_pong`. 643 | 644 | Args: 645 | data: 646 | Anything. 647 | 648 | This call does *not* wait for the client's reply or call to `msg_pong`. 649 | """ 650 | await self.send("ping", data) 651 | 652 | async def msg_pong(self, data: Any): 653 | """ 654 | Called by the client, sometime after you ping it. 655 | 656 | Args: 657 | data: 658 | the value you sent with :meth:`ping`. 659 | """ 660 | pass 661 | 662 | async def msg_ping(self, data: Any): 663 | """ 664 | May be called by the client to ensure the connecton is still up. 665 | 666 | Args: 667 | data: 668 | Whatever. 669 | """ 670 | await self.request("pong", data) 671 | 672 | async def msg_size(self, evt): 673 | """ 674 | The main window's size. 675 | 676 | This is informational. 677 | """ 678 | pass 679 | 680 | async def any_msg(self, action: str, data): 681 | """ 682 | Catch-all for unknown messages, i.e. the 'msg_{action}' handler 683 | does not exist. 684 | 685 | If you don't override it, this method raises an UnknownActionError. 686 | 687 | """ 688 | raise UnknownActionError(action,data) 689 | 690 | async def send(self, action:str, data:Any=None, **kw): 691 | """ 692 | Send a message to the client. 693 | 694 | This method does not wait for a reply. 695 | 696 | You should probably call one of the specific ``send_*` methods instead. 697 | """ 698 | if action == "req": 699 | raise RuntimeError("Use '.request' for that!") 700 | if kw: 701 | if data: 702 | data.update(kw) 703 | else: 704 | data = kw 705 | 706 | await super().send([action,data]) 707 | 708 | async def eval(self, obj:Proxy, attr:tuple=None, args:tuple=None, var:Union[str,Proxy]=None): 709 | """ 710 | Call a Javascript function on the client. Take an object, access a sequence 711 | of attributes, call the result with the supplied arguments. 712 | 713 | The object may be either a proxy or a global object, expressed as either 714 | a dottified path or a tuple. 715 | 716 | If @var is set, the result is stored on the client and a proxy object is returned. 717 | 718 | If the result is a promise, the reply is delayed until the promuise is resolved. 719 | 720 | Errors are re-raised as `ClientError`. 721 | """ 722 | if isinstance(obj,(tuple,list,str)): 723 | if isinstance(obj,str): 724 | obj = P(obj) 725 | attr = obj+(attr or ()) 726 | obj = Proxy("_") 727 | req = {"obj": obj} 728 | 729 | if args is not None: 730 | req["args"] = args 731 | if attr is not None: 732 | req["attr"] = attr 733 | if var is not None: 734 | if isinstance(var,Proxy): 735 | var = var.name 736 | req["var"] = var 737 | return await self.request("eval", **req) 738 | 739 | async def assign(self, obj:Proxy, path:tuple=(), value:Any=None): 740 | """ 741 | Set an attribute on the client:: 742 | 743 | await worker.assign(Proxy("any"), path=P("foo.bar"), value=[1,2,3]) 744 | 745 | effectively runs ``ANY.foo.bar=[1,2,3]`` on the browser. 746 | 747 | If no path is given, the proxy itself is changed to the given value. 748 | 749 | This call does not handle promises. 750 | """ 751 | if path: 752 | return await self.request("assign", obj=obj, dest=path[-1], val=value, 753 | **({"attr":path[:-1]} if len(path)>1 else {})) 754 | else: 755 | return await self.request("assign", var=obj, val=value) 756 | 757 | 758 | async def request(self, action:str, data:Any=None, var:str=None, **kw): 759 | """ 760 | Send a request to the client, await+return the reply. 761 | 762 | If @var is set, the result is stored on the client and a proxy object is returned. 763 | 764 | If the reply is a promise, the reply is delayed until the promuise is resolved. 765 | 766 | Errors are re-raised as `ClientError`. 767 | """ 768 | if processing.get(): 769 | raise RuntimeError("You cannot call this from within the receiver. Use a task.",processing.get()) 770 | 771 | self._n += 1 772 | n = self._n 773 | self._req[n] = evt = trio.Event() 774 | 775 | if kw: 776 | if data: 777 | data.update(kw) 778 | else: 779 | data = kw 780 | args = [action,n,data] 781 | if var is not None: 782 | if isinstance(var, Proxy): 783 | var = var.name 784 | args.append(var) 785 | try: 786 | await super().send(["req",args]) 787 | await evt.wait() 788 | except BaseException: 789 | self._req.pop(n) 790 | raise 791 | else: 792 | res = self._req.pop(n) 793 | if isinstance(res,Exception): 794 | raise res 795 | return res 796 | 797 | async def _monitor(self, *, task_status=trio.TASK_STATUS_IGNORED): 798 | self._kill_flag = trio.Event() 799 | task_status.started() 800 | await self._kill_flag.wait() 801 | raise RuntimeError("Global task died") from self._kill_exc 802 | 803 | async def _run(self, proc, *args, task_status=trio.TASK_STATUS_IGNORED): 804 | """ 805 | This is the starter for the worker's Websocket-independent nursery. 806 | 807 | If :meth:`spawn` is called with ``persistent`` set, this helper 808 | starts a background task off the server's nursery which holds it. 809 | 810 | Exceptions are propagated back to the worker. 811 | """ 812 | async def _work(proc, *args, task_status=trio.TASK_STATUS_IGNORED): 813 | try: 814 | async with trio.open_nursery() as n: 815 | self._persistent_nursery = n 816 | res = await n.start(proc, *args) 817 | task_status.started(res) 818 | except Exception as exc: 819 | self._kill_exc = exc 820 | if self._kill_flag is not None: 821 | self._kill_flag.set() 822 | finally: 823 | self._persistent_nursery = None 824 | 825 | if self._persistent_nursery is None: 826 | async with self._p_lock: 827 | return await self._app.main.start(_work,proc,*args) 828 | else: 829 | return await self._persistent_nursery.start(proc, *args) 830 | 831 | async def interrupted(self): 832 | """ 833 | Called when the client disconnects. 834 | 835 | This may or may not be called when a client reconnects. 836 | """ 837 | pass 838 | 839 | async def show_main(self, token:str=None): 840 | """ 841 | Override me to show the main window or whatever. 842 | 843 | 'token' is the value from the last 'pong'. It is None initially. 844 | You can use this to distinguish a client reload (empty main page) 845 | from a websocket reconnect (client state is the same as when you 846 | sent the last Ping, assuming that you didn't subsequently change 847 | anything). 848 | 849 | This method triggers starting your "talk" method, so you should 850 | call super() as soon as you have completed setup. 851 | """ 852 | self.main_showing.set() 853 | 854 | def set_uuid(self, uuid:UUID) -> bool: 855 | """ 856 | Set this worker's UUID. 857 | 858 | If there already is a worker with that UUID, the current worker 859 | (more specifically, its "connected" subtask) is cancelled. 860 | 861 | Use this to attach a websocket to a running worker after 862 | exchanging credentials. 863 | 864 | Returns True if the socket has been reassigned. 865 | """ 866 | if self.uuid == uuid: 867 | return False 868 | del self._app.clients[self.uuid] 869 | w = self._app.clients.get(uuid) 870 | if w is None: 871 | self._app.clients[self.uuid] = self 872 | self.uuid = uuid 873 | return False 874 | else: 875 | w.attach(self._talker) 876 | self.cancel(True) 877 | return True 878 | 879 | 880 | class SubWorker(BaseWorker): 881 | """ 882 | This is a worker that attaches to another via an iframe. 883 | """ 884 | def __init__(self, worker): 885 | self.master = worker 886 | super().__init__(worker._app) 887 | self.sub_id = self._app.attach_sub(self) 888 | 889 | async def index(self): 890 | """ 891 | Render the main page 892 | """ 893 | raise RuntimeError("You need to override sending the main page") 894 | 895 | -------------------------------------------------------------------------------- /example/hello.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import trio 4 | from deframed import App,Worker 5 | from deframed.default import CFG 6 | from wtforms import Form, BooleanField, StringField, validators 7 | from wtforms.widgets import TableWidget 8 | 9 | class TestForm(Form): 10 | username = StringField('Username', [validators.Length(min=4, max=25)]) 11 | email = StringField('Email Address', [validators.Length(min=6, max=35)]) 12 | accept_rules = BooleanField('I accept the site rules', [validators.InputRequired()]) 13 | 14 | import logging 15 | from logging.config import dictConfig as logging_config 16 | logger = logging.getLogger("hello") 17 | 18 | class Work(Worker): 19 | title="Hello!" 20 | # version="1.2.3" -- uses DeFramed's version if not set 21 | 22 | async def show_main(self, token): 23 | if token != "A2": 24 | await self.modal_hide() 25 | if token is None or token == "A0": 26 | await self.debug(True) 27 | await self.put_form(set_token=False) 28 | elif token == "A1": 29 | await self.put_button(set_token=False) 30 | elif token == "A2": 31 | await self.put_modal(set_token=False) 32 | elif token == "A3": 33 | await self.put_done(set_token=False) 34 | else: 35 | await self.alert("warning", "The token was "+repr(token), id="bad_token", timeout=10) 36 | await self.put_form() 37 | await self.alert("info", None) 38 | await self.busy(False) 39 | await super().show_main() 40 | 41 | async def put_form(self, set_token=True): 42 | await self.set_content("df_footer_left", "'Hello' demo example") 43 | await self.set_content("df_footer_right", "via DeFramed, the non-framework") 44 | await self.set_content("df_main", """ 45 |

46 | This is a toy test program which shows how to talk to your browser. 47 |

48 |

49 | It doesn't do much yet. That will change. 50 |

51 |

52 | First, here's a simple form. 53 |

54 |
55 |
56 |
57 | 58 |
59 |
60 | """) 61 | 62 | f=TestForm() 63 | f.id="plugh" 64 | fw=TableWidget() 65 | await self.set_content("plugh", fw(f)) 66 | 67 | await self.alert("info","Ready!", busy=False, timeout=2) 68 | if set_token: 69 | await self.set_token("A0") 70 | 71 | async def put_button(self,set_token=True): 72 | await self.set_content("df_main", "

Success. Next, here's a button.

") 73 | await self.set_token("A1") 74 | 75 | async def put_modal(self,set_token=True): 76 | await self.set_content("df_main", "

We're showing a modal so you don't see this.

") 77 | await self.set_content("df_modal_title", "Random Title") 78 | await self.set_content("df_modal_body", "

Random content

") 79 | await self.set_content("df_modal_footer", '') 80 | await self.modal_show(keyboard=False) 81 | if set_token: 82 | await self.set_token("A2") 83 | 84 | async def put_done(self,set_token=True): 85 | await self.set_content("df_main", "

Aww … you pressed the button!

") 86 | if set_token: 87 | await self.set_token("A3") 88 | 89 | 90 | async def form_form1(self, **kw): 91 | logger.debug("GOT %r",kw) 92 | await self.put_button() 93 | 94 | async def button_bt_m(self, **kw): 95 | await self.modal_hide() 96 | await self.put_done() 97 | 98 | async def button_butt1(self, **kw): 99 | logger.debug("GOT %r",kw) 100 | await self.put_modal() 101 | 102 | 103 | 104 | async def main(): 105 | del CFG.logging.handlers.logfile 106 | CFG.logging.handlers.stderr["level"]="DEBUG" 107 | CFG.logging.root["level"]="DEBUG" 108 | CFG.server.host="0.0.0.0" 109 | CFG.server.port=50080 110 | 111 | logging_config(CFG.logging) 112 | app=App(CFG,Work, debug=True) 113 | await app.run() 114 | trio.run(main) 115 | 116 | # See "deframed.default.CFG" for defaults and whatnot 117 | -------------------------------------------------------------------------------- /example/minefield_app.py: -------------------------------------------------------------------------------- 1 | """ 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import trio 16 | import os.path 17 | import random 18 | 19 | from quart.static import send_from_directory 20 | import deframed.remi.gui as gui 21 | from deframed.remi import RemiWorker 22 | from deframed import App,Worker 23 | from deframed.default import CFG 24 | 25 | import logging 26 | from logging.config import dictConfig as logging_config 27 | logger = logging.getLogger("minefield") 28 | 29 | class Cell(gui.TableItem): 30 | """ 31 | Represent a cell in the minefield map 32 | """ 33 | 34 | def __init__(self, width, height, x, y, game): 35 | super(Cell, self).__init__('') 36 | self.set_size(width, height) 37 | self.x = x 38 | self.y = y 39 | self.has_mine = False 40 | self.state = 0 # unknown - doubt - flag 41 | self.opened = False 42 | self.nearest_mine = 0 # number of mines adjacent with this cell 43 | self.game = game 44 | 45 | self.style['font-weight'] = 'bold' 46 | self.style['text-align'] = 'center' 47 | self.style['background-size'] = 'contain' 48 | if ((x + y) % 2) > 0: 49 | self.style['background-color'] = 'rgb(255,255,255)' 50 | else: 51 | self.style['background-color'] = 'rgb(245,245,240)' 52 | self.oncontextmenu.do(self.on_right_click, js_stop_propagation=True, js_prevent_default=True) 53 | self.onclick.do(self.check_mine) 54 | 55 | def on_right_click(self, widget): 56 | """ Here with right click the change of cell is changed """ 57 | if self.opened: 58 | return 59 | self.state = (self.state + 1) % 3 60 | self.set_icon() 61 | self.game.check_if_win() 62 | 63 | def check_mine(self, widget, notify_game=True): 64 | if self.state == 1: 65 | return 66 | if self.opened: 67 | return 68 | self.opened = True 69 | if self.has_mine and notify_game: 70 | self.game.explosion(self) 71 | self.set_icon() 72 | return 73 | if notify_game: 74 | self.game.no_mine(self) 75 | self.set_icon() 76 | 77 | def set_icon(self): 78 | self.style['background-image'] = "''" 79 | if self.opened: 80 | if self.has_mine: 81 | self.style['background-image'] = "url('/my_resources:mine.png')" 82 | else: 83 | if self.nearest_mine > 0: 84 | self.set_text(str(self.nearest_mine)) 85 | else: 86 | self.style['background-color'] = 'rgb(200,255,100)' 87 | return 88 | if self.state == 2: 89 | self.style['background-image'] = "url('/my_resources:doubt.png')" 90 | if self.state == 1: 91 | self.style['background-image'] = "url('/my_resources:flag.png')" 92 | 93 | def add_nearest_mine(self): 94 | self.nearest_mine += 1 95 | 96 | 97 | class Minefield(RemiWorker): 98 | title = "Minefield" 99 | 100 | _timer = None 101 | 102 | async def display_time(self): 103 | while True: 104 | await trio.sleep(1) 105 | self.lblTime.set_text('Play time: ' + str(self.time_count)) 106 | self.time_count += 1 107 | 108 | def main(self): 109 | # the arguments are width - height - layoutOrientationOrizontal 110 | self.main_container = gui.Container(margin='0px auto') 111 | self.main_container.set_size(1020, 600) 112 | self.main_container.set_layout_orientation(gui.Container.LAYOUT_VERTICAL) 113 | 114 | self.title = gui.Label('Mine Field GAME') 115 | self.title.set_size(1000, 30) 116 | self.title.style['margin'] = '10px' 117 | self.title.style['font-size'] = '25px' 118 | self.title.style['font-weight'] = 'bold' 119 | 120 | self.info = gui.Label('Minefield game. Enjoy.') 121 | self.info.set_size(400, 30) 122 | self.info.style['margin'] = '10px' 123 | self.info.style['font-size'] = '20px' 124 | 125 | self.lblMineCount = gui.Label('Mines') 126 | self.lblMineCount.set_size(100, 30) 127 | self.lblFlagCount = gui.Label('Flags') 128 | self.lblFlagCount.set_size(100, 30) 129 | 130 | self.time_count = 0 131 | self.lblTime = gui.Label('Time') 132 | self.lblTime.set_size(100, 30) 133 | 134 | self.btReset = gui.Button('Restart') 135 | self.btReset.set_size(100, 30) 136 | self.btReset.onclick.do(self.new_game) 137 | 138 | self.horizontal_container = gui.Container() 139 | self.horizontal_container.style['display'] = 'block' 140 | self.horizontal_container.style['overflow'] = 'auto' 141 | self.horizontal_container.set_layout_orientation(gui.Container.LAYOUT_HORIZONTAL) 142 | self.horizontal_container.style['margin'] = '10px' 143 | self.horizontal_container.append(self.info) 144 | imgMine = gui.Image('/my_resources:mine.png') 145 | imgMine.set_size(30, 30) 146 | self.horizontal_container.append([imgMine, self.lblMineCount]) 147 | imgFlag = gui.Image('/my_resources:flag.png') 148 | imgFlag.set_size(30, 30) 149 | self.horizontal_container.append([imgFlag, self.lblFlagCount, self.lblTime, self.btReset]) 150 | 151 | self.minecount = 0 # mine number in the map 152 | self.flagcount = 0 # flag placed by the players 153 | 154 | self.link = gui.Link("https://github.com/dddomodossola/remi", 155 | "This is an example of REMI gui library.") 156 | self.link.set_size(1000, 20) 157 | self.link.style['margin'] = '10px' 158 | 159 | self.main_container.append([self.title, self.horizontal_container, self.link]) 160 | 161 | self.new_game(self) 162 | 163 | # returning the root widget 164 | return self.main_container 165 | 166 | async def talk(self): 167 | if self._timer is not None: 168 | self._timer = self.spawn(self.display_time) 169 | await super().talk() 170 | 171 | def cancel(self): 172 | if self._timer is not None: 173 | self._timer.cancel() 174 | self._timer = None 175 | super(MyApp, self).cancel() 176 | 177 | def coord_in_map(self, x, y, w=None, h=None): 178 | w = len(self.mine_matrix[0]) if w is None else w 179 | h = len(self.mine_matrix) if h is None else h 180 | return not (x > w - 1 or y > h - 1 or x < 0 or y < 0) 181 | 182 | def new_game(self, widget): 183 | self.time_count = 0 184 | self.mine_table = gui.Table(margin='0px auto') # 900, 450 185 | self.mine_matrix = self.build_mine_matrix(8, 8, 5) 186 | self.mine_table.empty() 187 | 188 | for x in range(0, len(self.mine_matrix[0])): 189 | row = gui.TableRow() 190 | for y in range(0, len(self.mine_matrix)): 191 | row.append(self.mine_matrix[y][x]) 192 | self.mine_matrix[y][x].onclick.do(self.mine_matrix[y][x].check_mine) 193 | self.mine_table.append(row) 194 | 195 | # self.mine_table.append_from_list(self.mine_matrix, False) 196 | self.main_container.append(self.mine_table, key="mine_table") 197 | self.check_if_win() 198 | self.set_root_widget(self.main_container) 199 | 200 | def build_mine_matrix(self, w, h, minenum): 201 | """random fill cells with mines and increments nearest mines num in adiacent cells""" 202 | self.minecount = 0 203 | matrix = [[Cell(30, 30, x, y, self) for x in range(w)] for y in range(h)] 204 | for i in range(0, minenum): 205 | x = random.randint(0, w - 1) 206 | y = random.randint(0, h - 1) 207 | if matrix[y][x].has_mine: 208 | continue 209 | 210 | self.minecount += 1 211 | matrix[y][x].has_mine = True 212 | for coord in [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]: 213 | _x, _y = coord 214 | if not self.coord_in_map(x + _x, y + _y, w, h): 215 | continue 216 | matrix[y + _y][x + _x].add_nearest_mine() 217 | return matrix 218 | 219 | def no_mine(self, cell): 220 | """opens nearest cells that are not near a mine""" 221 | if cell.nearest_mine > 0: 222 | return 223 | self.fill_void_cells(cell) 224 | 225 | def check_if_win(self): 226 | """Here are counted the flags. Is checked if the user win.""" 227 | self.flagcount = 0 228 | win = True 229 | for x in range(0, len(self.mine_matrix[0])): 230 | for y in range(0, len(self.mine_matrix)): 231 | if self.mine_matrix[y][x].state == 1: 232 | self.flagcount += 1 233 | if not self.mine_matrix[y][x].has_mine: 234 | win = False 235 | elif self.mine_matrix[y][x].has_mine: 236 | win = False 237 | self.lblMineCount.set_text("%s" % self.minecount) 238 | self.lblFlagCount.set_text("%s" % self.flagcount) 239 | if win: 240 | self.dialog = gui.GenericDialog(title='You Win!', message='Game done in %s seconds' % self.time_count) 241 | self.dialog.confirm_dialog.do(self.new_game) 242 | self.dialog.cancel_dialog.do(self.new_game) 243 | self.dialog.show(self) 244 | 245 | def fill_void_cells(self, cell): 246 | checked_cells = [cell, ] 247 | while len(checked_cells) > 0: 248 | for cell in checked_cells[:]: 249 | checked_cells.remove(cell) 250 | if (not self.mine_matrix[cell.y][cell.x].has_mine) and \ 251 | (self.mine_matrix[cell.y][cell.x].nearest_mine == 0): 252 | for coord in [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]: 253 | _x, _y = coord 254 | if not self.coord_in_map(cell.x + _x, cell.y + _y): 255 | continue 256 | 257 | if not self.mine_matrix[cell.y + _y][cell.x + _x].opened: 258 | self.mine_matrix[cell.y + _y][cell.x + _x].check_mine(None, False) 259 | checked_cells.append(self.mine_matrix[cell.y + _y][cell.x + _x]) 260 | 261 | def explosion(self, cell): 262 | print("explosion") 263 | self.mine_table = gui.Table(margin='0px auto') 264 | self.main_container.append(self.mine_table, key="mine_table") 265 | for x in range(0, len(self.mine_matrix[0])): 266 | for y in range(0, len(self.mine_matrix)): 267 | self.mine_matrix[y][x].style['background-color'] = 'red' 268 | self.mine_matrix[y][x].check_mine(None, False) 269 | self.mine_table.empty() 270 | 271 | # self.mine_table.append_from_list(self.mine_matrix, False) 272 | for x in range(0, len(self.mine_matrix[0])): 273 | row = gui.TableRow() 274 | for y in range(0, len(self.mine_matrix)): 275 | row.append(self.mine_matrix[y][x]) 276 | self.mine_matrix[y][x].onclick.do(self.mine_matrix[y][x].check_mine) 277 | self.mine_table.append(row) 278 | 279 | class MyApp(Worker): 280 | # DeFramed stuff 281 | async def show_main(self, token): 282 | self.w = Minefield(self) 283 | await self.w.show(width=800,height=500, name=self.w.title) 284 | await self.busy(False) 285 | await super().show_main() 286 | 287 | 288 | async def main(): 289 | del CFG.logging.handlers.logfile 290 | CFG.logging.handlers.stderr["level"]="DEBUG" 291 | CFG.logging.root["level"]="DEBUG" 292 | CFG.server.host="0.0.0.0" 293 | CFG.server.port=50080 294 | 295 | logging_config(CFG.logging) 296 | app=App(CFG, MyApp, debug=True) 297 | 298 | resources = __file__.replace(".py","_res").replace("_app_res","_res") 299 | @app.route("/my_resources:", methods=['GET']) 300 | async def send_resources(filename): 301 | print("GETRESO",filename) 302 | return await send_from_directory(resources, filename) 303 | 304 | import remi 305 | res = os.path.join(os.path.dirname(remi.__file__), "res") 306 | @app.route("/res:", methods=['GET']) 307 | async def send_res(filename): 308 | print("GETRES",filename) 309 | return await send_from_directory(res, filename) 310 | 311 | await app.run() 312 | 313 | if __name__ == "__main__": 314 | trio.run(main) 315 | 316 | -------------------------------------------------------------------------------- /example/minefield_app.py.orig: -------------------------------------------------------------------------------- 1 | """ 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import os.path 16 | import random 17 | import threading 18 | 19 | import remi.gui as gui 20 | from remi import start, App 21 | 22 | 23 | class Cell(gui.TableItem): 24 | """ 25 | Represent a cell in the minefield map 26 | """ 27 | 28 | def __init__(self, width, height, x, y, game): 29 | super(Cell, self).__init__('') 30 | self.set_size(width, height) 31 | self.x = x 32 | self.y = y 33 | self.has_mine = False 34 | self.state = 0 # unknown - doubt - flag 35 | self.opened = False 36 | self.nearest_mine = 0 # number of mines adjacent with this cell 37 | self.game = game 38 | 39 | self.style['font-weight'] = 'bold' 40 | self.style['text-align'] = 'center' 41 | self.style['background-size'] = 'contain' 42 | if ((x + y) % 2) > 0: 43 | self.style['background-color'] = 'rgb(255,255,255)' 44 | else: 45 | self.style['background-color'] = 'rgb(245,245,240)' 46 | self.oncontextmenu.do(self.on_right_click) 47 | self.onclick.do(self.check_mine) 48 | 49 | def on_right_click(self, widget): 50 | """ Here with right click the change of cell is changed """ 51 | if self.opened: 52 | return 53 | self.state = (self.state + 1) % 3 54 | self.set_icon() 55 | self.game.check_if_win() 56 | 57 | def check_mine(self, widget, notify_game=True): 58 | if self.state == 1: 59 | return 60 | if self.opened: 61 | return 62 | self.opened = True 63 | if self.has_mine and notify_game: 64 | self.game.explosion(self) 65 | self.set_icon() 66 | return 67 | if notify_game: 68 | self.game.no_mine(self) 69 | self.set_icon() 70 | 71 | def set_icon(self): 72 | self.style['background-image'] = "''" 73 | if self.opened: 74 | if self.has_mine: 75 | self.style['background-image'] = "url('/my_resources:mine.png')" 76 | else: 77 | if self.nearest_mine > 0: 78 | self.set_text(str(self.nearest_mine)) 79 | else: 80 | self.style['background-color'] = 'rgb(200,255,100)' 81 | return 82 | if self.state == 2: 83 | self.style['background-image'] = "url('/my_resources:doubt.png')" 84 | if self.state == 1: 85 | self.style['background-image'] = "url('/my_resources:flag.png')" 86 | 87 | def add_nearest_mine(self): 88 | self.nearest_mine += 1 89 | 90 | 91 | class MyApp(App): 92 | def __init__(self, *args): 93 | res_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'res') 94 | super(MyApp, self).__init__(*args, static_file_path={'my_resources': res_path}) 95 | 96 | def display_time(self): 97 | self.lblTime.set_text('Play time: ' + str(self.time_count)) 98 | self.time_count += 1 99 | if not self.stop_flag: 100 | threading.Timer(1, self.display_time).start() 101 | 102 | def main(self): 103 | # the arguments are width - height - layoutOrientationOrizontal 104 | self.main_container = gui.Container(margin='0px auto') 105 | self.main_container.set_size(1020, 600) 106 | self.main_container.set_layout_orientation(gui.Container.LAYOUT_VERTICAL) 107 | 108 | self.title = gui.Label('Mine Field GAME') 109 | self.title.set_size(1000, 30) 110 | self.title.style['margin'] = '10px' 111 | self.title.style['font-size'] = '25px' 112 | self.title.style['font-weight'] = 'bold' 113 | 114 | self.info = gui.Label('Collaborative minefiled game. Enjoy.') 115 | self.info.set_size(400, 30) 116 | self.info.style['margin'] = '10px' 117 | self.info.style['font-size'] = '20px' 118 | 119 | self.lblMineCount = gui.Label('Mines') 120 | self.lblMineCount.set_size(100, 30) 121 | self.lblFlagCount = gui.Label('Flags') 122 | self.lblFlagCount.set_size(100, 30) 123 | 124 | self.time_count = 0 125 | self.lblTime = gui.Label('Time') 126 | self.lblTime.set_size(100, 30) 127 | 128 | self.btReset = gui.Button('Restart') 129 | self.btReset.set_size(100, 30) 130 | self.btReset.onclick.do(self.new_game) 131 | 132 | self.horizontal_container = gui.Container() 133 | self.horizontal_container.style['display'] = 'block' 134 | self.horizontal_container.style['overflow'] = 'auto' 135 | self.horizontal_container.set_layout_orientation(gui.Container.LAYOUT_HORIZONTAL) 136 | self.horizontal_container.style['margin'] = '10px' 137 | self.horizontal_container.append(self.info) 138 | imgMine = gui.Image('/my_resources:mine.png') 139 | imgMine.set_size(30, 30) 140 | self.horizontal_container.append([imgMine, self.lblMineCount]) 141 | imgFlag = gui.Image('/my_resources:flag.png') 142 | imgFlag.set_size(30, 30) 143 | self.horizontal_container.append([imgFlag, self.lblFlagCount, self.lblTime, self.btReset]) 144 | 145 | self.minecount = 0 # mine number in the map 146 | self.flagcount = 0 # flag placed by the players 147 | 148 | self.link = gui.Link("https://github.com/dddomodossola/remi", 149 | "This is an example of REMI gui library.") 150 | self.link.set_size(1000, 20) 151 | self.link.style['margin'] = '10px' 152 | 153 | self.main_container.append([self.title, self.horizontal_container, self.link]) 154 | 155 | self.new_game(self) 156 | 157 | self.stop_flag = False 158 | self.display_time() 159 | # returning the root widget 160 | return self.main_container 161 | 162 | def on_close(self): 163 | self.stop_flag = True 164 | super(MyApp, self).on_close() 165 | 166 | def coord_in_map(self, x, y, w=None, h=None): 167 | w = len(self.mine_matrix[0]) if w is None else w 168 | h = len(self.mine_matrix) if h is None else h 169 | return not (x > w - 1 or y > h - 1 or x < 0 or y < 0) 170 | 171 | def new_game(self, widget): 172 | self.time_count = 0 173 | self.mine_table = gui.Table(margin='0px auto') # 900, 450 174 | self.mine_matrix = self.build_mine_matrix(8, 8, 5) 175 | self.mine_table.empty() 176 | 177 | for x in range(0, len(self.mine_matrix[0])): 178 | row = gui.TableRow() 179 | for y in range(0, len(self.mine_matrix)): 180 | row.append(self.mine_matrix[y][x]) 181 | self.mine_matrix[y][x].onclick.do(self.mine_matrix[y][x].check_mine) 182 | self.mine_table.append(row) 183 | 184 | # self.mine_table.append_from_list(self.mine_matrix, False) 185 | self.main_container.append(self.mine_table, key="mine_table") 186 | self.check_if_win() 187 | self.set_root_widget(self.main_container) 188 | 189 | def build_mine_matrix(self, w, h, minenum): 190 | """random fill cells with mines and increments nearest mines num in adiacent cells""" 191 | self.minecount = 0 192 | matrix = [[Cell(30, 30, x, y, self) for x in range(w)] for y in range(h)] 193 | for i in range(0, minenum): 194 | x = random.randint(0, w - 1) 195 | y = random.randint(0, h - 1) 196 | if matrix[y][x].has_mine: 197 | continue 198 | 199 | self.minecount += 1 200 | matrix[y][x].has_mine = True 201 | for coord in [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]: 202 | _x, _y = coord 203 | if not self.coord_in_map(x + _x, y + _y, w, h): 204 | continue 205 | matrix[y + _y][x + _x].add_nearest_mine() 206 | return matrix 207 | 208 | def no_mine(self, cell): 209 | """opens nearest cells that are not near a mine""" 210 | if cell.nearest_mine > 0: 211 | return 212 | self.fill_void_cells(cell) 213 | 214 | def check_if_win(self): 215 | """Here are counted the flags. Is checked if the user win.""" 216 | self.flagcount = 0 217 | win = True 218 | for x in range(0, len(self.mine_matrix[0])): 219 | for y in range(0, len(self.mine_matrix)): 220 | if self.mine_matrix[y][x].state == 1: 221 | self.flagcount += 1 222 | if not self.mine_matrix[y][x].has_mine: 223 | win = False 224 | elif self.mine_matrix[y][x].has_mine: 225 | win = False 226 | self.lblMineCount.set_text("%s" % self.minecount) 227 | self.lblFlagCount.set_text("%s" % self.flagcount) 228 | if win: 229 | self.dialog = gui.GenericDialog(title='You Win!', message='Game done in %s seconds' % self.time_count) 230 | self.dialog.confirm_dialog.do(self.new_game) 231 | self.dialog.cancel_dialog.do(self.new_game) 232 | self.dialog.show(self) 233 | 234 | def fill_void_cells(self, cell): 235 | checked_cells = [cell, ] 236 | while len(checked_cells) > 0: 237 | for cell in checked_cells[:]: 238 | checked_cells.remove(cell) 239 | if (not self.mine_matrix[cell.y][cell.x].has_mine) and \ 240 | (self.mine_matrix[cell.y][cell.x].nearest_mine == 0): 241 | for coord in [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]: 242 | _x, _y = coord 243 | if not self.coord_in_map(cell.x + _x, cell.y + _y): 244 | continue 245 | 246 | if not self.mine_matrix[cell.y + _y][cell.x + _x].opened: 247 | self.mine_matrix[cell.y + _y][cell.x + _x].check_mine(None, False) 248 | checked_cells.append(self.mine_matrix[cell.y + _y][cell.x + _x]) 249 | 250 | def explosion(self, cell): 251 | print("explosion") 252 | self.mine_table = gui.Table(margin='0px auto') 253 | self.main_container.append(self.mine_table, key="mine_table") 254 | for x in range(0, len(self.mine_matrix[0])): 255 | for y in range(0, len(self.mine_matrix)): 256 | self.mine_matrix[y][x].style['background-color'] = 'red' 257 | self.mine_matrix[y][x].check_mine(None, False) 258 | self.mine_table.empty() 259 | 260 | # self.mine_table.append_from_list(self.mine_matrix, False) 261 | for x in range(0, len(self.mine_matrix[0])): 262 | row = gui.TableRow() 263 | for y in range(0, len(self.mine_matrix)): 264 | row.append(self.mine_matrix[y][x]) 265 | self.mine_matrix[y][x].onclick.do(self.mine_matrix[y][x].check_mine) 266 | self.mine_table.append(row) 267 | 268 | 269 | if __name__ == "__main__": 270 | start(MyApp, multiple_instance=True, address='0.0.0.0', port=0, debug=True, start_browser=True) 271 | -------------------------------------------------------------------------------- /example/minefield_embed.py: -------------------------------------------------------------------------------- 1 | """ 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import trio 16 | import os.path 17 | import random 18 | 19 | from quart.static import send_from_directory 20 | import deframed.remi.gui as gui 21 | from deframed.remi import RemiHandler, RemiSupport 22 | from deframed import App,Worker 23 | from deframed.default import CFG 24 | 25 | import logging 26 | from logging.config import dictConfig as logging_config 27 | logger = logging.getLogger("minefield") 28 | 29 | class Cell(gui.TableItem): 30 | """ 31 | Represent a cell in the minefield map 32 | """ 33 | 34 | def __init__(self, width, height, x, y, game): 35 | super(Cell, self).__init__('') 36 | self.set_size(width, height) 37 | self.x = x 38 | self.y = y 39 | self.has_mine = False 40 | self.state = 0 # unknown - doubt - flag 41 | self.opened = False 42 | self.nearest_mine = 0 # number of mines adjacent with this cell 43 | self.game = game 44 | 45 | self.style['font-weight'] = 'bold' 46 | self.style['text-align'] = 'center' 47 | self.style['background-size'] = 'contain' 48 | if ((x + y) % 2) > 0: 49 | self.style['background-color'] = 'rgb(255,255,255)' 50 | else: 51 | self.style['background-color'] = 'rgb(245,245,240)' 52 | self.oncontextmenu.do(self.on_right_click, js_stop_propagation=True, js_prevent_default=True) 53 | self.onclick.do(self.check_mine) 54 | 55 | def on_right_click(self, widget): 56 | """ Here with right click the change of cell is changed """ 57 | if self.opened: 58 | return 59 | self.state = (self.state + 1) % 3 60 | self.set_icon() 61 | self.game.check_if_win() 62 | 63 | def check_mine(self, widget, notify_game=True): 64 | if self.state == 1: 65 | return 66 | if self.opened: 67 | return 68 | self.opened = True 69 | if self.has_mine and notify_game: 70 | self.game.explosion(self) 71 | self.set_icon() 72 | return 73 | if notify_game: 74 | self.game.no_mine(self) 75 | self.set_icon() 76 | 77 | def set_icon(self): 78 | self.style['background-image'] = "''" 79 | if self.opened: 80 | if self.has_mine: 81 | self.style['background-image'] = "url('/my_resources:mine.png')" 82 | else: 83 | if self.nearest_mine > 0: 84 | self.set_text(str(self.nearest_mine)) 85 | else: 86 | self.style['background-color'] = 'rgb(200,255,100)' 87 | return 88 | if self.state == 2: 89 | self.style['background-image'] = "url('/my_resources:doubt.png')" 90 | if self.state == 1: 91 | self.style['background-image'] = "url('/my_resources:flag.png')" 92 | 93 | def add_nearest_mine(self): 94 | self.nearest_mine += 1 95 | 96 | 97 | class Minefield(RemiHandler): 98 | title = "Minefield" 99 | 100 | _timer = None 101 | 102 | async def display_time(self): 103 | while True: 104 | await trio.sleep(1) 105 | self.lblTime.set_text('Play time: ' + str(self.time_count)) 106 | self.time_count += 1 107 | 108 | def main(self): 109 | # the arguments are width - height - layoutOrientationOrizontal 110 | self.main_container = gui.Container(margin='0px auto') 111 | self.main_container.set_size(1020, 600) 112 | self.main_container.set_layout_orientation(gui.Container.LAYOUT_VERTICAL) 113 | 114 | self.title = gui.Label('Mine Field GAME') 115 | self.title.set_size(1000, 30) 116 | self.title.style['margin'] = '10px' 117 | self.title.style['font-size'] = '25px' 118 | self.title.style['font-weight'] = 'bold' 119 | 120 | self.info = gui.Label('Minefield game. Enjoy.') 121 | self.info.set_size(400, 30) 122 | self.info.style['margin'] = '10px' 123 | self.info.style['font-size'] = '20px' 124 | 125 | self.lblMineCount = gui.Label('Mines') 126 | self.lblMineCount.set_size(100, 30) 127 | self.lblFlagCount = gui.Label('Flags') 128 | self.lblFlagCount.set_size(100, 30) 129 | 130 | self.time_count = 0 131 | self.lblTime = gui.Label('Time') 132 | self.lblTime.set_size(100, 30) 133 | 134 | self.btReset = gui.Button('Restart') 135 | self.btReset.set_size(100, 30) 136 | self.btReset.onclick.do(self.new_game) 137 | 138 | self.horizontal_container = gui.Container() 139 | self.horizontal_container.style['display'] = 'block' 140 | self.horizontal_container.style['overflow'] = 'auto' 141 | self.horizontal_container.set_layout_orientation(gui.Container.LAYOUT_HORIZONTAL) 142 | self.horizontal_container.style['margin'] = '10px' 143 | self.horizontal_container.append(self.info) 144 | imgMine = gui.Image('/my_resources:mine.png') 145 | imgMine.set_size(30, 30) 146 | self.horizontal_container.append([imgMine, self.lblMineCount]) 147 | imgFlag = gui.Image('/my_resources:flag.png') 148 | imgFlag.set_size(30, 30) 149 | self.horizontal_container.append([imgFlag, self.lblFlagCount, self.lblTime, self.btReset]) 150 | 151 | self.minecount = 0 # mine number in the map 152 | self.flagcount = 0 # flag placed by the players 153 | 154 | self.link = gui.Link("https://github.com/dddomodossola/remi", 155 | "This is an example of REMI gui library.") 156 | self.link.set_size(1000, 20) 157 | self.link.style['margin'] = '10px' 158 | 159 | self.main_container.append([self.title, self.horizontal_container, self.link]) 160 | 161 | self.new_game(self) 162 | 163 | # returning the root widget 164 | return self.main_container 165 | 166 | async def talk(self): 167 | if self._timer is not None: 168 | self._timer = self.spawn(self.display_time) 169 | await super().talk() 170 | 171 | def cancel(self): 172 | if self._timer is not None: 173 | self._timer.cancel() 174 | self._timer = None 175 | super(MyApp, self).cancel() 176 | 177 | def coord_in_map(self, x, y, w=None, h=None): 178 | w = len(self.mine_matrix[0]) if w is None else w 179 | h = len(self.mine_matrix) if h is None else h 180 | return not (x > w - 1 or y > h - 1 or x < 0 or y < 0) 181 | 182 | def new_game(self, widget): 183 | self.time_count = 0 184 | self.mine_table = gui.Table(margin='0px auto') # 900, 450 185 | self.mine_matrix = self.build_mine_matrix(8, 8, 5) 186 | self.mine_table.empty() 187 | 188 | for x in range(0, len(self.mine_matrix[0])): 189 | row = gui.TableRow() 190 | for y in range(0, len(self.mine_matrix)): 191 | row.append(self.mine_matrix[y][x]) 192 | self.mine_matrix[y][x].onclick.do(self.mine_matrix[y][x].check_mine) 193 | self.mine_table.append(row) 194 | 195 | # self.mine_table.append_from_list(self.mine_matrix, False) 196 | self.main_container.append(self.mine_table, key="mine_table") 197 | self.check_if_win() 198 | self.set_root_widget(self.main_container) 199 | 200 | def build_mine_matrix(self, w, h, minenum): 201 | """random fill cells with mines and increments nearest mines num in adiacent cells""" 202 | self.minecount = 0 203 | matrix = [[Cell(30, 30, x, y, self) for x in range(w)] for y in range(h)] 204 | for i in range(0, minenum): 205 | x = random.randint(0, w - 1) 206 | y = random.randint(0, h - 1) 207 | if matrix[y][x].has_mine: 208 | continue 209 | 210 | self.minecount += 1 211 | matrix[y][x].has_mine = True 212 | for coord in [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]: 213 | _x, _y = coord 214 | if not self.coord_in_map(x + _x, y + _y, w, h): 215 | continue 216 | matrix[y + _y][x + _x].add_nearest_mine() 217 | return matrix 218 | 219 | def no_mine(self, cell): 220 | """opens nearest cells that are not near a mine""" 221 | if cell.nearest_mine > 0: 222 | return 223 | self.fill_void_cells(cell) 224 | 225 | def check_if_win(self): 226 | """Here are counted the flags. Is checked if the user win.""" 227 | self.flagcount = 0 228 | win = True 229 | for x in range(0, len(self.mine_matrix[0])): 230 | for y in range(0, len(self.mine_matrix)): 231 | if self.mine_matrix[y][x].state == 1: 232 | self.flagcount += 1 233 | if not self.mine_matrix[y][x].has_mine: 234 | win = False 235 | elif self.mine_matrix[y][x].has_mine: 236 | win = False 237 | self.lblMineCount.set_text("%s" % self.minecount) 238 | self.lblFlagCount.set_text("%s" % self.flagcount) 239 | if win: 240 | self.dialog = gui.GenericDialog(title='You Win!', message='Game done in %s seconds' % self.time_count) 241 | self.dialog.confirm_dialog.do(self.new_game) 242 | self.dialog.cancel_dialog.do(self.new_game) 243 | self.dialog.show(self) 244 | 245 | def fill_void_cells(self, cell): 246 | checked_cells = [cell, ] 247 | while len(checked_cells) > 0: 248 | for cell in checked_cells[:]: 249 | checked_cells.remove(cell) 250 | if (not self.mine_matrix[cell.y][cell.x].has_mine) and \ 251 | (self.mine_matrix[cell.y][cell.x].nearest_mine == 0): 252 | for coord in [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]: 253 | _x, _y = coord 254 | if not self.coord_in_map(cell.x + _x, cell.y + _y): 255 | continue 256 | 257 | if not self.mine_matrix[cell.y + _y][cell.x + _x].opened: 258 | self.mine_matrix[cell.y + _y][cell.x + _x].check_mine(None, False) 259 | checked_cells.append(self.mine_matrix[cell.y + _y][cell.x + _x]) 260 | 261 | def explosion(self, cell): 262 | print("explosion") 263 | self.mine_table = gui.Table(margin='0px auto') 264 | self.main_container.append(self.mine_table, key="mine_table") 265 | for x in range(0, len(self.mine_matrix[0])): 266 | for y in range(0, len(self.mine_matrix)): 267 | self.mine_matrix[y][x].style['background-color'] = 'red' 268 | self.mine_matrix[y][x].check_mine(None, False) 269 | self.mine_table.empty() 270 | 271 | # self.mine_table.append_from_list(self.mine_matrix, False) 272 | for x in range(0, len(self.mine_matrix[0])): 273 | row = gui.TableRow() 274 | for y in range(0, len(self.mine_matrix)): 275 | row.append(self.mine_matrix[y][x]) 276 | self.mine_matrix[y][x].onclick.do(self.mine_matrix[y][x].check_mine) 277 | self.mine_table.append(row) 278 | 279 | class MyApp(RemiSupport, Worker): 280 | title = "Minefield (embedded)" 281 | 282 | # DeFramed stuff 283 | async def show_main(self, token): 284 | # await self.debug(True) 285 | self.h = Minefield(self) 286 | await self.h.show() 287 | await self.busy(False) 288 | await self.alert("info",None) 289 | await super().show_main() 290 | 291 | 292 | async def main(): 293 | del CFG.logging.handlers.logfile 294 | CFG.logging.handlers.stderr["level"]="DEBUG" 295 | CFG.logging.root["level"]="DEBUG" 296 | CFG.server.host="0.0.0.0" 297 | CFG.server.port=50080 298 | 299 | logging_config(CFG.logging) 300 | app=App(CFG, MyApp, debug=True) 301 | 302 | resources = __file__.replace(".py","_res").replace("_app_res","_res").replace("_embed_res","_res").replace("_app_res","_res") 303 | @app.route("/my_resources:", methods=['GET']) 304 | async def send_resources(filename): 305 | print("GETRESO",filename) 306 | return await send_from_directory(resources, filename) 307 | 308 | import remi 309 | res = os.path.join(os.path.dirname(remi.__file__), "res") 310 | @app.route("/res:", methods=['GET']) 311 | async def send_res(filename): 312 | print("GETRES",filename) 313 | return await send_from_directory(res, filename) 314 | 315 | await app.run() 316 | 317 | if __name__ == "__main__": 318 | trio.run(main) 319 | 320 | -------------------------------------------------------------------------------- /example/minefield_res/doubt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smurfix/deframed/9c1d4db2991cef55725ac6ecae44af60a96ff4f2/example/minefield_res/doubt.png -------------------------------------------------------------------------------- /example/minefield_res/flag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smurfix/deframed/9c1d4db2991cef55725ac6ecae44af60a96ff4f2/example/minefield_res/flag.png -------------------------------------------------------------------------------- /example/minefield_res/mine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smurfix/deframed/9c1d4db2991cef55725ac6ecae44af60a96ff4f2/example/minefield_res/mine.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | LONG_DESC = open("README.rst").read() 4 | 5 | setup( 6 | name="deframed", 7 | use_scm_version={"version_scheme": "guess-next-dev", "local_scheme": "dirty-tag"}, 8 | description="A minimal web non-framework", 9 | url="https://github.com/smurfix/deframed", 10 | long_description=LONG_DESC, 11 | author="Matthias Urlichs", 12 | author_email="matthias@urlichs.de", 13 | license="GPL3", 14 | packages=find_packages(), 15 | setup_requires=["setuptools_scm", "pytest_runner"], 16 | install_requires=[ 17 | "trio >= 0.12", 18 | "attrs >= 18.2", 19 | "chevron", 20 | "quart-trio >= 0.5", 21 | ], 22 | tests_require=[ 23 | "pytest", 24 | "pytest-trio", 25 | "flake8 >= 3.7" 26 | ], 27 | keywords=["async", "web", "framework"], 28 | python_requires=">=3.7", 29 | classifiers=[ 30 | "Development Status :: 2 - Pre-Alpha", 31 | "Intended Audience :: Developers", 32 | "Framework :: Trio", 33 | "Programming Language :: Python :: 3.7", 34 | "Programming Language :: Python :: 3.8", 35 | "Programming Language :: Python :: Implementation :: CPython", 36 | "Topic :: Communications :: Telephony", 37 | "Topic :: Software Development :: Testing", 38 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 39 | ], 40 | zip_safe=False, 41 | ) 42 | --------------------------------------------------------------------------------