├── .daemon.py ├── .gitignore ├── .gitmodules ├── MANIFEST.in ├── README.md ├── browsergui ├── __init__.py ├── _documentchangetracker.py ├── _gui.py ├── _pythoncompatibility.py ├── _server │ ├── __init__.py │ ├── index.html │ └── puppet.js ├── elements │ ├── __init__.py │ ├── _basic │ │ ├── __init__.py │ │ ├── _image.py │ │ └── _text.py │ ├── _callbacksetter.py │ ├── _input │ │ ├── __init__.py │ │ ├── _bigtextfield.py │ │ ├── _button.py │ │ ├── _colorfield.py │ │ ├── _datefield.py │ │ ├── _dropdown.py │ │ ├── _inputfield.py │ │ ├── _numberfield.py │ │ ├── _slider.py │ │ ├── _textfield.py │ │ └── _valuedelement.py │ ├── _layout │ │ ├── __init__.py │ │ ├── _container.py │ │ ├── _grid.py │ │ ├── _list.py │ │ └── _viewport.py │ ├── _styler.py │ └── _xmltagshield.py ├── events │ └── __init__.py └── examples │ ├── __init__.py │ ├── __main__.py │ ├── clock.py │ ├── helloworld.py │ ├── interactive.py │ ├── longrunning.py │ ├── minesweeper.py │ ├── tour-image.png │ └── tour.py ├── doc ├── Makefile ├── browsergui.elements.rst ├── browsergui.events.rst ├── browsergui.examples.rst ├── browsergui.rst ├── conf.py ├── index.rst └── modules.rst ├── setup.cfg ├── setup.py └── test ├── __init__.py ├── test_big_text_field.py ├── test_button.py ├── test_callback_setter.py ├── test_color_field.py ├── test_container.py ├── test_date_field.py ├── test_document_change_tracker.py ├── test_dropdown.py ├── test_element.py ├── test_events.py ├── test_grid.py ├── test_gui.py ├── test_image.py ├── test_link.py ├── test_list.py ├── test_number_field.py ├── test_server.py ├── test_slider.py ├── test_styler.py ├── test_styling.py ├── test_text.py ├── test_text_field.py ├── test_viewport.py └── test_xml_tag_shield.py /.daemon.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import shlex 4 | import subprocess 5 | from pathlib import Path 6 | 7 | from watchman_context import with_triggers # curl -o ~/Downloads/watchman_context.py https://gist.githubusercontent.com/speezepearson/39dd07131dd27e81b92e6794b182dba7/raw/f57f13f2070480edb0b304655a8728ec6296e3da/watchman_context.py 8 | import xargsd.server # pip install xargsd 9 | 10 | TRIGGERS = [ 11 | { 12 | "name": "pytest", 13 | "expression": [ 14 | "allof", 15 | ["not", ["pcre", ".*/__pycache__/.*", "wholename"]], 16 | [ 17 | "anyof", 18 | ["pcre", "browsergui/.*\\.py", "wholename"], 19 | ["pcre", "test/.*\\.py", "wholename"] 20 | ] 21 | ], 22 | "stdin": ["name", "mode"], 23 | "command": [ 24 | "bash", 25 | "-c", 26 | "python -m xargsd.client -s .xargsd-pytest.sock ." 27 | ] 28 | } 29 | ] 30 | 31 | 32 | with with_triggers(dir='.', triggers=TRIGGERS): 33 | xargsd.server.run_server_sync( 34 | command=['chime-success', 'pytest', '--color=yes'], 35 | socket_file='.xargsd-pytest.sock', 36 | unique=True, 37 | log_level=logging.INFO, 38 | ) 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | doc/_build -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "wiki"] 2 | path = wiki 3 | url = https://github.com/speezepearson/browsergui.wiki.git 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | What is this? 2 | ------------- 3 | It's a GUI framework prioritizing portability, simplicity, and a Pythonic feel. The two facts I think will best hook you: 4 | - Simple GUIs are simple to make: `GUI(Text("Hello world!")).run()`. 5 | - You could type `easy_install browsergui && python -m browsergui.examples` *this instant*, and it would work. (Well, maybe you need to `sudo`.) There's no complex application framework you need to install first. 6 | 7 | (Corollary: if you build a GUI using this package, your co-developers/users also won't need to install anything complicated.) 8 | 9 | If you want to build a simple GUI for a simple task, this is a great library. [Here's a mock-up][demo] that shows all the stuff you can do with it. 10 | 11 | If you want to build a video game, or a nice, fluid 3D visualization, this is easily the worst GUI framework I have ever seen. 12 | 13 | - [Why is it good?](#why-is-it-good) 14 | - [Why is it bad?](#why-is-it-bad) 15 | - [What are the alternatives?](#what-are-the-alternatives) 16 | - [How do I install it?](#how-do-i-install-it) 17 | - [How do I learn to use it?](#how-do-i-learn-to-use-it) 18 | 19 | 20 | Why is it good? 21 | --------------- 22 | 23 | This package prioritizes ease of use, portability, and good documentation above all else. The following statements will remain true: 24 | 25 | - **It feels like Python.** It uses HTML/CSS/JS under the hood, but that fact is carefully hidden under nice object-oriented abstractions. Contrast with [Tkinter][tkinter], which feels like Tk, because it is. 26 | - **It has a shallow learning curve.** "Hello World" is `GUI(Text("Hello world!")).run()`. Minesweeper, including the game logic, is [less than 100 lines of code][minesweeper-code] and [looks like this][minesweeper-screenshot]. 27 | - **It's super-portable.** `pip install browsergui && python -m browsergui.examples` has worked, with no snags, on every system I've tried (OS X, Debian, and Ubuntu, with both Python 2.7 and a few Python 3.Xs). Seriously, you could run that right now and it would work, without a single abstruse error message about your Qt/wx/PyObjC installation. At the risk of tooting my own horn, I've never seen another GUI library so easy to install. 28 | - **It's well-documented.** There's a [wiki][wiki] to teach you how to use it. There are [examples](#how-do-I-learn-to-use-it). There's a [reference manual][docs]. There's a [runnable demo for every predefined kind of element][demo]. I've spent more time documenting than I've spent writing actual code. 29 | 30 | Why is it bad? 31 | -------------- 32 | 33 | - **It's slow.** It does not even try to be high-performance. There's an HTTP request every time the user interacts with the GUI, and again every time the view is updated. Performance is off the table. (It's not *frustratingly* slow -- you can drag a slider and see the value update with no perceptible delay -- but it's not good for fancy stuff.) 34 | - **It's not super-easy to make super-pretty things.** I just haven't prioritized styling: any styling you want to do, you have to do through CSS. I'm not sure `element.css['color'] = 'red'` is so much worse than `widget.config(foreground="#f00")`, but it *does* feel like a thin wrapper over CSS (because it is), which is gross. 35 | - **Its input-handling is limited.** Full-powered GUI libraries let you capture every mouse movement, every keypress, anything you can dream. That might come to Browsergui eventually, but for now, you're pretty much limited to predefined input fields. 36 | - **It doesn't provide utility functions.** Every other GUI framework I can recall seeing provides things like timers: things that are *nice* to have when you're making a GUI, but aren't directly related to user-interaction. This package doesn't offer those and probably never will. If you want timers, get them from a different package. 37 | 38 | What are the alternatives? 39 | -------------------------- 40 | 41 | I am aware of some GUI toolkits for Python that fill a similar niche. You should consider using these instead: 42 | 43 | - [RemI][remi], which has exactly the same idea (build a GUI in Python, run it in a browser). Definitely worth a look. 44 | 45 | Advantages: (at the time of writing) has more features, e.g. file-input dialogs. Looks significantly prettier. 46 | 47 | Disadvantages: (at the time of writing) less thorough tutorials/documentation. Simple apps are more verbose. 48 | 49 | - [tkinter][tkinter] (standard library) 50 | 51 | Advantages: it's well-known. Lots of people have written tutorials and documentation for it. 52 | 53 | Disadvantages: it feels like a wrapper around Tk, because it is. This gives good performance and detailed control, but writing it feels unintuitive (to me). Also, I've had trouble getting it to work with multiple Python installations. 54 | 55 | - [pyJS][pyjs], another Python package for making GUIs targeting browsers. It works by compiling your Python code into a slug of JavaScript which runs in the browser. 56 | 57 | Advantages: pyJS applications are much faster and much easier to deploy (since it doesn't require the user to run Python). 58 | 59 | Disadvantages: I had trouble installing it. And like `tkinter`, it's a wrapper, with the same dis/advantages. 60 | 61 | - [flexx][flexx], which (if I understand correctly) compiles a Python app to JavaScript. I haven't investigated it very much. 62 | 63 | There are, of course, many other GUI toolkits. [Here][official-alternatives] is a list of those popular enough to earn the notice of Official Python People. [Here][unofficial-alternatives] is a paralytically long listing of less-notable ones. 64 | 65 | How do I install it? 66 | -------------------- 67 | 68 | If you use pip, `pip install browsergui`. 69 | 70 | If you use easy_install, `easy_install browsergui`. 71 | 72 | If you don't like package managers, just unzip [this][download-zip] and put the `browsergui` folder anywhere on your Python path. 73 | 74 | Once it's installed, I recommend running `python -m browsergui.examples` to see a catalog of all the kinds of building blocks available to you, or checking out [the wiki][wiki] for tutorial-type stuff. 75 | 76 | 77 | How do I learn to use it? 78 | ------------------------- 79 | 80 | [The wiki][wiki] has several tutorial-type pages. Or you could just extrapolate from these examples: 81 | 82 | - Hello world: 83 | 84 | from browsergui import GUI, Text 85 | GUI(Text("Hello world!")).run() 86 | 87 | - A number that increments every time you press a button: 88 | 89 | from browsergui import GUI, Text, Button 90 | 91 | button = Button('0') 92 | @button.def_callback 93 | def increment(): 94 | button.text = str(int(button.text)+1) 95 | 96 | GUI(button).run() 97 | 98 | - A clock: 99 | 100 | import time 101 | import threading 102 | from browsergui import Text, GUI 103 | 104 | now = Text("") 105 | 106 | def update_now_forever(): 107 | while True: 108 | now.text = time.strftime("%Y-%m-%d %H:%M:%S") 109 | time.sleep(1) 110 | 111 | t = threading.Thread(target=update_now_forever) 112 | t.daemon = True 113 | t.start() 114 | 115 | GUI(Text("The time is: "), now).run() 116 | 117 | (You can close/reopen the browser window at any time; Ctrl-C will stop the server.) 118 | 119 | Each kind of element (`Text`, `Button`, `ColorField`, `Grid`...) also has a simple example showing you how to use it: `python -m browsergui.examples` will display all those examples to you. 120 | 121 | [remi]: https://github.com/dddomodossola/remi 122 | [flexx]: https://github.com/zoofIO/flexx 123 | [demo]: http://speezepearson.github.io/misc/fake-browsergui-demo.html 124 | [minesweeper-code]: https://github.com/speezepearson/browsergui/blob/master/browsergui/examples/minesweeper.py 125 | [minesweeper-screenshot]: http://i.imgur.com/8Ax04sZ.png 126 | [download-zip]: https://github.com/speezepearson/browsergui/archive/master.zip 127 | [wiki]: https://github.com/speezepearson/browsergui/wiki 128 | [docs]: http://pythonhosted.org/browsergui 129 | [download-zip]: https://github.com/speezepearson/browsergui/archive/master.zip 130 | [tkinter]: https://docs.python.org/3/library/tkinter.html#module-tkinter 131 | [pyjs]: http://pyjs.org 132 | [official-alternatives]: http://docs.python.org/2/library/othergui.html 133 | [unofficial-alternatives]: http://wiki.python.org/moin/GuiProgramming 134 | -------------------------------------------------------------------------------- /browsergui/__init__.py: -------------------------------------------------------------------------------- 1 | """Tools for building GUIs that use a browser as the front-end. 2 | 3 | .. autosummary:: 4 | 5 | elements 6 | events 7 | GUI 8 | 9 | .. autoclass:: GUI 10 | :members: 11 | """ 12 | 13 | from ._gui import GUI 14 | 15 | from . import elements, events 16 | from .elements import * 17 | from .events import * 18 | -------------------------------------------------------------------------------- /browsergui/_documentchangetracker.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import json 3 | 4 | class Destroyed(Exception): 5 | '''Raised when trying to operate on a destroyed DocumentChangeTracker.''' 6 | pass 7 | 8 | def ancestors(tag): 9 | tag = tag.parentNode 10 | while tag is not None: 11 | yield tag 12 | tag = tag.parentNode 13 | 14 | def innerHTML(tag): 15 | return ''.join(child.toxml() for child in tag.childNodes) 16 | 17 | def javascript_to_update_tag(tag, variable_name): 18 | lines = [] 19 | 20 | # In Chrome, doing `tag.setAttribute("value", ...)` usually updates the value displayed to the user, 21 | # but for color-type inputs, the displayed value does not update. So we need this following clause. 22 | # Furthermore, doing `tag.value = x` has no effect if `tag.getAttribute("value") == x`, 23 | # so this clause must come before the attribute-setting. 24 | # Crazy, I know. 25 | if tag.tagName == 'input': 26 | lines.append('{var}.value = {value}'.format(var=variable_name, value=json.dumps(tag.getAttribute('value')))) 27 | 28 | # Update tag attributes 29 | lines.append('while ({var}.attributes.length > 0) {var}.removeAttribute({var}.attributes[0].name);'.format(var=variable_name)) 30 | lines.extend( 31 | '{var}.setAttribute({key}, {value})'.format(var=variable_name, key=json.dumps(attr), value=json.dumps(value)) 32 | for attr, value in tag.attributes.items()) 33 | 34 | # Update tag children 35 | lines.append('{var}.innerHTML = {innerHTML}'.format(var=variable_name, innerHTML=json.dumps(innerHTML(tag)))) 36 | return ';\n'.join(lines) 37 | 38 | class DocumentChangeTracker(object): 39 | '''Provides JavaScript to help a browser keep its document up to date with a local one. 40 | 41 | Intended use case: there's a locally stored XML document, and there's another document 42 | stored in a browser window. The browser wants to execute some JavaScript to make its 43 | document look like the one on the server, so it makes a request to the server. 44 | The server should wait until a change is made to the local document (if necessary), 45 | then respond with the appropriate JS to apply the change to the remote document. 46 | 47 | How it works: the DocumentChangeTracker is instantiated. It keeps track of which tags 48 | are "dirty" (i.e. which tags need to be brought up to date in the browser). 49 | 50 | The :func:`mark_dirty` method marks a tag as dirty, possibly waking up threads 51 | waiting on :func:`flush_changes`. 52 | 53 | The :func:`flush_changes` method waits until a tag is dirty (if necessary), 54 | returns a JavaScript string that will bring the browser's DOM up to date, 55 | and unmarks all tags as dirty. 56 | 57 | The :func:`destroy` method wakes up all waiting threads, but causes them to return 58 | JavaScript that will close the browser window (if possible) or make it obviously 59 | obsolete (otherwise). (Some browsers don't let scripts close windows they didn't open; 60 | security concerns, I believe.) 61 | ''' 62 | def __init__(self, **kwargs): 63 | super(DocumentChangeTracker, self).__init__(**kwargs) 64 | 65 | self._mutex = threading.RLock() 66 | self._changed_condition = threading.Condition(self._mutex) 67 | self._dirty_tags = set() 68 | self._destroyed = False 69 | 70 | def destroy(self): 71 | '''Wake up waiting threads and give them JS to close the browser window.''' 72 | with self._mutex: 73 | self._destroyed = True 74 | self._changed_condition.notify_all() 75 | 76 | def flush_changes(self): 77 | '''Wait until the document is dirty, then return JS to bring a browser up to date. 78 | 79 | :returns: str 80 | ''' 81 | with self._changed_condition: 82 | while not (self._dirty_tags or self._destroyed): 83 | self._changed_condition.wait() 84 | if self._destroyed: 85 | return 'window.close(); sleep(9999)' 86 | 87 | # Not all dirty tags need rewriting. 88 | # Since rewriting a tag rewrites all its children, 89 | # a tag only needs rewriting if *none* of its ancestors 90 | # needs rewriting. 91 | self._dirty_tags = set( 92 | t for t in self._dirty_tags 93 | if not any(ancestor in self._dirty_tags for ancestor in ancestors(t))) 94 | 95 | result = ';\n'.join( 96 | 'temp = document.getElementById({id}); {update}'.format( 97 | id=json.dumps(tag.getAttribute('id')), 98 | update=javascript_to_update_tag(tag, variable_name='temp')) 99 | for tag in self._dirty_tags) 100 | self._dirty_tags = set() 101 | return result 102 | 103 | def mark_dirty(self, tag): 104 | '''Mark the given tag as dirty, waking up calls to :func:`flush_changes`.''' 105 | with self._changed_condition: 106 | if self._destroyed: 107 | raise Destroyed(self) 108 | 109 | self._dirty_tags.add(tag) 110 | self._changed_condition.notify_all() 111 | -------------------------------------------------------------------------------- /browsergui/_gui.py: -------------------------------------------------------------------------------- 1 | import xml.dom.minidom 2 | from .elements import Element, Text, Container 3 | from ._documentchangetracker import DocumentChangeTracker 4 | from . import _server as server 5 | 6 | class _Page(Element): 7 | '''Root Element, corresponding to the tag. 8 | 9 | Since an HTML document always has a and a , a _Page always has a :class:`_Head` and a :class:`_Body`. 10 | ''' 11 | def __init__(self, gui, **kwargs): 12 | self._gui = gui 13 | super(_Page, self).__init__(tag_name='html', **kwargs) 14 | self.tag.setAttribute('id', '__html__') 15 | 16 | self.head = _Head() 17 | self.tag.appendChild(self.head.tag) 18 | 19 | self.body = _Body() 20 | self.tag.appendChild(self.body.tag) 21 | 22 | @property 23 | def gui(self): 24 | return self._gui 25 | @gui.setter 26 | def gui(self, new_gui): 27 | self._gui = new_gui 28 | 29 | class _Head(Container): 30 | '''Element corresponding to the HTML tag. Always has a title.''' 31 | def __init__(self, **kwargs): 32 | super(_Head, self).__init__(tag_name='head', **kwargs) 33 | self.title = Text(tag_name='title', text='') 34 | self.append(self.title) 35 | 36 | class _Body(Container): 37 | '''Element corresponding to the HTML tag.''' 38 | def __init__(self, **kwargs): 39 | super(_Body, self).__init__(tag_name='body', **kwargs) 40 | 41 | 42 | class GUI(object): 43 | """Manages high-level features of the UI and coordinates between elements. 44 | 45 | Useful attributes/methods: 46 | 47 | See `this wiki page`_ for a guide to the basics. 48 | 49 | .. _this wiki page: https://github.com/speezepearson/browsergui/wiki/How-Do-I... 50 | 51 | :param Element elements: elements to immediately include in the GUI 52 | :param str title: title of the GUI (i.e. title of browser tab) 53 | """ 54 | 55 | def __init__(self, *elements, **kwargs): 56 | # Create the page initially with no GUI, because once it has a GUI, 57 | # modifying it will try to mark it as dirty; but we haven't set up 58 | # the change tracker yet, so it'll reach for a tracker that isn't there. 59 | self.page = _Page(gui=None) 60 | self.title = kwargs.pop('title', 'browsergui') 61 | 62 | self._create_change_tracker() 63 | self.server = None 64 | 65 | # NOW that we're all initialized, we can connect the page to the GUI. 66 | self.page.gui = self 67 | 68 | super(GUI, self).__init__(**kwargs) 69 | 70 | self.body.extend(elements) 71 | 72 | @property 73 | def body(self): 74 | '''A :class:`Container` that contains all the Elements you put into the GUI. 75 | 76 | Since Containers are mutable sequences, the following all do what you'd expect: 77 | 78 | >>> gui.body.append(e) 79 | >>> gui.body.extend([e1, e2, e3, e4]) 80 | >>> gui.body[3] = e5 81 | >>> del gui.body[0] 82 | ''' 83 | return self.page.body 84 | 85 | @property 86 | def title(self): 87 | '''The title for the browser page.''' 88 | return self.page.head.title.text 89 | @title.setter 90 | def title(self, new_title): 91 | self.page.head.title.text = new_title 92 | 93 | def dispatch_event(self, event): 94 | """Dispatch an event to whatever element is responsible for handling it.""" 95 | for element in self.body.walk(): 96 | if element.id == event.target_id: 97 | element.handle_event(event) 98 | break 99 | else: 100 | raise KeyError('no element with id {!r}'.format(event.target_id)) 101 | 102 | def _create_change_tracker(self): 103 | self._change_tracker = DocumentChangeTracker() 104 | self._change_tracker.mark_dirty(self.page.tag) 105 | 106 | @property 107 | def running(self): 108 | return (self.server is not None) 109 | 110 | def run(self, open_browser=True, port=None, quiet=False): 111 | '''Displays the GUI, and blocks until Ctrl-C is hit or :func:`stop_running()` is called. 112 | 113 | Raises a :class:`RuntimeError` if the GUI is already running. 114 | 115 | :param bool open_browser: whether to open a browser tab for the GUI 116 | :param int port: which port to start the HTTP server on (``None`` for "don't care") 117 | :param bool quiet: whether to silence normal the server's normal logging to stderr. 118 | ''' 119 | if self.running: 120 | raise RuntimeError('{} is already running'.format(self)) 121 | 122 | self.server = server.make_server_for_gui(self, port=port, quiet=quiet) 123 | 124 | if open_browser: 125 | server.point_browser_to_server(self.server, quiet=quiet) 126 | 127 | try: 128 | self.server.serve_forever() 129 | except KeyboardInterrupt: 130 | self.stop_running() 131 | 132 | def stop_running(self): 133 | '''Stops displaying the GUI. 134 | 135 | Raises a :class:`RuntimeError` if the GUI is not running. 136 | ''' 137 | if not self.running: 138 | raise RuntimeError('{} is not running'.format(self)) 139 | self.server.shutdown() 140 | self.server.socket.close() 141 | self._change_tracker.destroy() 142 | self._create_change_tracker() 143 | self.server = None 144 | -------------------------------------------------------------------------------- /browsergui/_pythoncompatibility.py: -------------------------------------------------------------------------------- 1 | '''Normalizes Python 2/3 changes, e.g. module-renamings. 2 | ''' 3 | 4 | import sys 5 | 6 | if sys.version_info >= (3, 0): 7 | import http.client as http_status_codes 8 | from http.server import HTTPServer, BaseHTTPRequestHandler 9 | from socketserver import ThreadingMixIn as HTTPServerThreadingMixin 10 | else: 11 | import httplib as http_status_codes 12 | from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler 13 | from SocketServer import ThreadingMixIn as HTTPServerThreadingMixin 14 | 15 | if sys.version_info >= (3, 3): 16 | import collections.abc as collections_abc 17 | else: 18 | import collections as collections_abc 19 | 20 | STRING_TYPES = (str,) if sys.version_info >= (3, 0) else (str, unicode) 21 | 22 | import math 23 | if sys.version_info >= (3, 2): 24 | is_real_float = math.isfinite 25 | else: 26 | is_real_float = (lambda x: not (math.isnan(x) or math.isinf(x))) 27 | -------------------------------------------------------------------------------- /browsergui/_server/__init__.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | import os 4 | import cgi 5 | import webbrowser 6 | import json 7 | 8 | from .._pythoncompatibility import ( 9 | http_status_codes as status_codes, 10 | HTTPServer, BaseHTTPRequestHandler, 11 | HTTPServerThreadingMixin as ThreadingMixIn) 12 | 13 | import browsergui 14 | 15 | ROOT_PATH = "/" 16 | PUPPET_PATH = "/puppet.js" 17 | COMMAND_PATH = "/command" 18 | EVENT_PATH = "/event" 19 | 20 | def read_json(request_headers, request_file): 21 | n_bytes = int(request_headers['content-length']) 22 | content_bytes = request_file.read(n_bytes) 23 | content_string = content_bytes.decode('ascii') 24 | return json.loads(content_string) 25 | 26 | class GUIRequestHandler(BaseHTTPRequestHandler): 27 | """Handler for GUI-related requests. 28 | 29 | There are four main types of request: 30 | 31 | - client asking for the root page 32 | - client asking for a static resource required by the root page 33 | - client asking for a lump of JavaScript to execute 34 | - client notifying server of some user interaction in the browser 35 | """ 36 | 37 | # There will be different servers for different GUIs. 38 | # Each server will have its own request handler class. 39 | # Those classes will override `gui` to know what to 40 | # serve up. 41 | gui = None 42 | 43 | def do_GET(self): 44 | if self.path == ROOT_PATH: 45 | self.get_root() 46 | elif self.path == PUPPET_PATH: 47 | self.get_static_file("puppet.js") 48 | elif self.path == COMMAND_PATH: 49 | self.get_command() 50 | 51 | def do_POST(self): 52 | if self.path == EVENT_PATH: 53 | self.post_event() 54 | 55 | def get_static_file(self, relpath): 56 | """Serve a static file to the client. 57 | 58 | :param str relpath: the path of the resource to serve, relative to this file 59 | """ 60 | path = os.path.join(os.path.dirname(__file__), relpath) 61 | if os.path.exists(path): 62 | self.send_response(status_codes.OK) 63 | self.send_no_cache_headers() 64 | self.end_headers() 65 | self.write_bytes(open(path).read()) 66 | else: 67 | self.send_response(status_codes.NOT_FOUND) 68 | 69 | def get_root(self): 70 | """Respond to a request for a new view of the underlying GUI.""" 71 | type(self).gui._change_tracker.destroy() 72 | type(self).gui._create_change_tracker() 73 | self.get_static_file('index.html') 74 | 75 | def get_command(self): 76 | """Respond to a request for a JavaScript command to execute. 77 | 78 | If no commands become available after a few seconds, returns nothing, 79 | just so that if a request is cancelled (e.g. by page-close), 80 | the response thread won't linger too long. 81 | """ 82 | try: 83 | command = type(self).gui._change_tracker.flush_changes() 84 | self.send_response(status_codes.OK) 85 | self.send_no_cache_headers() 86 | self.end_headers() 87 | self.write_bytes(command) 88 | except socket.error: 89 | # The client stopped listening while we were waiting. Oh well! 90 | pass 91 | 92 | def post_event(self): 93 | """Parse the event from the client and notify the GUI.""" 94 | data = read_json(self.headers, self.rfile) 95 | event = browsergui.events.Event.from_dict(data) 96 | self.send_response(status_codes.OK) 97 | self.end_headers() 98 | type(self).gui.dispatch_event(event) 99 | 100 | def send_no_cache_headers(self): 101 | """Add headers to the response telling the client to not cache anything.""" 102 | # Source: http://stackoverflow.com/questions/49547/making-sure-a-web-page-is-not-cached-across-all-browsers 103 | self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate') 104 | self.send_header('Pragma', 'no-cache') 105 | self.send_header('Expires', '0') 106 | 107 | def write_bytes(self, x): 108 | """Write bytes or a string to the client. 109 | 110 | :type x: str or bytes 111 | 112 | TO DO: rename to stop sounding like the argument should be ``bytes``. 113 | """ 114 | if isinstance(x, str): 115 | x = x.encode() 116 | self.wfile.write(x) 117 | 118 | def make_request_handler_class_for_gui(served_gui, quiet=False): 119 | class _AnonymousGUIRequestHandlerSubclass(GUIRequestHandler, object): 120 | gui = served_gui 121 | if quiet: 122 | def noop(*args): pass 123 | _AnonymousGUIRequestHandlerSubclass.log_message = noop 124 | return _AnonymousGUIRequestHandlerSubclass 125 | 126 | class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): 127 | """Server that responds to each request in a separate thread.""" 128 | pass 129 | 130 | def make_server_for_gui(gui, port=None, quiet=False): 131 | 132 | handler_class = make_request_handler_class_for_gui(gui, quiet=quiet) 133 | 134 | if port is None: 135 | port = 0 136 | 137 | return ThreadedHTTPServer(('localhost', port), handler_class) 138 | 139 | def point_browser_to_server(server, quiet=False): 140 | port = server.socket.getsockname()[1] 141 | url = "http://localhost:{}".format(port) 142 | if not quiet: 143 | print('Directing browser to {}'.format(url)) 144 | webbrowser.open(url) 145 | -------------------------------------------------------------------------------- /browsergui/_server/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | browsergui 5 | 6 | 7 | 8 | 13 | 14 | -------------------------------------------------------------------------------- /browsergui/_server/puppet.js: -------------------------------------------------------------------------------- 1 | function obey(command) { 2 | if (command) { 3 | console.log("evaluating:", command); 4 | try { 5 | eval(command); 6 | } catch (e) { 7 | window.close(); 8 | // If a second window is opened, something funny happens and obey() is given some HTML as its command. 9 | // There's a syntax error, and we reach this block of code. 10 | // JS may keep running after calling window.close(), causing another request to /command to be make 11 | // and thereby stealing the command meant for the other window. 12 | // Re-raising the error will stop the request from being made. 13 | throw e; 14 | } 15 | } 16 | } 17 | 18 | function obey_forever() { 19 | var request = new XMLHttpRequest(); 20 | request.open('get', '/command'); 21 | request.onload = function() { 22 | obey(this.responseText); 23 | obey_forever(); 24 | } 25 | request.send(); 26 | } 27 | 28 | obey_forever(); 29 | 30 | function notify_server(event) { 31 | console.log('notifying server:', event); 32 | var request = new XMLHttpRequest(); 33 | request.open('post', '/event'); 34 | request.send(JSON.stringify(event)); 35 | } 36 | -------------------------------------------------------------------------------- /browsergui/elements/__init__.py: -------------------------------------------------------------------------------- 1 | '''Defines many types of GUI elements. 2 | 3 | Element Index 4 | ============= 5 | 6 | Basic 7 | ----- 8 | 9 | Simple, static, atomic GUI elements. 10 | 11 | .. autosummary:: 12 | 13 | Text 14 | Paragraph 15 | CodeSnippet 16 | CodeBlock 17 | EmphasizedText 18 | Link 19 | 20 | Image 21 | 22 | Input 23 | ----- 24 | 25 | Elements that gather input from the user. 26 | 27 | .. autosummary:: 28 | 29 | Button 30 | TextField 31 | BigTextField 32 | Dropdown 33 | NumberField 34 | ColorField 35 | DateField 36 | FloatSlider 37 | IntegerSlider 38 | 39 | Layout 40 | ------ 41 | 42 | Elements that arrange their children in certain ways. 43 | 44 | .. autosummary:: 45 | 46 | Container 47 | Viewport 48 | List 49 | Grid 50 | 51 | Element Class 52 | ============= 53 | 54 | The most important thing defined here, from which all other things inherit, is :class:`Element`. 55 | 56 | .. autoclass:: Element 57 | :members: 58 | :inherited-members: 59 | 60 | 61 | Full Subclass Documentation 62 | =========================== 63 | 64 | 65 | .. autoclass:: Text 66 | :members: 67 | .. autoclass:: Paragraph 68 | :members: 69 | .. autoclass:: CodeSnippet 70 | :members: 71 | .. autoclass:: CodeBlock 72 | :members: 73 | .. autoclass:: EmphasizedText 74 | :members: 75 | .. autoclass:: Link 76 | :members: 77 | 78 | .. autoclass:: Image 79 | :members: 80 | 81 | 82 | .. autoclass:: ValuedElement 83 | :members: 84 | 85 | .. autoclass:: Button 86 | :members: 87 | .. autoclass:: TextField 88 | :members: 89 | .. autoclass:: BigTextField 90 | :members: 91 | .. autoclass:: Dropdown 92 | :members: 93 | .. autoclass:: NumberField 94 | :members: 95 | .. autoclass:: ColorField 96 | :members: 97 | .. autoclass:: DateField 98 | :members: 99 | .. autoclass:: Slider 100 | :members: 101 | .. autoclass:: FloatSlider 102 | :members: 103 | .. autoclass:: IntegerSlider 104 | :members: 105 | 106 | 107 | .. autoclass:: Container 108 | :members: 109 | .. autoclass:: Viewport 110 | :members: 111 | .. autoclass:: List 112 | :members: 113 | .. autoclass:: Grid 114 | :members: 115 | 116 | ''' 117 | 118 | import collections 119 | import json 120 | import xml.dom.minidom 121 | import xml.parsers.expat 122 | import logging 123 | 124 | from ._xmltagshield import XMLTagShield 125 | from ._callbacksetter import CallbackSetter 126 | from ._styler import Styler 127 | 128 | 129 | class Element(XMLTagShield): 130 | """A conceptual GUI element, like a button or a table.""" 131 | 132 | def __init__(self, css={}, callbacks={}, **kwargs): 133 | #: a dict-like object mapping :class:`Event` subclasses to functions that should be called when the corresponding event occurs. 134 | #: 135 | #: >>> my_element.callbacks[Click] = (lambda event: print('Click:', event)) 136 | self.callbacks = CallbackSetter(element=self) 137 | 138 | #: a dict-like object mapping strings (CSS properties) to strings (CSS values). 139 | #: 140 | #: >>> my_text.css['color'] = 'red' 141 | self.css = Styler(element=self) 142 | 143 | super(Element, self).__init__(**kwargs) 144 | 145 | for key, value in css.items(): 146 | self.css[key] = value 147 | for key, value in callbacks.items(): 148 | self.callbacks[key] = value 149 | 150 | def __str__(self): 151 | return "(#{})".format(self.id) 152 | 153 | def __repr__(self): 154 | return "{cls}(id={id!r})".format(cls=type(self).__name__, id=self.id) 155 | 156 | def handle_event(self, event): 157 | if type(event) in self.callbacks: 158 | self.callbacks[type(event)](event) 159 | 160 | # Convenience functions accessing the GUI 161 | 162 | @property 163 | def gui(self): 164 | """The GUI the element belongs to, or None if there is none.""" 165 | return (None if self.orphaned else self.parent.gui) 166 | 167 | def mark_dirty(self): 168 | '''Marks the element as needing redrawing.''' 169 | if self.gui is not None: 170 | self.gui._change_tracker.mark_dirty(self.tag) 171 | 172 | class NotUniversallySupportedElement(Element): 173 | '''Mixin for elements that aren't supported in all major browsers. 174 | 175 | Prints a warning upon instantiation, i.e. if MyElement subclasses NotUniversallySupportedElement, then ``MyElement()`` will log a warning. 176 | 177 | To avoid the warning, either set ``MyElement.warn_about_potential_browser_incompatibility = False`` or pass the keyword argument ``warn_about_potential_browser_incompatibility=False`` into the constructor. (Yes, it's intentionally verbose. This package is meant to be super-portable and work the same way for everyone.) 178 | ''' 179 | warn_about_potential_browser_incompatibility = True 180 | def __init__(self, **kwargs): 181 | warn = kwargs.pop('warn_about_potential_browser_incompatibility', self.warn_about_potential_browser_incompatibility) 182 | super(NotUniversallySupportedElement, self).__init__(**kwargs) 183 | if warn: 184 | logging.warning('{} not supported in all major browsers'.format(type(self).__name__)) 185 | 186 | from ._basic import * 187 | from ._input import * 188 | from ._layout import * 189 | -------------------------------------------------------------------------------- /browsergui/elements/_basic/__init__.py: -------------------------------------------------------------------------------- 1 | '''Simple, static, atomic GUI elements. 2 | 3 | .. autosummary:: 4 | 5 | Text 6 | Link 7 | Paragraph 8 | CodeSnippet 9 | CodeBlock 10 | EmphasizedText 11 | Image 12 | ''' 13 | 14 | from ._text import * 15 | from ._image import Image 16 | -------------------------------------------------------------------------------- /browsergui/elements/_basic/_image.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import re 3 | import os.path 4 | from .. import Element 5 | 6 | class Image(Element): 7 | def __init__(self, filename, format=None, **kwargs): 8 | """ 9 | :param str filename: the name of the file to read image data from 10 | :param str format: the image's file format, or None to guess from filename 11 | """ 12 | super(Image, self).__init__(tag_name='img', **kwargs) 13 | 14 | self.filename = filename 15 | if format is None: 16 | _, format = os.path.splitext(filename) 17 | format = format[1:] 18 | if not format: 19 | raise ValueError('no format given and none guessable from filename '+filename) 20 | self.format = format 21 | 22 | self.reload_data() 23 | 24 | @property 25 | def data(self): 26 | '''The raw image data, as bytes.''' 27 | src = self.tag.getAttribute('src') 28 | base64_data = re.search(r'base64,(.*)', src).group(1) 29 | return base64.b64decode(base64_data) 30 | 31 | def reload_data(self): 32 | """Reads image contents from disk, in case they've changed.""" 33 | with open(self.filename, 'rb') as f: 34 | data = f.read() 35 | self.tag.setAttribute('src', 'data:image/{format};base64,{data}'.format(format=self.format, data=base64.b64encode(data).decode('ascii'))) 36 | self.mark_dirty() 37 | -------------------------------------------------------------------------------- /browsergui/elements/_basic/_text.py: -------------------------------------------------------------------------------- 1 | import json 2 | import xml.dom.minidom 3 | from .. import Element 4 | 5 | class Text(Element): 6 | """A piece of text with no structure inside it. 7 | 8 | The currently displayed string may be accessed or changed via the `text` attribute. 9 | 10 | If you want to be fancy, a Text instance can represent any HTML tag that contains only plain text. For instance, :class:`Button` subclasses Text, even though it's not just a plain piece of text. 11 | 12 | :param str text: the text to display 13 | """ 14 | def __init__(self, text, tag_name="span", **kwargs): 15 | if not isinstance(text, str): 16 | raise TypeError(text) 17 | super(Text, self).__init__(tag_name=tag_name, **kwargs) 18 | self._text = xml.dom.minidom.Text() 19 | self._text.data = text 20 | self.tag.appendChild(self._text) 21 | 22 | @property 23 | def text(self): 24 | '''The string to be displayed.''' 25 | return self._text.data 26 | @text.setter 27 | def text(self, value): 28 | if self.text == value: 29 | return 30 | 31 | self._text.data = value 32 | self.mark_dirty() 33 | 34 | class CodeSnippet(Text): 35 | """Inline text representing ``computer code``.""" 36 | def __init__(self, text, **kwargs): 37 | super(CodeSnippet, self).__init__(text, tag_name="code", css={'white-space': 'pre'}, **kwargs) 38 | class Paragraph(Text): 39 | """A block of plain text.""" 40 | def __init__(self, text, **kwargs): 41 | super(Paragraph, self).__init__(text, tag_name="p", **kwargs) 42 | class CodeBlock(Text): 43 | """A block of ``computer code``.""" 44 | def __init__(self, text, **kwargs): 45 | super(CodeBlock, self).__init__(text, tag_name="pre", **kwargs) 46 | class EmphasizedText(Text): 47 | """Text that should have **emphasis** on it.""" 48 | def __init__(self, text, **kwargs): 49 | super(EmphasizedText, self).__init__(text, tag_name="strong", **kwargs) 50 | 51 | class Link(Text): 52 | """A `hyperlink `_.""" 53 | def __init__(self, text, url, **kwargs): 54 | super(Link, self).__init__(text, tag_name="a", **kwargs) 55 | self.tag.setAttribute('target', '_blank') 56 | self.url = url 57 | 58 | @property 59 | def url(self): 60 | '''The URL to which the link points.''' 61 | return self.tag.getAttribute('href') 62 | @url.setter 63 | def url(self, value): 64 | self.tag.setAttribute('href', value) 65 | self.mark_dirty() 66 | -------------------------------------------------------------------------------- /browsergui/elements/_callbacksetter.py: -------------------------------------------------------------------------------- 1 | from .._pythoncompatibility import collections_abc 2 | 3 | class CallbackSetter(collections_abc.MutableMapping): 4 | def __init__(self, element, **kwargs): 5 | self.callbacks = {} 6 | self.element = element 7 | super(CallbackSetter, self).__init__(**kwargs) 8 | 9 | def __getitem__(self, key): 10 | return self.callbacks[key] 11 | 12 | def __setitem__(self, key, value): 13 | self.callbacks[key] = value 14 | key.enable_server_notification(self.element.tag) 15 | self.element.mark_dirty() 16 | 17 | def __delitem__(self, key): 18 | del self.callbacks[key] 19 | key.disable_server_notification(self.element.tag) 20 | self.element.mark_dirty() 21 | 22 | def __iter__(self): 23 | return iter(self.callbacks) 24 | 25 | def __len__(self): 26 | return len(self.callbacks) 27 | -------------------------------------------------------------------------------- /browsergui/elements/_input/__init__.py: -------------------------------------------------------------------------------- 1 | '''Elements that gather input from the user. 2 | 3 | .. autosummary:: 4 | 5 | Button 6 | TextField 7 | NumberField 8 | Dropdown 9 | ColorField 10 | DateField 11 | Slider 12 | FloatSlider 13 | IntegerSlider 14 | ValuedElement 15 | ''' 16 | 17 | from ._button import Button 18 | from ._inputfield import InputField 19 | from ._textfield import TextField 20 | from ._bigtextfield import BigTextField 21 | from ._dropdown import Dropdown 22 | from ._numberfield import NumberField 23 | from ._colorfield import ColorField 24 | from ._datefield import DateField 25 | from ._slider import Slider, FloatSlider, IntegerSlider 26 | from ._valuedelement import ValuedElement 27 | -------------------------------------------------------------------------------- /browsergui/elements/_input/_bigtextfield.py: -------------------------------------------------------------------------------- 1 | from ._valuedelement import ValuedElement 2 | from ..._pythoncompatibility import STRING_TYPES 3 | 4 | class BigTextField(ValuedElement): 5 | '''A multi-line text field. 6 | ''' 7 | def __init__(self, value='', **kwargs): 8 | super(BigTextField, self).__init__(tag_name='textarea', value=value, **kwargs) 9 | 10 | def _get_value_from_tag(self): 11 | return self.tag.childNodes[0].data if self.tag.childNodes else '' 12 | 13 | def _set_value_in_tag(self, value): 14 | if not isinstance(value, STRING_TYPES): 15 | raise TypeError('expected str, got {}'.format(type(value).__name__)) 16 | if not self.tag.childNodes: 17 | self.tag.appendChild(self.tag.ownerDocument.createTextNode('')) 18 | self.tag.childNodes[0].data = value 19 | 20 | def _get_value_from_event(self, event): 21 | return event.value 22 | -------------------------------------------------------------------------------- /browsergui/elements/_input/_button.py: -------------------------------------------------------------------------------- 1 | from .._basic import Text 2 | from ...events import Click 3 | 4 | class Button(Text): 5 | """A simple button that does something when clicked. 6 | 7 | The ``text`` and ``callback`` fields may be safely set at any time. 8 | 9 | :param str text: the label of the button 10 | :param callback: the function to be called 11 | :type callback: function of zero arguments, or None 12 | """ 13 | def __init__(self, text="Click!", callback=None, **kwargs): 14 | if not isinstance(text, str): 15 | raise TypeError(text) 16 | super(Button, self).__init__(text, tag_name="button", **kwargs) 17 | self.callback = callback 18 | self.callbacks[Click] = self._handle_click 19 | 20 | def _handle_click(self, event): 21 | if self.callback is not None: 22 | self.callback() 23 | 24 | def def_callback(self, f): 25 | '''Decorator to set the Button's ``callback``. 26 | 27 | >>> button = Button() 28 | >>> @button.def_callback 29 | ... def _(): 30 | ... print("Button was clicked!") 31 | ''' 32 | self.callback = f 33 | return f 34 | -------------------------------------------------------------------------------- /browsergui/elements/_input/_colorfield.py: -------------------------------------------------------------------------------- 1 | import types 2 | import numbers 3 | import re 4 | from .. import NotUniversallySupportedElement 5 | from ._inputfield import InputField 6 | from ..._pythoncompatibility import collections_abc 7 | 8 | class ColorField(InputField, NotUniversallySupportedElement): 9 | '''A field where the user can input a RGB color. 10 | 11 | Inherits from :class:`ValuedElement`, meaning it has a ``value``, ``placeholder``, and ``change_callback``. 12 | ''' 13 | def __init__(self, value=(0, 0, 0), placeholder='#xxxxxx', **kwargs): 14 | super(ColorField, self).__init__(value=value, placeholder=placeholder, **kwargs) 15 | self.tag.setAttribute('type', 'color') 16 | 17 | def _value_to_xml_string(self, value): 18 | if isinstance(value, collections_abc.Sequence): 19 | r, g, b = value 20 | for x in (r, g, b): 21 | if not isinstance(x, numbers.Integral): 22 | raise TypeError('color triplet must consist of integers, not {}'.format(type(x).__name__)) 23 | if not 0 <= x <= 255: 24 | raise ValueError('color triplet values must be between 0 and 255 (got {})'.format(repr(x))) 25 | return '#{:02x}{:02x}{:02x}'.format(r, g, b) 26 | raise TypeError("expected sequence, got {}".format(type(value).__name__)) 27 | 28 | def _value_from_xml_string(self, string): 29 | if re.match(r'#[0-9a-f]{6}', string): 30 | return tuple(int(hex_repr, 16) for hex_repr in (string[1:3], string[3:5], string[5:7])) 31 | raise ValueError('malformed hex color: {!r}'.format(string)) 32 | -------------------------------------------------------------------------------- /browsergui/elements/_input/_datefield.py: -------------------------------------------------------------------------------- 1 | import re 2 | import datetime 3 | from .. import NotUniversallySupportedElement 4 | from ._inputfield import InputField 5 | 6 | CLIENT_DATE_FORMAT = '%Y-%m-%d' 7 | 8 | class DateField(InputField, NotUniversallySupportedElement): 9 | '''A field where the user can input a date. 10 | 11 | Inherits from :class:`ValuedElement`, meaning it has a ``value``, ``placeholder``, and ``change_callback``. 12 | ''' 13 | def __init__(self, placeholder='yyyy-mm-dd', **kwargs): 14 | super(DateField, self).__init__(placeholder=placeholder, **kwargs) 15 | self.tag.setAttribute('type', 'date') 16 | 17 | def _value_to_xml_string(self, value): 18 | if value is None: 19 | return '' 20 | elif hasattr(value, 'strftime'): 21 | return value.strftime(CLIENT_DATE_FORMAT) 22 | raise TypeError('expected value to be datelike (have `strftime` method), got {}'.format(type(value).__name__)) 23 | 24 | def _value_from_xml_string(self, string): 25 | if not string: 26 | return None 27 | 28 | # Kludge incoming. 29 | # Some browsers don't support date-input fields, and display them as text instead. 30 | # We still get notified about changes, but the reported value might be malformed. 31 | # In this case, we might get '2013-3-1' as a value, which I don't think should be accepted. 32 | # However, `date.strptime` will happily parse it. So we have to throw an error here. 33 | if not re.match(r'\d{4}-\d{2}-\d{2}', string): 34 | raise ValueError('malformed date: {!r}'.format(string)) 35 | return datetime.datetime.strptime(string, CLIENT_DATE_FORMAT).date() 36 | -------------------------------------------------------------------------------- /browsergui/elements/_input/_dropdown.py: -------------------------------------------------------------------------------- 1 | from ._valuedelement import ValuedElement 2 | from ...events import Change 3 | from ..._pythoncompatibility import collections_abc, STRING_TYPES 4 | 5 | class Dropdown(ValuedElement, collections_abc.MutableSequence): 6 | '''A dropdown-selector for a set of options (strings). 7 | 8 | A Dropdown is a :class:`ValuedElement`, meaning it has a ``value``, ``placeholder``, and ``change_callback``. It's also a `mutable sequence`_, meaning most things you can do to lists (append, get/set/delitem), you can do to Dropdowns too. 9 | 10 | .. _mutable sequence: https://docs.python.org/2/library/collections.html#collections-abstract-base-classes 11 | ''' 12 | 13 | def __init__(self, options, **kwargs): 14 | super(Dropdown, self).__init__(tag_name='select', input_event_type=Change, **kwargs) 15 | 16 | options = list(options) 17 | if not options: 18 | raise ValueError('Dropdown must not be empty') 19 | 20 | for option in options: 21 | self.append(option) 22 | 23 | self._set_value_in_tag(options[0]) 24 | 25 | def _get_value_from_tag(self): 26 | for option_tag in self.tag.childNodes: 27 | if option_tag.getAttribute('selected') == 'true': 28 | return option_tag.childNodes[0].data 29 | assert False, 'No option in the dropdown seems to be selected. How did this happen?' 30 | 31 | def _set_value_in_tag(self, value): 32 | if not self.tag.childNodes: 33 | # Kludge here. The value gets set during super().__init__(), but at that point 34 | # we haven't initialized the tag's children yet. Just accept it, because we're 35 | # going to set the value later in the initializer anyway. 36 | return 37 | 38 | if not isinstance(value, STRING_TYPES): 39 | raise TypeError('expected str, got {}'.format(type(value).__name__)) 40 | 41 | if value not in self: 42 | raise ValueError('value not in options: {!r}'.format(value)) 43 | 44 | for child in self.tag.childNodes: 45 | if 'selected' in child.attributes.keys(): 46 | child.removeAttribute('selected') 47 | if child.childNodes[0].data == value: 48 | child.setAttribute('selected', 'true') 49 | 50 | def _get_value_from_event(self, event): 51 | return event.value 52 | 53 | # MutableSequence implementation 54 | 55 | def __getitem__(self, index): 56 | if isinstance(index, slice): 57 | return [tag.childNodes[0].data for tag in self.tag.childNodes[index]] 58 | else: 59 | return self.tag.childNodes[index].childNodes[0].data 60 | 61 | def __setitem__(self, index, value): 62 | if not isinstance(value, str): 63 | raise TypeError("Dropdown options must be strings") 64 | if isinstance(index, slice): 65 | raise NotImplementedError("slice assignment to Dropdowns not yet supported") 66 | 67 | option_tag = self.tag.childNodes[index] 68 | option_tag.setAttribute('value', value) 69 | option_tag.childNodes[0].data = value 70 | self.mark_dirty() 71 | 72 | def __delitem__(self, index): 73 | if isinstance(index, slice): 74 | raise NotImplementedError("slice deletion from Dropdowns not yet supported") 75 | if len(self) == 1 and int(index) in (0, -1): 76 | raise ValueError('Dropdown must not be made empty') 77 | 78 | option_tag = self.tag.childNodes[index] 79 | self.tag.removeChild(option_tag) 80 | if option_tag.getAttribute('selected') == 'true': 81 | self.value = self[0] 82 | self.mark_dirty() 83 | 84 | def __len__(self): 85 | return len(self.tag.childNodes) 86 | 87 | def insert(self, index, option): 88 | if not isinstance(option, str): 89 | raise TypeError("Dropdown options must be strings") 90 | 91 | option_tag = self.tag.ownerDocument.createElement('option') 92 | option_tag.setAttribute('value', option) 93 | option_tag.appendChild(self.tag.ownerDocument.createTextNode(option)) 94 | next_option_tag = self.tag.childNodes[index] if index < len(self.tag.childNodes) else None 95 | self.tag.insertBefore(option_tag, next_option_tag) 96 | self.mark_dirty() 97 | 98 | def def_change_callback(self, f): 99 | '''Decorator to set the Dropdown's ``change_callback``''' 100 | self.change_callback = f 101 | return f 102 | -------------------------------------------------------------------------------- /browsergui/elements/_input/_inputfield.py: -------------------------------------------------------------------------------- 1 | from .. import Element 2 | from ._valuedelement import ValuedElement 3 | 4 | class InputField(ValuedElement): 5 | def __init__(self, tag_name='input', **kwargs): 6 | super(InputField, self).__init__(tag_name=tag_name, **kwargs) 7 | 8 | def _get_value_from_tag(self): 9 | return self._value_from_xml_string(self.tag.getAttribute('value')) 10 | 11 | def _set_value_in_tag(self, value): 12 | self.tag.setAttribute('value', self._value_to_xml_string(value)) 13 | 14 | def _get_value_from_event(self, event): 15 | return self._value_from_xml_string(event.value) 16 | -------------------------------------------------------------------------------- /browsergui/elements/_input/_numberfield.py: -------------------------------------------------------------------------------- 1 | import numbers 2 | from ._inputfield import InputField 3 | 4 | class NumberField(InputField): 5 | '''A field where the user can input a real number. 6 | 7 | Inherits from :class:`ValuedElement`, meaning it has a ``value``, ``placeholder``, and ``change_callback``. 8 | ''' 9 | 10 | def __init__(self, value=None, **kwargs): 11 | if value is not None: 12 | value = float(value) 13 | super(NumberField, self).__init__(value=value, **kwargs) 14 | self.tag.setAttribute('type', 'number') 15 | 16 | def _value_to_xml_string(self, value): 17 | if value is None: 18 | return '' 19 | elif isinstance(value, numbers.Real): 20 | return repr(value) 21 | raise TypeError('expected real-number value (or None), got {}'.format(type(value).__name__)) 22 | 23 | def _value_from_xml_string(self, string): 24 | return float(string) 25 | -------------------------------------------------------------------------------- /browsergui/elements/_input/_slider.py: -------------------------------------------------------------------------------- 1 | import numbers 2 | import datetime 3 | from ._inputfield import InputField 4 | from ..._pythoncompatibility import is_real_float 5 | 6 | class Slider(InputField): 7 | '''A draggable slider. 8 | 9 | Any attempted assignment to ``min``, ``max``, or ``value`` that would violate ``min <= value <= max`` will fail and instead raise a ``ValueError``. 10 | 11 | Don't instantiate; you probably want :class:`FloatSlider` or :class:`IntegerSlider`. You can also define your own subclasses for other data types: see `this tutorial`_. 12 | 13 | .. _this tutorial: https://github.com/speezepearson/browsergui/wiki/Make-Your-Own-Sliders 14 | 15 | :param min: the smallest value the slider can accept. Writable. 16 | :param max: the largest value the slider can accept. Writable. 17 | ''' 18 | 19 | DISCRETE = False 20 | 21 | def __init__(self, min, max, value=None, **kwargs): 22 | if value is None: 23 | value = type(self).value_from_number((type(self).value_to_number(min)+type(self).value_to_number(max)) / 2.0) 24 | 25 | self._min = min 26 | self._max = max 27 | super(Slider, self).__init__(value=value, **kwargs) 28 | self.max = max 29 | self.min = min 30 | 31 | self.tag.setAttribute('type', 'range') 32 | self.tag.setAttribute('step', str(1 if type(self).DISCRETE else (self.value_to_number(max)-self.value_to_number(min))/1000.)) 33 | 34 | @property 35 | def min(self): 36 | return self._min 37 | @min.setter 38 | def min(self, new_min): 39 | new_min_number = self.value_to_number(new_min) 40 | if new_min > self.value: 41 | raise ValueError("can't set min to above current value") 42 | self._min = new_min 43 | self.tag.setAttribute('min', str(new_min_number)) 44 | self.mark_dirty() 45 | 46 | @property 47 | def max(self): 48 | return self._max 49 | @max.setter 50 | def max(self, new_max): 51 | new_max_number = self.value_to_number(new_max) 52 | if new_max < self.value: 53 | raise ValueError("can't set max to below current value") 54 | self._max = new_max 55 | self.tag.setAttribute('max', str(new_max_number)) 56 | self.mark_dirty() 57 | 58 | def _value_to_xml_string(self, value): 59 | self.value_to_number(value) 60 | if (value < self.min) or (value > self.max): 61 | raise ValueError('{} not between {} and {}'.format(value, self.min, self.max)) 62 | return repr(self.value_to_number(value)) 63 | 64 | def _value_from_xml_string(self, value): 65 | return self.value_from_number(float(value)) 66 | 67 | def value_to_number(self, x): 68 | raise NotImplementedError('{} has not implemented value_to_number'.format(type(self).__name__)) 69 | 70 | def value_from_number(self, x): 71 | raise NotImplementedError('{} has not implemented value_from_number'.format(type(self).__name__)) 72 | 73 | class FloatSlider(Slider): 74 | '''A type of :class:`Slider` that accepts real-valued values/bounds (e.g. float, int, ``fractions.Fraction``). 75 | 76 | When the user drags the slider, the value is set to a float. 77 | ''' 78 | 79 | @staticmethod 80 | def value_to_number(x): 81 | if not isinstance(x, numbers.Real): 82 | raise TypeError('expected real number, got {}'.format(type(x).__name__)) 83 | result = float(x) 84 | if not is_real_float(result): 85 | raise TypeError('expected finite value, got {}'.format(result)) 86 | return result 87 | 88 | @staticmethod 89 | def value_from_number(x): 90 | return x 91 | 92 | class IntegerSlider(Slider): 93 | '''A type of :class:`Slider` that accepts only integer values/bounds.''' 94 | 95 | DISCRETE = True 96 | 97 | @staticmethod 98 | def value_to_number(x): 99 | if not isinstance(x, int): 100 | raise TypeError('expected int, got {}'.format(type(x).__name__)) 101 | return x 102 | 103 | @staticmethod 104 | def value_from_number(x): 105 | return int(x) 106 | -------------------------------------------------------------------------------- /browsergui/elements/_input/_textfield.py: -------------------------------------------------------------------------------- 1 | from ._inputfield import InputField 2 | from ..._pythoncompatibility import STRING_TYPES 3 | 4 | class TextField(InputField): 5 | '''A single-line text input field. 6 | 7 | Inherits from :class:`ValuedElement`, meaning it has a ``value``, ``placeholder``, and ``change_callback``. 8 | 9 | For multi-line text input, see :class:`BigTextField`, which has a very similar interface. 10 | ''' 11 | def __init__(self, value='', **kwargs): 12 | super(TextField, self).__init__(value=value, **kwargs) 13 | self.tag.setAttribute('type', 'text') 14 | 15 | def _value_to_xml_string(self, value): 16 | if isinstance(value, STRING_TYPES): 17 | return value 18 | raise TypeError('expected value to be str (or None), got {}'.format(type(value).__name__)) 19 | 20 | def _value_from_xml_string(self, string): 21 | return string 22 | -------------------------------------------------------------------------------- /browsergui/elements/_input/_valuedelement.py: -------------------------------------------------------------------------------- 1 | from .. import Element 2 | from ...events import Input 3 | 4 | class ValuedElement(Element): 5 | '''An abstract class for elements that conceptually contain a value (string, number, color...) 6 | 7 | Many kinds of input (e.g. :class:`TextField`, :class:`Slider`, :class:`DateField`) inherit from this. 8 | 9 | :param change_callback: function to be called whenever the input's value changes 10 | :type change_callback: function of zero arguments 11 | ''' 12 | def __init__(self, value=None, placeholder=None, change_callback=None, input_event_type=Input, **kwargs): 13 | super(ValuedElement, self).__init__(**kwargs) 14 | self.placeholder = placeholder 15 | 16 | # "self.value = ..." accesses change_callback, but calls it if it's not None. 17 | # It'd be unintuitive to call the change_callback upon instantiation, but we 18 | # need the change_callback attribute to avoid an AttributeError; so we set it 19 | # to None for just a minute. 20 | self.change_callback = None 21 | self.value = value 22 | self.change_callback = change_callback 23 | 24 | self.callbacks[input_event_type] = self._handle_input_event 25 | 26 | @property 27 | def value(self): 28 | '''The value contained by the element.''' 29 | return self._get_value_from_tag() 30 | @value.setter 31 | def value(self, value): 32 | self._set_value_in_tag(value) 33 | if self.change_callback is not None: 34 | self.change_callback() 35 | self.mark_dirty() 36 | 37 | @property 38 | def placeholder(self): 39 | '''The placeholder text that should be shown when the value is empty. 40 | 41 | Applicable to many, but not all, kinds of ValuedElement.''' 42 | return self.tag.getAttribute('placeholder') 43 | @placeholder.setter 44 | def placeholder(self, placeholder): 45 | if placeholder is None: 46 | if 'placeholder' in self.tag.attributes.keys(): 47 | self.tag.removeAttribute('placeholder') 48 | else: 49 | self.tag.setAttribute('placeholder', placeholder) 50 | self.mark_dirty() 51 | 52 | def _handle_input_event(self, event): 53 | try: 54 | value = self._get_value_from_event(event) 55 | except ValueError: 56 | # We got some garbage value from the browser for some reason. Abort! 57 | # (This might happen if, say, the browser doesn't support 58 | # and so presents it as a text field where the user can type whatever they want.) 59 | print("{} was unable to read a valid value from {}".format(self, event)) 60 | return 61 | self._set_value_in_tag(value) 62 | if self.change_callback is not None: 63 | self.change_callback() 64 | 65 | def _get_value_from_tag(self): 66 | raise NotImplementedError('{} does not define _get_value_from_tag'.format(type(self).__name__)) 67 | def _set_value_in_tag(self, value): 68 | raise NotImplementedError('{} does not define _set_value_in_tag'.format(type(self).__name__)) 69 | 70 | def _get_value_from_event(self, event): 71 | raise NotImplementedError('{} does not define _get_value_from_event'.format(type(self).__name__)) 72 | 73 | def def_change_callback(self, f): 74 | '''Decorator to set the InputField's ``change_callback``. 75 | 76 | >>> text_field = TextField() 77 | >>> @text_field.def_change_callback 78 | ... def _(): 79 | ... print("Current value: " + text_field.value) 80 | ''' 81 | self.change_callback = f 82 | return f 83 | -------------------------------------------------------------------------------- /browsergui/elements/_layout/__init__.py: -------------------------------------------------------------------------------- 1 | '''Elements that arrange their children in certain ways. 2 | 3 | .. autosummary:: 4 | 5 | Container 6 | List 7 | Grid 8 | Viewport 9 | 10 | ''' 11 | 12 | from ._container import Container 13 | from ._grid import Grid 14 | from ._list import List 15 | from ._viewport import Viewport 16 | -------------------------------------------------------------------------------- /browsergui/elements/_layout/_container.py: -------------------------------------------------------------------------------- 1 | from .. import Element 2 | from ..._pythoncompatibility import collections_abc 3 | 4 | class Container(Element, collections_abc.MutableSequence): 5 | """Contains other elements without any fancy layout stuff. 6 | 7 | Useful when you want to put several elements in a place that can only hold one element (e.g. if you want a :class:`List` item consisting of multiple elements, or if you want to put multiple elements in a :class:`Grid` cell). 8 | 9 | Subclasses `MutableSequence`_, which means that most operations you'd do to a `list` (e.g. insert, remove, get/set/delitem), you can do to a Container as well. 10 | 11 | .. _MutableSequence: https://docs.python.org/2/library/collections.html#collections-abstract-base-classes 12 | 13 | :param children: Elements the Container should contain 14 | """ 15 | def __init__(self, *children, **kwargs): 16 | kwargs.setdefault('tag_name', 'div') 17 | super(Container, self).__init__(**kwargs) 18 | for child in children: 19 | self.append(child) 20 | 21 | def __getitem__(self, index): 22 | return self.children[index] 23 | 24 | def __setitem__(self, index, child): 25 | if not isinstance(child, Element): 26 | raise TypeError('expected Element, got {}'.format(type(child).__name__)) 27 | del self[index] 28 | self.insert(index, child) 29 | 30 | def __delitem__(self, index): 31 | self.tag.removeChild(self.tag.childNodes[index]) 32 | self.mark_dirty() 33 | 34 | def __len__(self): 35 | return len(self.children) 36 | 37 | def insert(self, index, child): 38 | if not isinstance(child, Element): 39 | raise TypeError('expected Element, got {}'.format(type(child).__name__)) 40 | try: 41 | next_child = self.tag.childNodes[index] 42 | except IndexError: 43 | next_child = None 44 | self.tag.insertBefore(child.tag, next_child) 45 | self.mark_dirty() 46 | -------------------------------------------------------------------------------- /browsergui/elements/_layout/_grid.py: -------------------------------------------------------------------------------- 1 | import numbers 2 | from .. import Element 3 | 4 | def empty_grid(n_rows, n_columns): 5 | if n_rows < 0 or not isinstance(n_rows, numbers.Integral): 6 | raise TypeError('number of rows must be non-negative integer') 7 | if n_columns < 0 or not isinstance(n_columns, numbers.Integral): 8 | raise TypeError('number of columns must be non-negative integer') 9 | return [[None for j in range(n_columns)] for i in range(n_rows)] 10 | 11 | def smallest_fitting_dimensions(cells): 12 | return (len(cells), (max(len(row) for row in cells) if cells else 0)) 13 | 14 | class Grid(Element): 15 | """A two-dimensional grid of elements. 16 | 17 | A grid's number of rows and columns are given by `n_rows` and `n_columns`. 18 | Those properties may also be set, to change the number of rows and columns. 19 | 20 | Grids are indexable by pairs of non-negative integers, e.g. 21 | 22 | >>> my_grid[0, 0] 23 | >>> my_grid[3, 2] = Text('hi') 24 | >>> my_grid[1, 2] = None 25 | >>> del my_grid[3, 3] 26 | """ 27 | def __init__(self, cells=(), n_rows=None, n_columns=None, **kwargs): 28 | super(Grid, self).__init__(tag_name='table', **kwargs) 29 | self.css['border-spacing'] = '0' 30 | self.css['border-collapse'] = 'collapse' 31 | 32 | if not all(all(isinstance(x, Element) or x is None for x in row) for row in cells): 33 | raise TypeError('cell contents must be Elements') 34 | 35 | if not (cells or (n_rows is not None and n_columns is not None)): 36 | raise ValueError("can't guess dimensions for Grid") 37 | 38 | self._n_rows = 0 39 | self._n_columns = 0 40 | self._cells = [] 41 | if cells: 42 | self.n_rows, self.n_columns = smallest_fitting_dimensions(cells) 43 | else: 44 | self.n_rows, self.n_columns = n_rows, n_columns 45 | 46 | for (i, row) in enumerate(cells): 47 | for (j, cell) in enumerate(row): 48 | if cell is not None: 49 | self[i,j] = cell 50 | 51 | @property 52 | def n_rows(self): 53 | return self._n_rows 54 | @n_rows.setter 55 | def n_rows(self, value): 56 | if value < 0 or not isinstance(value, numbers.Integral): 57 | raise TypeError('number of rows must be non-negative integer') 58 | if value < self.n_rows: 59 | for i in range(value, self.n_rows): 60 | for j in range(self.n_columns): 61 | if self[i,j] is not None: 62 | del self[i,j] 63 | for tr in self.tag.childNodes[value:]: 64 | self.tag.removeChild(tr) 65 | self._cells = self._cells[:value] 66 | else: 67 | while len(self._cells) < value: 68 | self._cells.append([None]*self.n_columns) 69 | tr = self._new_tr() 70 | for j in range(self.n_columns): 71 | td = self._new_td() 72 | tr.appendChild(td) 73 | self.tag.appendChild(tr) 74 | 75 | self._n_rows = value 76 | self.mark_dirty() 77 | 78 | @property 79 | def n_columns(self): 80 | return self._n_columns 81 | @n_columns.setter 82 | def n_columns(self, value): 83 | if value < 0 or not isinstance(value, numbers.Integral): 84 | raise TypeError('number of columns must be non-negative integer') 85 | if value < self.n_columns: 86 | for i in range(self.n_rows): 87 | for j in range(value, self.n_columns): 88 | if self[i,j] is not None: 89 | del self[i,j] 90 | self._cells[i] = self._cells[i][:value] 91 | for td in self.tag.childNodes[i].childNodes[value:]: 92 | self.tag.childNodes[i].removeChild(td) 93 | else: 94 | for i, row in enumerate(self._cells): 95 | while len(row) < value: 96 | row.append(None) 97 | td = self._new_td() 98 | self.tag.childNodes[i].appendChild(td) 99 | self._n_columns = value 100 | self.mark_dirty() 101 | 102 | def _new_tr(self): 103 | return self.tag.ownerDocument.createElement('tr') 104 | def _new_td(self): 105 | td = self.tag.ownerDocument.createElement('td') 106 | td.setAttribute('style', 'border: 1px solid black') 107 | return td 108 | 109 | def __getitem__(self, indices): 110 | (i, j) = indices 111 | if isinstance(i, slice): 112 | rows = self._cells[i] 113 | return [row[j] for row in rows] 114 | else: 115 | return self._cells[i][j] 116 | 117 | def __setitem__(self, indices, child): 118 | (i, j) = indices 119 | if isinstance(i, slice) or isinstance(j, slice): 120 | raise NotImplementedError("slice assignment to Grids not yet supported") 121 | 122 | td = self.tag.childNodes[i].childNodes[j] 123 | 124 | old_child = self._cells[i][j] 125 | if old_child is not None: 126 | td.removeChild(td.childNodes[0]) 127 | 128 | self._cells[i][j] = child 129 | 130 | td.appendChild(child.tag) 131 | 132 | self.mark_dirty() 133 | 134 | def __delitem__(self, indices): 135 | (i, j) = indices 136 | if isinstance(i, slice) or isinstance(j, slice): 137 | raise NotImplementedError("slice deletion from Grids not yet supported") 138 | 139 | old_child = self._cells[i][j] 140 | self._cells[i][j] = None 141 | old_child.tag.parentNode.removeChild(old_child.tag) 142 | self.mark_dirty() 143 | 144 | @classmethod 145 | def make_column(cls, *elements, **kwargs): 146 | return cls(cells=[[e] for e in elements], **kwargs) 147 | 148 | @classmethod 149 | def make_row(cls, *elements, **kwargs): 150 | return cls(cells=[elements], **kwargs) 151 | -------------------------------------------------------------------------------- /browsergui/elements/_layout/_list.py: -------------------------------------------------------------------------------- 1 | from .. import Element 2 | from ..._pythoncompatibility import collections_abc 3 | 4 | class List(Element, collections_abc.MutableSequence): 5 | """A list of elements. 6 | 7 | May be numbered or bulleted, according to the `numbered` property (a boolean). 8 | 9 | Supports pretty much all the operations that a normal list does, e.g. 10 | 11 | my_list = List(items=[first, second]) 12 | my_list.append(third) 13 | my_list.insert(0, new_first) 14 | assert my_list[0] is new_first 15 | my_list[1] = new_second 16 | del my_list[2] 17 | """ 18 | def __init__(self, items=(), numbered=False, **kwargs): 19 | super(List, self).__init__(tag_name='ol', **kwargs) 20 | self.numbered = numbered 21 | for item in items: 22 | self.append(item) 23 | 24 | @property 25 | def numbered(self): 26 | return (self.css['list-style-type'] == 'decimal') 27 | @numbered.setter 28 | def numbered(self, value): 29 | # The "right" way to do this would be to change the tagName between "ol" and "ul", 30 | # but the DOM API doesn't specify a way to change a tagName. Using CSS seems like 31 | # a better solution than destroying the tag and creating a new one. 32 | # 33 | # According to https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type#Browser_compatibility 34 | # the values "disc" and "decimal" are supported in all major browsers. 35 | self.css['list-style-type'] = ('decimal' if value else 'disc') 36 | 37 | def __getitem__(self, index): 38 | return self.children[index] 39 | def __setitem__(self, index, child): 40 | if isinstance(index, slice): 41 | raise NotImplementedError("slice assignment to Lists not yet supported") 42 | 43 | del self[index] 44 | self.insert(index, child) 45 | 46 | def __delitem__(self, index): 47 | if isinstance(index, slice): 48 | raise NotImplementedError("slice deletion from Lists not yet supported") 49 | 50 | old_child = self.children[index] 51 | del self.children[index] 52 | self.tag.removeChild(old_child.tag.parentNode) 53 | self.mark_dirty() 54 | 55 | def __len__(self): 56 | return len(self.children) 57 | 58 | def insert(self, index, child): 59 | if not isinstance(child, Element): 60 | raise TypeError("List children must be Elements") 61 | 62 | li_tag = self.tag.ownerDocument.createElement('li') 63 | li_tag.appendChild(child.tag) 64 | 65 | next_li = self.tag.childNodes[index] if index < len(self.tag.childNodes) else None 66 | self.tag.insertBefore(li_tag, next_li) 67 | 68 | self.mark_dirty() 69 | -------------------------------------------------------------------------------- /browsergui/elements/_layout/_viewport.py: -------------------------------------------------------------------------------- 1 | import re 2 | import numbers 3 | from .. import Element 4 | 5 | class Viewport(Element): 6 | """A scrollable window into some other (probably big) element.""" 7 | def __init__(self, target, width, height, **kwargs): 8 | if not isinstance(target, Element): 9 | raise TypeError('expected Element, got {}'.format(type(target).__name__)) 10 | super(Viewport, self).__init__(tag_name='div', **kwargs) 11 | 12 | self.css.update(overflow='scroll', width='0', height='0') 13 | self.width = width 14 | self.height = height 15 | 16 | self._target = None 17 | self.target = target 18 | 19 | @property 20 | def target(self): 21 | '''The Element being viewed through the Viewport.''' 22 | return self._target 23 | @target.setter 24 | def target(self, new_target): 25 | if self._target is not None: 26 | self.tag.removeChild(self._target.tag) 27 | self._target = new_target 28 | self.tag.appendChild(new_target.tag) 29 | self.mark_dirty() 30 | 31 | @property 32 | def width(self): 33 | '''The Viewport's width, in pixels.''' 34 | return int(self.css['width']) 35 | @width.setter 36 | def width(self, value): 37 | if not isinstance(value, numbers.Real): 38 | raise TypeError('width must be a non-negative number, not {}'.format(type(value))) 39 | if value < 0: 40 | raise ValueError('width must be non-negative') 41 | self.css['width'] = str(value) 42 | self.mark_dirty() 43 | 44 | @property 45 | def height(self): 46 | '''The Viewport's height, in pixels.''' 47 | return int(self.css['height']) 48 | @height.setter 49 | def height(self, value): 50 | if not isinstance(value, numbers.Real): 51 | raise TypeError('height must be a non-negative number, not {}'.format(type(value))) 52 | if value < 0: 53 | raise ValueError('height must be non-negative') 54 | self.css['height'] = str(value) 55 | self.mark_dirty() 56 | -------------------------------------------------------------------------------- /browsergui/elements/_styler.py: -------------------------------------------------------------------------------- 1 | from .._pythoncompatibility import collections_abc 2 | 3 | class Styler(collections_abc.MutableMapping): 4 | def __init__(self, element, **kwargs): 5 | self.element = element 6 | self.rules = {} 7 | super(Styler, self).__init__(**kwargs) 8 | 9 | def _update_tag_style_attribute(self): 10 | css = self._css() 11 | if css: 12 | self.element.tag.setAttribute('style', css) 13 | else: 14 | self.element.tag.removeAttribute('style') 15 | self.element.mark_dirty() 16 | 17 | def _css(self): 18 | return '; '.join('{}: {}'.format(k, v) for k, v in sorted(self.items())) 19 | 20 | def __getitem__(self, key): 21 | return self.rules[key] 22 | 23 | def __setitem__(self, key, value): 24 | self.rules[key] = value 25 | self._update_tag_style_attribute() 26 | 27 | def __delitem__(self, key): 28 | del self.rules[key] 29 | self._update_tag_style_attribute() 30 | 31 | def __iter__(self): 32 | return iter(self.rules) 33 | 34 | def __len__(self): 35 | return len(self.rules) 36 | -------------------------------------------------------------------------------- /browsergui/elements/_xmltagshield.py: -------------------------------------------------------------------------------- 1 | import xml.dom.minidom 2 | 3 | _unique_id_counter = 0 4 | def unique_id(): 5 | """Returns a new string suitable for a tag id every time it's called.""" 6 | global _unique_id_counter 7 | _unique_id_counter += 1 8 | return "_element_{}".format(_unique_id_counter) 9 | 10 | class TreeTraversalMixin(object): 11 | '''Provides tree-traversal methods like ``walk``. 12 | 13 | Subclasses must define ``parent`` and ``children``. 14 | 15 | This would be more appropriate as an abstract class, but metaclass syntax is 16 | incompatible between Python 2 and 3, and this isn't really user-facing, 17 | so I can afford to be a little lazy. 18 | ''' 19 | 20 | @property 21 | def orphaned(self): 22 | """Whether the element has no parent.""" 23 | return (self.parent is None) 24 | 25 | @property 26 | def ancestors(self): 27 | '''Lists the object's parent, parent's parent, etc.''' 28 | if self.orphaned: 29 | return [] 30 | return [self.parent] + self.parent.ancestors 31 | 32 | @property 33 | def root(self): 34 | '''The uppermost ancestor of the element (i.e. the one with no parent).''' 35 | return self if self.parent is None else self.parent.root 36 | 37 | def walk(self): 38 | """Iterates over the element and all the elements below it in the tree.""" 39 | yield self 40 | for child in self.children: 41 | for descendant in child.walk(): 42 | yield descendant 43 | 44 | 45 | class XMLTagShield(TreeTraversalMixin): 46 | def __init__(self, tag_name, **kwargs): 47 | super(XMLTagShield, self).__init__(**kwargs) 48 | 49 | #: The HTML tag associated with the element. 50 | self.tag = xml.dom.minidom.Document().createElement(tag_name) 51 | self.tag.setAttribute('id', unique_id()) 52 | self.tag.__owner = self 53 | 54 | @property 55 | def id(self): 56 | '''A unique identifying string.''' 57 | return self.tag.getAttribute('id') 58 | 59 | @property 60 | def parent(self): 61 | '''The element whose tag contains this element's tag (or None)''' 62 | tag = self.tag.parentNode 63 | while tag is not None: 64 | try: 65 | return tag.__owner 66 | except AttributeError: 67 | tag = tag.parentNode 68 | return None 69 | 70 | @property 71 | def children(self): 72 | '''A list of the element's immediate children, in depth-first order.''' 73 | result = [] 74 | frontier = list(reversed(self.tag.childNodes)) 75 | while frontier: 76 | frontier, to_expand = frontier[:-1], frontier[-1] 77 | try: 78 | result.append(to_expand.__owner) 79 | except AttributeError: 80 | frontier += list(reversed(to_expand.childNodes)) 81 | return result 82 | 83 | -------------------------------------------------------------------------------- /browsergui/events/__init__.py: -------------------------------------------------------------------------------- 1 | '''Defines ``Events`` that represent actions taken by the user. 2 | 3 | You can attach :class:`Events ` to :class:`Elements ` to make sure certain functions get called when the user takes the corresponding kind of action: 4 | 5 | >>> e = Element(tag_name='input') 6 | >>> e.callbacks[Input] = (lambda event: print(event.value)) 7 | 8 | The predefined types of Event are: 9 | 10 | .. autosummary:: 11 | 12 | Click 13 | Input 14 | Change 15 | ''' 16 | 17 | def _dict_to_javascript_object(dict): 18 | return ''.join(( 19 | '{', 20 | ', '.join('{}: {}'.format(k, v) for k, v in sorted(dict.items())), 21 | '}')) 22 | 23 | class Event(object): 24 | '''Represents an event triggered by the user interacting with an Element. 25 | 26 | The Event life cycle: 27 | 28 | - Every Event subclass has a ``javascript_type_name``, which is the name of the corresponding kind of JS event. (The subclass should also use ``Event.register_subclass`` to ensure that it will be instantiated when notifications with that type-name are received; see below.) 29 | - An Event subclass can enable itself on a tag, so that when that tag handles an event of that type-name in the browser, the server is sent a dict. That dict describes the browser-side event, including the type-name. 30 | - That type-name is looked up in a table to determine which Event subclass to instantiate. (Subclasses are added to the table via ``Event.register_subclass``.) 31 | - The subclass is instantiated, using the dict as ``**kwargs``. 32 | 33 | :param str target_id: the ``id`` attribute of the HTML tag interacted with. 34 | ''' 35 | def __init__(self, target_id, **kwargs): 36 | super(Event, self).__init__(**kwargs) 37 | self.target_id = target_id 38 | 39 | @classmethod 40 | def enable_server_notification(cls, tag): 41 | '''Attach JS to a tag to notify the server when this event happens targeting the given tag. 42 | 43 | :param xml.dom.minidom.Element tag: 44 | ''' 45 | tag.setAttribute( 46 | 'on'+cls.javascript_type_name, 47 | 'notify_server({})'.format(_dict_to_javascript_object(cls.dict_to_notify_server()))) 48 | 49 | @classmethod 50 | def disable_server_notification(cls, tag): 51 | '''Remove server-notification JS for this event from the given tag.''' 52 | if 'on'+cls.javascript_type_name not in tag.attributes.keys(): 53 | raise KeyError("tag is not set up to notify server for {} events".format(cls.__name__)) 54 | tag.removeAttribute('on'+cls.javascript_type_name) 55 | 56 | @classmethod 57 | def dict_to_notify_server(cls): 58 | '''The information the browser should send the server when an event occurs. 59 | 60 | Returns a dict where the keys are strings, and the values are strings (JS expressions to be evaluated and stuck into the server notification). 61 | 62 | Example: for the Click event, the JS to notify the server would be: 63 | 64 | notify_server({target_id: this.getAttribute("id"), type_name: "click"}) 65 | 66 | so ``dict_to_notify_server`` should return 67 | 68 | {'target_id': 'this.getAttribute("id")', 'type_name': '"click"'} 69 | ''' 70 | return dict( 71 | target_id='this.getAttribute("id")', 72 | type_name='event.type') 73 | 74 | registered_subclasses_by_name = {} 75 | @staticmethod 76 | def register_subclass(cls): 77 | '''Decorator to add the class to the (JS-type-name -> class) table.''' 78 | Event.registered_subclasses_by_name[cls.javascript_type_name] = cls 79 | return cls 80 | 81 | @staticmethod 82 | def from_dict(event_dict): 83 | '''Parse a dict received from the browser into an Event instance.''' 84 | type_name = event_dict.pop('type_name') 85 | cls = Event.registered_subclasses_by_name[type_name] 86 | return cls.from_dict_of_right_type(event_dict) 87 | 88 | @classmethod 89 | def from_dict_of_right_type(cls, event_dict): 90 | return cls(**event_dict) 91 | 92 | @Event.register_subclass 93 | class Click(Event): 94 | '''Fired when the user clicks on an element.''' 95 | javascript_type_name = 'click' 96 | 97 | @Event.register_subclass 98 | class Input(Event): 99 | '''Fired when the user changes the value of an input-type element, in some sense.''' 100 | javascript_type_name = 'input' 101 | 102 | def __init__(self, value, **kwargs): 103 | super(Input, self).__init__(**kwargs) 104 | self.value = value 105 | 106 | @classmethod 107 | def dict_to_notify_server(cls): 108 | return dict( 109 | value='this.value', 110 | **super(Input, cls).dict_to_notify_server()) 111 | 112 | @Event.register_subclass 113 | class Change(Event): 114 | '''Fired when the user changes the value of an input-type element, in some sense. 115 | 116 | To be honest, I don't really know the difference between the "input" and "change" JavaScript events. 117 | ''' 118 | javascript_type_name = 'change' 119 | 120 | def __init__(self, value, **kwargs): 121 | super(Change, self).__init__(**kwargs) 122 | self.value = value 123 | 124 | @classmethod 125 | def dict_to_notify_server(cls): 126 | return dict( 127 | value='this.value', 128 | **super(Change, cls).dict_to_notify_server()) 129 | -------------------------------------------------------------------------------- /browsergui/examples/__init__.py: -------------------------------------------------------------------------------- 1 | from . import helloworld, clock, interactive, longrunning, tour, minesweeper 2 | 3 | EXAMPLES = { 4 | 'helloworld': helloworld, 5 | 'clock': clock, 6 | 'interactive': interactive, 7 | 'longrunning': longrunning, 8 | 'tour': tour, 9 | 'minesweeper': minesweeper} 10 | -------------------------------------------------------------------------------- /browsergui/examples/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import browsergui.examples 4 | 5 | parser = argparse.ArgumentParser() 6 | parser.add_argument('examplename', nargs='?', choices=list(sorted(browsergui.examples.EXAMPLES.keys())), default='tour', 7 | help='name of example to run (default: tour)') 8 | 9 | def main(): 10 | args = parser.parse_args() 11 | 12 | browsergui.examples.EXAMPLES[args.examplename].main() 13 | 14 | if __name__ == '__main__': 15 | main() -------------------------------------------------------------------------------- /browsergui/examples/clock.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | from browsergui import Text, GUI 4 | 5 | def main(): 6 | now = Text("") 7 | 8 | def update_now_forever(): 9 | while True: 10 | now.text = time.strftime("%Y-%m-%d %H:%M:%S") 11 | time.sleep(1) 12 | 13 | t = threading.Thread(target=update_now_forever) 14 | t.daemon = True 15 | t.start() 16 | 17 | GUI(Text("The time is: "), now).run() 18 | 19 | if __name__ == '__main__': 20 | main() -------------------------------------------------------------------------------- /browsergui/examples/helloworld.py: -------------------------------------------------------------------------------- 1 | from browsergui import GUI, Text 2 | 3 | def main(): 4 | GUI(Text("Hello world!")).run() 5 | 6 | if __name__ == '__main__': 7 | main() -------------------------------------------------------------------------------- /browsergui/examples/interactive.py: -------------------------------------------------------------------------------- 1 | import code 2 | import browsergui 3 | import threading 4 | from browsergui import GUI, Paragraph, CodeBlock, Paragraph 5 | 6 | def run_repl(gui): 7 | interpreter = code.InteractiveConsole(locals={'_gui': gui}) 8 | interpreter.runsource('from browsergui import *') 9 | interpreter.runsource('gui = _gui') 10 | interpreter.interact( 11 | banner=""" 12 | Here's an interpreter! You have access to everything in the `browsergui` 13 | namespace, plus a ready-made GUI named `gui`. 14 | 15 | The server startup might print a couple things on top of the prompt - 16 | don't worry, you're still in the interpreter. 17 | 18 | Exiting the interpreter will terminate the program. 19 | """) 20 | 21 | def main(): 22 | gui = GUI(Paragraph(""" 23 | Run commands in the REPL. 24 | As you change `gui`, this page will update. 25 | Some commands you might run are: 26 | """)) 27 | 28 | for sample in ("gui.body.append(Text('Hiiii!'))", 29 | "gui.body.append(Button(callback=(lambda: gui.body.append(Paragraph('Clicked!')))))"): 30 | gui.body.append(CodeBlock(sample)) 31 | 32 | t = threading.Thread(target=gui.run, kwargs={'quiet': True}) 33 | t.daemon = True 34 | t.start() 35 | run_repl(gui) 36 | 37 | if gui.running: # If the user killed the GUI in the REPL, it might not still be running. 38 | gui.stop_running() 39 | 40 | 41 | if __name__ == '__main__': 42 | main() -------------------------------------------------------------------------------- /browsergui/examples/longrunning.py: -------------------------------------------------------------------------------- 1 | import os 2 | import threading 3 | import collections 4 | from browsergui import * 5 | 6 | def find_extension(path): 7 | filename = os.path.basename(path) 8 | if not filename.startswith('.') and '.' in filename: 9 | extension = filename.rsplit('.', 1)[-1] 10 | if extension.isalnum(): 11 | return extension.upper() 12 | return None 13 | 14 | def file_paths(): 15 | for root, dirs, files in os.walk("."): 16 | for entry in files: 17 | yield os.path.join(root, entry) 18 | 19 | class ExtensionTallierGUI(GUI): 20 | def __init__(self): 21 | self.extension_counts = collections.defaultdict(int) 22 | self.current_file_text = CodeSnippet("") 23 | self.extension_counts_text = CodeBlock("") 24 | super(ExtensionTallierGUI, self).__init__( 25 | Paragraph("This will walk all files under the current directory and tally their extensions."), 26 | Paragraph("It might take a while. Feel free to close the window and come back later!"), 27 | Button(text="Start!", callback=self.tally), 28 | Container(Text("Currently visiting: "), self.current_file_text), 29 | Container(Text("Current extension counts:"), self.extension_counts_text)) 30 | 31 | def tally(self): 32 | for path in file_paths(): 33 | self.current_file_text.text = path 34 | extension = find_extension(path) 35 | self.extension_counts[extension] += 1 36 | self.extension_counts_text.text = "\n".join("{}: {}".format(k, v) for k, v in sorted(self.extension_counts.items(), key=(lambda kv: kv[1]), reverse=True)) 37 | 38 | def main(): 39 | ExtensionTallierGUI().run(quiet=True) 40 | 41 | if __name__ == '__main__': 42 | main() -------------------------------------------------------------------------------- /browsergui/examples/minesweeper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | 3 | import random 4 | from browsergui import * 5 | 6 | class Game(object): 7 | def __init__(self, w=20, h=20, mine_density=0.14): 8 | self.w = w 9 | self.h = h 10 | self.mine_density = mine_density 11 | self.mine_locations = set((random.randrange(self.h), random.randrange(self.w)) for _ in range(int(self.w*self.h*self.mine_density))) 12 | 13 | def neighbors(self, ij): 14 | i, j = ij 15 | for di in (-1, 0, 1): 16 | for dj in (-1, 0, 1): 17 | if not (di == dj == 0) and (0 <= i+di < self.h) and (0 <= j+dj < self.w): 18 | yield (i+di, j+dj) 19 | 20 | def n_mine_neighbors(self, ij): 21 | return len(list(n for n in self.neighbors(ij) if n in self.mine_locations)) 22 | 23 | def expand_region(self, ij): 24 | clean = set() 25 | boundary = set() 26 | to_expand = set([ij]) 27 | while to_expand: 28 | ij = to_expand.pop() 29 | if self.n_mine_neighbors(ij) == 0: 30 | clean.add(ij) 31 | for nij in self.neighbors(ij): 32 | if nij not in (clean | boundary): 33 | to_expand.add(nij) 34 | else: 35 | boundary.add(ij) 36 | 37 | return clean | boundary 38 | 39 | 40 | class MinesweeperGUI(GUI): 41 | def __init__(self, **kwargs): 42 | super(MinesweeperGUI, self).__init__(**kwargs) 43 | 44 | self.game = Game() 45 | 46 | self.w_field = TextField(value=str(self.game.w), placeholder='width') 47 | self.h_field = TextField(value=str(self.game.h), placeholder='height') 48 | self.mine_density_slider = FloatSlider(value=self.game.mine_density, min=0, max=1) 49 | self.reset_button = Button('Reset and Apply', callback=self.reset) 50 | 51 | self.body.append(Grid([ 52 | [Text('width'), Text('height'), Text('mine density')], 53 | [self.w_field, self.h_field, 54 | Container(Text('0'), self.mine_density_slider, Text('1')), 55 | self.reset_button]])) 56 | self.grid = None 57 | 58 | self.reset() 59 | 60 | def reset(self): 61 | w = int(self.w_field.value) 62 | h = int(self.h_field.value) 63 | mine_density = float(self.mine_density_slider.value) 64 | 65 | self.game = Game(w=w, h=h, mine_density=mine_density) 66 | 67 | if self.grid is not None: 68 | self.body.remove(self.grid) 69 | self.grid = Grid(n_rows=self.game.h, n_columns=self.game.w) 70 | self.body.append(self.grid) 71 | 72 | for i in range(self.game.h): 73 | for j in range(self.game.w): 74 | self.grid[i, j] = self.button_for((i, j)) 75 | 76 | def button_for(self, ij): 77 | def callback(): 78 | if ij in self.game.mine_locations: 79 | self.grid[ij] = Text('X', css={'color': 'red'}) 80 | self.grid.css['background-color'] = '#fcc' 81 | else: 82 | for nij in self.game.expand_region(ij): 83 | nmn = self.game.n_mine_neighbors(nij) 84 | self.grid[nij] = Text('' if nmn == 0 else str(nmn)) 85 | 86 | return Button('', callback=callback, css={'width': '2em', 'height': '2em'}) 87 | 88 | def main(): 89 | MinesweeperGUI().run() 90 | 91 | if __name__ == '__main__': 92 | main() 93 | -------------------------------------------------------------------------------- /browsergui/examples/tour-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/speezepearson/browsergui/53fc260a91170b2becf38d36f460b57c386dc3ef/browsergui/examples/tour-image.png -------------------------------------------------------------------------------- /browsergui/examples/tour.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from browsergui import * 4 | 5 | def n_leading_spaces(s): 6 | return len(re.match('^ *', s).group()) 7 | 8 | def strip_whitespace(s): 9 | s = s.strip('\n') 10 | n = min(n_leading_spaces(line) for line in s.split('\n') if line) 11 | return '\n'.join(line[n:] for line in s.split('\n')) 12 | 13 | def exec_then_eval(to_exec, to_eval): 14 | # Due to some scoping subtlety, the locals and globals in 15 | # exec and eval should be the same, or we won't be able to 16 | # exec a program like 17 | # 18 | # xs = [] 19 | # (lambda: xs)() 20 | # 21 | # I dunno. I just dunno. 22 | scope = globals().copy() 23 | if to_exec is not None: 24 | exec(to_exec, scope, scope) 25 | return eval(to_eval, scope, scope) 26 | 27 | class Example(object): 28 | def __init__(self, show_code, prep_code=None): 29 | self.show_code = show_code 30 | self.prep_code = prep_code 31 | 32 | def to_grid_row(self): 33 | element = exec_then_eval(to_exec=self.prep_code, to_eval=self.show_code) 34 | code = self.show_code if self.prep_code is None else (self.prep_code + '\n\n' + self.show_code) 35 | return [CodeBlock(code), element] 36 | 37 | def main(): 38 | 39 | examples = {} 40 | 41 | def example_grid_for_types(*types): 42 | header_row = [EmphasizedText('Code'), EmphasizedText('Result')] 43 | rows = [examples[t].to_grid_row() for t in types] 44 | return Grid(cells=[header_row] + rows) 45 | 46 | examples[Text] = Example('Text("some plain text")') 47 | examples[Paragraph] = Example('Container(Paragraph("one"), Paragraph("two"))') 48 | examples[EmphasizedText] = Example('EmphasizedText("some bold text")') 49 | examples[CodeSnippet] = Example('CodeSnippet("some code")') 50 | examples[CodeBlock] = Example(r'CodeBlock("one\ntwo")') 51 | examples[Link] = Example('Link("google", url="http://google.com")') 52 | 53 | examples[Container] = Example('Container(Text("one"), CodeSnippet("two"))') 54 | examples[Viewport] = Example(strip_whitespace(r''' 55 | Viewport( 56 | CodeBlock('\n'.join(50*'viewport ' for _ in range(100))), 57 | width=400, height=200)''')) 58 | examples[List] = Example('List(items=[Text("one"), Text("two")])') 59 | examples[Grid] = Example(strip_whitespace(''' 60 | Grid(cells=[ 61 | [Text("00"), Text("01")], 62 | [Text("10"), Text("11")]])''')) 63 | 64 | examples[Image] = Example("Image(os.path.join(os.path.dirname(__file__), 'tour-image.png'))") 65 | 66 | examples[Button] = Example( 67 | 'Container(click_count, button)', 68 | strip_whitespace(''' 69 | click_count = Text('0') 70 | def button_clicked(): 71 | n = int(click_count.text) 72 | click_count.text = str(n+1) 73 | button = Button('Click me!', callback=button_clicked)''')) 74 | 75 | examples[TextField] = Example( 76 | 'Container(text_field, reversed_text_field_contents)', 77 | strip_whitespace(''' 78 | reversed_text_field_contents = Text('') 79 | def text_field_changed(): 80 | reversed_contents = ''.join(reversed(text_field.value)) 81 | reversed_text_field_contents.text = reversed_contents 82 | text_field = TextField(change_callback=text_field_changed) 83 | text_field.value = "Reversed"''')) 84 | 85 | examples[BigTextField] = Example( 86 | 'Container(text_field, reversed_text_field_contents)', 87 | strip_whitespace(''' 88 | reversed_text_field_contents = Text('') 89 | def text_field_changed(): 90 | reversed_contents = ''.join(reversed(text_field.value)) 91 | reversed_text_field_contents.text = reversed_contents 92 | text_field = BigTextField(change_callback=text_field_changed) 93 | text_field.value = "Reversed"''')) 94 | 95 | examples[Dropdown] = Example( 96 | 'Container(dropdown, selected_dropdown_item)', 97 | strip_whitespace(''' 98 | selected_dropdown_item = Text('') 99 | dropdown = Dropdown(['Dr', 'op', 'do', 'wn']) 100 | @dropdown.def_change_callback 101 | def _(): 102 | selected_dropdown_item.text = dropdown.value 103 | dropdown.value = "wn"''')) 104 | 105 | examples[NumberField] = Example( 106 | 'Container(number_field, number_field_squared)', 107 | strip_whitespace(''' 108 | number_field_squared = Text('') 109 | def number_changed(): 110 | if number_field.value is None: 111 | number_field_squared.text = '' 112 | else: 113 | number_field_squared.text = str(number_field.value ** 2) 114 | number_field = NumberField(change_callback=number_changed) 115 | number_field.value = 12''')) 116 | 117 | examples[ColorField] = Example( 118 | 'Container(color_field, colored_text)', 119 | strip_whitespace(''' 120 | colored_text = Text('colored') 121 | def color_changed(): 122 | color = color_field.value 123 | color_hex = '#{:02x}{:02x}{:02x}'.format(*color) 124 | colored_text.css['color'] = color_hex 125 | color_field = ColorField(change_callback=color_changed) 126 | color_field.value = (0, 0, 255)''')) 127 | 128 | examples[DateField] = Example( 129 | 'Container(date_field, weekday_text)', 130 | strip_whitespace(''' 131 | weekday_text = Text('...') 132 | DAYS = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 133 | 'Friday', 'Saturday', 'Sunday') 134 | def date_changed(): 135 | if date_field.value is None: 136 | weekday_text.text = '' 137 | else: 138 | weekday_text.text = DAYS[date_field.value.weekday()] 139 | date_field = DateField(change_callback=date_changed)''')) 140 | 141 | examples[FloatSlider] = Example( 142 | 'Container(slider, slider_value_squared)', 143 | strip_whitespace(''' 144 | slider_value_squared = Text('') 145 | def slider_changed(): 146 | if slider.value is None: 147 | slider_value_squared.text = '' 148 | else: 149 | slider_value_squared.text = '{:.3g}'.format(slider.value ** 2) 150 | slider = FloatSlider(min=0, max=10, change_callback=slider_changed) 151 | slider.value = 3''')) 152 | 153 | examples[IntegerSlider] = Example( 154 | 'Container(slider, slider_value_squared)', 155 | strip_whitespace(''' 156 | slider_value_squared = Text('') 157 | def slider_changed(): 158 | if slider.value is None: 159 | slider_value_squared.text = '' 160 | else: 161 | slider_value_squared.text = str(slider.value ** 2) 162 | slider = IntegerSlider(min=0, max=5, change_callback=slider_changed) 163 | slider.value = 3''')) 164 | 165 | 166 | GUI( 167 | Paragraph(''' 168 | Here is a list of all the kinds of Element available to you. 169 | See the classes' documentation for more detailed information on them.'''), 170 | List(items=[ 171 | Container( 172 | Paragraph('Text of many flavors:'), 173 | example_grid_for_types(Text, Paragraph, EmphasizedText, CodeSnippet, CodeBlock, Link)), 174 | Container( 175 | Paragraph('Input of many flavors:'), 176 | example_grid_for_types(Button, FloatSlider, TextField, BigTextField, Dropdown, NumberField, ColorField, DateField)), 177 | Container( 178 | Paragraph('Structural elements of many flavors:'), 179 | example_grid_for_types(Container, Viewport, List, Grid)), 180 | Container( 181 | Paragraph('Other:'), 182 | example_grid_for_types(Image))]), 183 | title='browsergui tour').run() 184 | 185 | if __name__ == '__main__': 186 | main() 187 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/browsergui.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/browsergui.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/browsergui" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/browsergui" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /doc/browsergui.elements.rst: -------------------------------------------------------------------------------- 1 | browsergui.elements package 2 | =========================== 3 | 4 | Module contents 5 | --------------- 6 | 7 | .. automodule:: browsergui.elements 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /doc/browsergui.events.rst: -------------------------------------------------------------------------------- 1 | browsergui.events package 2 | ========================= 3 | 4 | Module contents 5 | --------------- 6 | 7 | .. automodule:: browsergui.events 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /doc/browsergui.examples.rst: -------------------------------------------------------------------------------- 1 | browsergui.examples package 2 | =========================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | browsergui.examples.clock module 8 | -------------------------------- 9 | 10 | .. automodule:: browsergui.examples.clock 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | browsergui.examples.helloworld module 16 | ------------------------------------- 17 | 18 | .. automodule:: browsergui.examples.helloworld 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | browsergui.examples.interactive module 24 | -------------------------------------- 25 | 26 | .. automodule:: browsergui.examples.interactive 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | browsergui.examples.longrunning module 32 | -------------------------------------- 33 | 34 | .. automodule:: browsergui.examples.longrunning 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | browsergui.examples.minesweeper module 40 | -------------------------------------- 41 | 42 | .. automodule:: browsergui.examples.minesweeper 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | browsergui.examples.tour module 48 | ------------------------------- 49 | 50 | .. automodule:: browsergui.examples.tour 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | 56 | Module contents 57 | --------------- 58 | 59 | .. automodule:: browsergui.examples 60 | :members: 61 | :undoc-members: 62 | :show-inheritance: 63 | -------------------------------------------------------------------------------- /doc/browsergui.rst: -------------------------------------------------------------------------------- 1 | browsergui package 2 | ================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | browsergui.elements 10 | browsergui.events 11 | browsergui.examples 12 | 13 | Module contents 14 | --------------- 15 | 16 | .. automodule:: browsergui 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # browsergui documentation build configuration file, created by 5 | # sphinx-quickstart on Sun Nov 29 11:03:46 2015. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | import shlex 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | #sys.path.insert(0, os.path.abspath('.')) 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | #needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'sphinx.ext.autodoc', 35 | 'sphinx.ext.viewcode', 36 | 'sphinx.ext.autosummary', 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ['_templates'] 41 | 42 | # The suffix(es) of source filenames. 43 | # You can specify multiple suffix as a list of string: 44 | # source_suffix = ['.rst', '.md'] 45 | source_suffix = '.rst' 46 | 47 | # The encoding of source files. 48 | #source_encoding = 'utf-8-sig' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # General information about the project. 54 | project = 'browsergui' 55 | copyright = '2015, Spencer Pearson' 56 | author = 'Spencer Pearson' 57 | 58 | # The version info for the project you're documenting, acts as replacement for 59 | # |version| and |release|, also used in various other places throughout the 60 | # built documents. 61 | # 62 | # The short X.Y version. 63 | version = '0.3.1' 64 | # The full version, including alpha/beta/rc tags. 65 | release = '0.3.1' 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | # 70 | # This is also used if you do content translation via gettext catalogs. 71 | # Usually you set "language" from the command line for these cases. 72 | language = None 73 | 74 | # There are two options for replacing |today|: either, you set today to some 75 | # non-false value, then it is used: 76 | #today = '' 77 | # Else, today_fmt is used as the format for a strftime call. 78 | #today_fmt = '%B %d, %Y' 79 | 80 | # List of patterns, relative to source directory, that match files and 81 | # directories to ignore when looking for source files. 82 | exclude_patterns = ['_build'] 83 | 84 | # The reST default role (used for this markup: `text`) to use for all 85 | # documents. 86 | #default_role = None 87 | 88 | # If true, '()' will be appended to :func: etc. cross-reference text. 89 | #add_function_parentheses = True 90 | 91 | # If true, the current module name will be prepended to all description 92 | # unit titles (such as .. function::). 93 | #add_module_names = True 94 | 95 | # If true, sectionauthor and moduleauthor directives will be shown in the 96 | # output. They are ignored by default. 97 | #show_authors = False 98 | 99 | # The name of the Pygments (syntax highlighting) style to use. 100 | pygments_style = 'sphinx' 101 | 102 | # A list of ignored prefixes for module index sorting. 103 | #modindex_common_prefix = [] 104 | 105 | # If true, keep warnings as "system message" paragraphs in the built documents. 106 | #keep_warnings = False 107 | 108 | # If true, `todo` and `todoList` produce output, else they produce nothing. 109 | todo_include_todos = False 110 | 111 | 112 | # -- Options for HTML output ---------------------------------------------- 113 | 114 | # The theme to use for HTML and HTML Help pages. See the documentation for 115 | # a list of builtin themes. 116 | html_theme = 'classic' 117 | 118 | # Theme options are theme-specific and customize the look and feel of a theme 119 | # further. For a list of options available for each theme, see the 120 | # documentation. 121 | #html_theme_options = {} 122 | 123 | # Add any paths that contain custom themes here, relative to this directory. 124 | #html_theme_path = [] 125 | 126 | # The name for this set of Sphinx documents. If None, it defaults to 127 | # " v documentation". 128 | #html_title = None 129 | 130 | # A shorter title for the navigation bar. Default is the same as html_title. 131 | #html_short_title = None 132 | 133 | # The name of an image file (relative to this directory) to place at the top 134 | # of the sidebar. 135 | #html_logo = None 136 | 137 | # The name of an image file (within the static path) to use as favicon of the 138 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 139 | # pixels large. 140 | #html_favicon = None 141 | 142 | # Add any paths that contain custom static files (such as style sheets) here, 143 | # relative to this directory. They are copied after the builtin static files, 144 | # so a file named "default.css" will overwrite the builtin "default.css". 145 | html_static_path = ['_static'] 146 | 147 | # Add any extra paths that contain custom files (such as robots.txt or 148 | # .htaccess) here, relative to this directory. These files are copied 149 | # directly to the root of the documentation. 150 | #html_extra_path = [] 151 | 152 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 153 | # using the given strftime format. 154 | #html_last_updated_fmt = '%b %d, %Y' 155 | 156 | # If true, SmartyPants will be used to convert quotes and dashes to 157 | # typographically correct entities. 158 | #html_use_smartypants = True 159 | 160 | # Custom sidebar templates, maps document names to template names. 161 | #html_sidebars = {} 162 | 163 | # Additional templates that should be rendered to pages, maps page names to 164 | # template names. 165 | #html_additional_pages = {} 166 | 167 | # If false, no module index is generated. 168 | #html_domain_indices = True 169 | 170 | # If false, no index is generated. 171 | #html_use_index = True 172 | 173 | # If true, the index is split into individual pages for each letter. 174 | #html_split_index = False 175 | 176 | # If true, links to the reST sources are added to the pages. 177 | #html_show_sourcelink = True 178 | 179 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 180 | #html_show_sphinx = True 181 | 182 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 183 | #html_show_copyright = True 184 | 185 | # If true, an OpenSearch description file will be output, and all pages will 186 | # contain a tag referring to it. The value of this option must be the 187 | # base URL from which the finished HTML is served. 188 | #html_use_opensearch = '' 189 | 190 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 191 | #html_file_suffix = None 192 | 193 | # Language to be used for generating the HTML full-text search index. 194 | # Sphinx supports the following languages: 195 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 196 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 197 | #html_search_language = 'en' 198 | 199 | # A dictionary with options for the search language support, empty by default. 200 | # Now only 'ja' uses this config value 201 | #html_search_options = {'type': 'default'} 202 | 203 | # The name of a javascript file (relative to the configuration directory) that 204 | # implements a search results scorer. If empty, the default will be used. 205 | #html_search_scorer = 'scorer.js' 206 | 207 | # Output file base name for HTML help builder. 208 | htmlhelp_basename = 'browserguidoc' 209 | 210 | # -- Options for LaTeX output --------------------------------------------- 211 | 212 | latex_elements = { 213 | # The paper size ('letterpaper' or 'a4paper'). 214 | #'papersize': 'letterpaper', 215 | 216 | # The font size ('10pt', '11pt' or '12pt'). 217 | #'pointsize': '10pt', 218 | 219 | # Additional stuff for the LaTeX preamble. 220 | #'preamble': '', 221 | 222 | # Latex figure (float) alignment 223 | #'figure_align': 'htbp', 224 | } 225 | 226 | # Grouping the document tree into LaTeX files. List of tuples 227 | # (source start file, target name, title, 228 | # author, documentclass [howto, manual, or own class]). 229 | latex_documents = [ 230 | (master_doc, 'browsergui.tex', 'browsergui Documentation', 231 | 'Spencer Pearson', 'manual'), 232 | ] 233 | 234 | # The name of an image file (relative to this directory) to place at the top of 235 | # the title page. 236 | #latex_logo = None 237 | 238 | # For "manual" documents, if this is true, then toplevel headings are parts, 239 | # not chapters. 240 | #latex_use_parts = False 241 | 242 | # If true, show page references after internal links. 243 | #latex_show_pagerefs = False 244 | 245 | # If true, show URL addresses after external links. 246 | #latex_show_urls = False 247 | 248 | # Documents to append as an appendix to all manuals. 249 | #latex_appendices = [] 250 | 251 | # If false, no module index is generated. 252 | #latex_domain_indices = True 253 | 254 | 255 | # -- Options for manual page output --------------------------------------- 256 | 257 | # One entry per manual page. List of tuples 258 | # (source start file, name, description, authors, manual section). 259 | man_pages = [ 260 | (master_doc, 'browsergui', 'browsergui Documentation', 261 | [author], 1) 262 | ] 263 | 264 | # If true, show URL addresses after external links. 265 | #man_show_urls = False 266 | 267 | 268 | # -- Options for Texinfo output ------------------------------------------- 269 | 270 | # Grouping the document tree into Texinfo files. List of tuples 271 | # (source start file, target name, title, author, 272 | # dir menu entry, description, category) 273 | texinfo_documents = [ 274 | (master_doc, 'browsergui', 'browsergui Documentation', 275 | author, 'browsergui', 'One line description of project.', 276 | 'Miscellaneous'), 277 | ] 278 | 279 | # Documents to append as an appendix to all manuals. 280 | #texinfo_appendices = [] 281 | 282 | # If false, no module index is generated. 283 | #texinfo_domain_indices = True 284 | 285 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 286 | #texinfo_show_urls = 'footnote' 287 | 288 | # If true, do not generate a @detailmenu in the "Top" node's menu. 289 | #texinfo_no_detailmenu = False 290 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. browsergui documentation master file, created by 2 | sphinx-quickstart on Sun Nov 29 11:03:46 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. currentmodule:: browsergui 7 | 8 | browsergui documentation 9 | ======================== 10 | 11 | Welcome! Here lives the documentation for ``browsergui``. 12 | 13 | This site is meant as a refresher/lookup table for people who already know how to use the package. If you're new and you want to learn how, try the `wiki`_ instead. 14 | 15 | .. _wiki: https://github.com/speezepearson/browsergui/wiki 16 | 17 | 18 | 19 | Useful resources: 20 | 21 | - The :mod:`elements ` module docs, listing all the predefined kinds of Element 22 | - The :class:`GUI` class docs 23 | - The :mod:`events ` module docs, listing all the predefined kinds of Event 24 | 25 | 26 | Indices and tables 27 | ================== 28 | 29 | * :ref:`genindex` 30 | * :ref:`modindex` 31 | * :ref:`search` 32 | 33 | -------------------------------------------------------------------------------- /doc/modules.rst: -------------------------------------------------------------------------------- 1 | browsergui 2 | ========== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | browsergui 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [build_sphinx] 2 | source-dir = doc 3 | build-dir = doc/_build 4 | all_files = 1 5 | 6 | [upload_sphinx] 7 | upload-dir = doc/_build/html 8 | 9 | [bdist_wheel] 10 | universal=1 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Always prefer setuptools over distutils 2 | from setuptools import setup, find_packages 3 | # To use a consistent encoding 4 | from codecs import open 5 | from os import path 6 | 7 | here = path.abspath(path.dirname(__file__)) 8 | 9 | # Get the long description from the relevant file 10 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 11 | long_description = f.read() 12 | 13 | setup( 14 | name='browsergui', 15 | 16 | # Versions should comply with PEP440. For a discussion on single-sourcing 17 | # the version across setup.py and the project code, see 18 | # https://packaging.python.org/en/latest/single_source_version.html 19 | version='0.4', 20 | 21 | description='A GUI toolkit targeting browsers', 22 | long_description=long_description, 23 | 24 | # The project's main homepage. 25 | url='https://github.com/speezepearson/browsergui', 26 | 27 | # Author details 28 | author='speezepearson', 29 | author_email='speeze.pearson+1097@gmail.com', 30 | 31 | # Choose your license 32 | # license='MIT', 33 | 34 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 35 | classifiers=[ 36 | # How mature is this project? Common values are 37 | # 3 - Alpha 38 | # 4 - Beta 39 | # 5 - Production/Stable 40 | 'Development Status :: 4 - Beta', 41 | 42 | # Indicate who your project is intended for 43 | 'Intended Audience :: Developers', 44 | 'Topic :: Software Development :: User Interfaces', 45 | 46 | # Pick your license as you wish (should match "license" above) 47 | # 'License :: OSI Approved :: MIT License', 48 | 49 | # Specify the Python versions you support here. In particular, ensure 50 | # that you indicate whether you support Python 2, Python 3 or both. 51 | 'Programming Language :: Python :: 2.7', 52 | 'Programming Language :: Python :: 3', 53 | 'Programming Language :: Python :: 3.2', 54 | 'Programming Language :: Python :: 3.3', 55 | 'Programming Language :: Python :: 3.4', 56 | ], 57 | 58 | # What does your project relate to? 59 | keywords='browser gui', 60 | 61 | # You can just specify the packages manually here if your project is 62 | # simple. Or you can use find_packages(). 63 | packages=find_packages(exclude=['test', 'doc', 'wiki']), 64 | 65 | # List run-time dependencies here. These will be installed by pip when 66 | # your project is installed. For an analysis of "install_requires" vs pip's 67 | # requirements files see: 68 | # https://packaging.python.org/en/latest/requirements.html 69 | install_requires=[], 70 | 71 | # List additional groups of dependencies here (e.g. development 72 | # dependencies). You can install these using the following syntax, 73 | # for example: 74 | # $ pip install -e .[dev,test] 75 | extras_require={ 76 | 'dev': [], 77 | 'test': [], 78 | }, 79 | 80 | # If there are data files included in your packages that need to be 81 | # installed, specify them here. If using Python 2.6 or less, then these 82 | # have to be included in MANIFEST.in as well. 83 | package_data={ 84 | 'browsergui': ['_server/*.html', '_server/*.js', 'examples/*.png'], 85 | }, 86 | 87 | # Although 'package_data' is the preferred approach, in some case you may 88 | # need to place data files outside of your packages. See: 89 | # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files # noqa 90 | # In this case, 'data_file' will be installed into '/my_data' 91 | # data_files=[('my_data', ['data/data_file'])], 92 | 93 | # To provide executable scripts, use entry points in preference to the 94 | # "scripts" keyword. Entry points provide cross-platform support and allow 95 | # pip to create the appropriate form of executable for the target platform. 96 | # entry_points={ 97 | # 'console_scripts': [ 98 | # 'sample=sample:main', 99 | # ], 100 | # }, 101 | ) 102 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unittest 3 | import xml.dom.minidom 4 | import contextlib 5 | 6 | def walk(tag): 7 | yield tag 8 | for child in tag.childNodes: 9 | for descendant in walk(child): 10 | yield descendant 11 | 12 | class BrowserGUITestCase(unittest.TestCase): 13 | def setUp(self): 14 | self.last_event = None 15 | 16 | def set_last_event(self, event): 17 | self.last_event = event 18 | 19 | @contextlib.contextmanager 20 | def assertSetsEvent(self, event): 21 | self.last_event = None 22 | yield 23 | self.assertEqual(event, self.last_event) 24 | 25 | @contextlib.contextmanager 26 | def assertMarksDirty(self, element): 27 | xs = [] 28 | def dummy(): 29 | xs.append(0) 30 | element.mark_dirty = dummy 31 | try: 32 | yield 33 | finally: 34 | del element.mark_dirty 35 | self.assertTrue(xs) 36 | 37 | def assertHTMLIn(self, included, html): 38 | self.assertIn(re.sub("\s", "", included), re.sub("\s", "", html)) 39 | 40 | def assertHTMLLike(self, expected_string, element, ignored_attrs=['id']): 41 | """Asserts the HTML for an element is equivalent to the given HTML. 42 | 43 | :param str expected_string: XML string the element's HTML should look like 44 | :param Element element: 45 | :param iterable ignored_attrs: ignore differences in attributes with these names 46 | """ 47 | expected_tag = xml.dom.minidom.parseString(expected_string).documentElement 48 | tag = xml.dom.minidom.parseString(element.tag.toxml()).documentElement 49 | 50 | for attr in ignored_attrs: 51 | for descendant in walk(tag): 52 | if descendant.attributes is None: continue 53 | if attr in descendant.attributes.keys(): 54 | descendant.removeAttribute(attr) 55 | for descendant in walk(expected_tag): 56 | if descendant.attributes is None: continue 57 | if attr in descendant.attributes.keys(): 58 | descendant.removeAttribute(attr) 59 | 60 | self.assertEqual(tag.toxml(), expected_tag.toxml()) 61 | 62 | def assertUnstyledHTMLLike(self, xml, grid, ignored_attrs=['id']): 63 | ignored_attrs = set(ignored_attrs) 64 | ignored_attrs.add('style') 65 | self.assertHTMLLike(xml, grid, ignored_attrs=ignored_attrs) 66 | -------------------------------------------------------------------------------- /test/test_big_text_field.py: -------------------------------------------------------------------------------- 1 | from browsergui import BigTextField 2 | from browsergui.events import Input 3 | from . import BrowserGUITestCase 4 | 5 | 6 | class BigTextFieldTest(BrowserGUITestCase): 7 | def test_constructor(self): 8 | self.assertEqual('foo', BigTextField(value='foo').value) 9 | 10 | def test_set_value(self): 11 | e = BigTextField() 12 | e.value = 'foo' 13 | self.assertEqual('foo', e.value) 14 | 15 | def test_set_value__marks_dirty(self): 16 | e = BigTextField() 17 | with self.assertMarksDirty(e): 18 | e.value = 'foo' 19 | 20 | def test_set_placeholder(self): 21 | e = BigTextField() 22 | e.placeholder = 'foo' 23 | self.assertEqual('foo', e.placeholder) 24 | self.assertEqual('foo', e.tag.getAttribute('placeholder')) 25 | 26 | def test_set_placeholder__marks_dirty(self): 27 | e = BigTextField() 28 | with self.assertMarksDirty(e): 29 | e.placeholder = 'foo' 30 | 31 | def test_change_callback(self): 32 | xs = [] 33 | e = BigTextField(change_callback=(lambda: xs.append(1))) 34 | e.value = 'hi' 35 | self.assertEqual([1], xs) 36 | 37 | xs = [] 38 | e.change_callback = (lambda: xs.append(2)) 39 | e.value = 'bye' 40 | self.assertEqual([2], xs) 41 | 42 | def test_validation(self): 43 | t = BigTextField() 44 | 45 | for good_object in ('', 'abc', u'abc', 'a b c'): 46 | t.value = good_object 47 | 48 | for bad_object in (None, 0, [], ()): 49 | with self.assertRaises(TypeError): 50 | t.value = bad_object 51 | 52 | def test_def_change_callback(self): 53 | t = BigTextField() 54 | @t.def_change_callback 55 | def callback(): 56 | pass 57 | self.assertEqual(callback, t.change_callback) 58 | -------------------------------------------------------------------------------- /test/test_button.py: -------------------------------------------------------------------------------- 1 | from browsergui import Button, Event, Click 2 | from . import BrowserGUITestCase 3 | 4 | class ButtonTest(BrowserGUITestCase): 5 | def test_construction(self): 6 | Button("Press me") 7 | 8 | with self.assertRaises(TypeError): 9 | Button(0) 10 | 11 | def test_default_text(self): 12 | self.assertEqual(Button().text, "Click!") 13 | 14 | def test_callback_is_settable(self): 15 | xs = [] 16 | b = Button(callback=(lambda: xs.append(1))) 17 | b.handle_event(Click(target_id=b.id)) 18 | self.assertEqual([1], xs) 19 | 20 | xs = [] 21 | b.callback = (lambda: xs.append(2)) 22 | b.handle_event(Click(target_id=b.id)) 23 | self.assertEqual([2], xs) 24 | 25 | xs = [] 26 | b.callback = None 27 | b.handle_event(Click(target_id=b.id)) 28 | self.assertEqual([], xs) 29 | 30 | def test_tag(self): 31 | self.assertHTMLLike('', Button('Hi')) 32 | 33 | def test_def_callback(self): 34 | xs = [] 35 | b = Button() 36 | @b.def_callback 37 | def _(): 38 | xs.append(1) 39 | 40 | b.handle_event(Click(target_id=b.id)) 41 | self.assertEqual([1], xs) 42 | -------------------------------------------------------------------------------- /test/test_callback_setter.py: -------------------------------------------------------------------------------- 1 | from browsergui import Text, Click 2 | 3 | from . import BrowserGUITestCase 4 | 5 | class CallbackSetterTest(BrowserGUITestCase): 6 | 7 | def setUp(self): 8 | self.element = Text('') 9 | self.tag = self.element.tag 10 | self.callback_setter = self.element.callbacks 11 | 12 | def test_setitem__adds_attribute(self): 13 | self.callback_setter[Click] = (lambda event: None) 14 | self.assertIn('onclick', self.tag.attributes.keys()) 15 | 16 | def test_setitem__marks_dirty(self): 17 | with self.assertMarksDirty(self.element): 18 | self.callback_setter[Click] = (lambda event: None) 19 | 20 | def test_delitem__removes_attribute(self): 21 | self.callback_setter[Click] = (lambda event: None) 22 | del self.callback_setter[Click] 23 | self.assertNotIn('onclick', self.tag.attributes.keys()) 24 | 25 | def test_delitem__marks_dirty(self): 26 | self.callback_setter[Click] = (lambda event: None) 27 | 28 | with self.assertMarksDirty(self.element): 29 | del self.callback_setter[Click] 30 | -------------------------------------------------------------------------------- /test/test_color_field.py: -------------------------------------------------------------------------------- 1 | from browsergui import ColorField 2 | from . import BrowserGUITestCase 3 | 4 | class ColorFieldTest(BrowserGUITestCase): 5 | def setUp(self): 6 | ColorField.warn_about_potential_browser_incompatibility = False 7 | 8 | def test_validation(self): 9 | c = ColorField() 10 | 11 | for good_object in ((0,0,0), (1,2,3), [1,2,3]): 12 | c.value = good_object 13 | 14 | for bad_object in (None, (x for x in [1,2,3]), (1, 2, 3.0), '123', (1, 2, '3'), 123): 15 | with self.assertRaises(TypeError): 16 | c.value = bad_object 17 | 18 | for bad_object in ((1, 2, -3), (1, 2, 256)): 19 | with self.assertRaises(ValueError): 20 | c.value = bad_object 21 | -------------------------------------------------------------------------------- /test/test_container.py: -------------------------------------------------------------------------------- 1 | from browsergui import Container, Click 2 | from . import BrowserGUITestCase 3 | 4 | class ContainerTest(BrowserGUITestCase): 5 | def test_construction(self): 6 | left = Container() 7 | right = Container() 8 | top = Container(left, right) 9 | self.assertEqual(list(top.children), [left, right]) 10 | 11 | def test_children_must_be_elements(self): 12 | with self.assertRaises(TypeError): 13 | Container(0) 14 | with self.assertRaises(TypeError): 15 | Container().append(0) 16 | with self.assertRaises(TypeError): 17 | Container(Container())[0] = 0 18 | 19 | def test_tag(self): 20 | self.assertHTMLLike('
', Container()) 21 | self.assertHTMLLike('', Container(tag_name='span')) 22 | 23 | def test_children(self): 24 | container = Container() 25 | first = Container() 26 | second = Container() 27 | 28 | self.assertEqual(list(container.children), []) 29 | 30 | container.append(first) 31 | self.assertEqual(list(container.children), [first]) 32 | 33 | container.insert(container.index(first)+1, second) 34 | self.assertEqual(list(container.children), [first, second]) 35 | 36 | container.remove(first) 37 | self.assertEqual(list(container.children), [second]) 38 | 39 | container.remove(second) 40 | self.assertEqual(list(container.children), []) 41 | 42 | def test_hash_static(self): 43 | c = Container() 44 | h = hash(c) 45 | 46 | self.assertEqual(h, hash(c)) 47 | 48 | c.append(Container()) 49 | self.assertEqual(h, hash(c)) 50 | 51 | c.callbacks[Click] = self.set_last_event 52 | self.assertEqual(h, hash(c)) 53 | -------------------------------------------------------------------------------- /test/test_date_field.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from browsergui import DateField 3 | from . import BrowserGUITestCase 4 | 5 | class DateFieldTest(BrowserGUITestCase): 6 | def setUp(self): 7 | DateField.warn_about_potential_browser_incompatibility = False 8 | 9 | def test_validation(self): 10 | d = DateField() 11 | 12 | for good_object in (None, datetime.date(2015, 9, 30)): 13 | d.value = good_object 14 | 15 | for bad_object in ('2015-05-05', 0): 16 | with self.assertRaises(TypeError): 17 | d.value = bad_object 18 | -------------------------------------------------------------------------------- /test/test_document_change_tracker.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import xml.dom.minidom 3 | import threading 4 | import time 5 | from browsergui._documentchangetracker import * 6 | 7 | class DocumentChangeTrackerTest(unittest.TestCase): 8 | def setUp(self): 9 | self.document = xml.dom.minidom.parseString('') 10 | self.root = self.document.documentElement 11 | self.tracker = DocumentChangeTracker() 12 | 13 | def test_mark_dirty_awakens_waiting_threads(self): 14 | t = threading.Thread(target=self.tracker.flush_changes) 15 | t.start() 16 | time.sleep(0.01) 17 | self.tracker.mark_dirty(self.root) 18 | t.join() 19 | 20 | def test_flush_changes_doesnt_wait_if_already_dirty(self): 21 | self.tracker.mark_dirty(self.root) 22 | self.tracker.flush_changes() 23 | 24 | def test_mark_dirty__parent_overrides_child(self): 25 | e = self.document.createElement('floozle') 26 | e.setAttribute('id', 'floozle') 27 | self.root.appendChild(e) 28 | 29 | self.tracker.mark_dirty(e) 30 | self.tracker.mark_dirty(self.root) 31 | changes = self.tracker.flush_changes() 32 | self.assertIn('getElementById("root")', changes) 33 | self.assertNotIn('getElementById("floozle")', changes) 34 | 35 | self.tracker.mark_dirty(self.root) 36 | self.tracker.mark_dirty(e) 37 | changes = self.tracker.flush_changes() 38 | self.assertIn('getElementById("root")', changes) 39 | self.assertNotIn('getElementById("floozle")', changes) 40 | -------------------------------------------------------------------------------- /test/test_dropdown.py: -------------------------------------------------------------------------------- 1 | from browsergui import Dropdown, Text 2 | from . import BrowserGUITestCase 3 | 4 | def dropdown_xml(*options): 5 | return ''.format(''.join(''.format(s=s) for s in options)) 6 | 7 | class DropdownTest(BrowserGUITestCase): 8 | 9 | def test_construction(self): 10 | self.assertEqual(['a', 'b'], list(Dropdown(['a', 'b']))) 11 | 12 | with self.assertRaises(ValueError): 13 | Dropdown([]) 14 | 15 | def test_options_must_be_strings(self): 16 | with self.assertRaises(TypeError): 17 | Dropdown([()]) 18 | 19 | d = Dropdown(['a']) 20 | with self.assertRaises(TypeError): 21 | d[0] = () 22 | with self.assertRaises(TypeError): 23 | d.insert(0, ()) 24 | 25 | def test_getitem(self): 26 | d = Dropdown(['a', 'b']) 27 | self.assertEqual(['a', 'b'], d[:]) 28 | self.assertEqual('a', d[0]) 29 | with self.assertRaises(IndexError): 30 | d[2] 31 | 32 | def test_delitem(self): 33 | d = Dropdown(['a', 'b']) 34 | 35 | del d[0] 36 | self.assertEqual('b', d[0]) 37 | with self.assertRaises(IndexError): 38 | d[1] 39 | 40 | self.assertHTMLLike(dropdown_xml('b'), d, ignored_attrs=['id', 'onchange', 'selected']) 41 | 42 | def test_delitem__marks_dirty(self): 43 | d = Dropdown(['a', 'b']) 44 | with self.assertMarksDirty(d): 45 | del d[0] 46 | 47 | def test_setitem(self): 48 | d = Dropdown(['a', 'b']) 49 | 50 | d[0] = 'c' 51 | self.assertEqual(d[0], 'c') 52 | self.assertEqual(2, len(d)) 53 | 54 | self.assertHTMLLike(dropdown_xml('c', 'b'), d, ignored_attrs=['id', 'onchange', 'selected']) 55 | 56 | def test_setitem__marks_dirty(self): 57 | d = Dropdown(['a']) 58 | with self.assertMarksDirty(d): 59 | d[0] = 'b' 60 | 61 | def test_insert(self): 62 | d = Dropdown(['b']) 63 | d.insert(0, 'a') 64 | d.insert(99, 'd') 65 | d.insert(-1, 'c') 66 | self.assertEqual(['a', 'b', 'c', 'd'], list(d)) 67 | self.assertHTMLLike(dropdown_xml('a', 'b', 'c', 'd'), d, ignored_attrs=['id', 'onchange', 'selected']) 68 | 69 | def test_insert__marks_dirty(self): 70 | d = Dropdown(['a']) 71 | with self.assertMarksDirty(d): 72 | d.insert(1, 'b') 73 | 74 | def test_tag(self): 75 | self.assertHTMLLike('', Dropdown(['a']), ignored_attrs=['id']) 76 | self.assertHTMLLike('', Dropdown(['a', 'b']), ignored_attrs=['id', 'onchange', 'selected']) 77 | 78 | def test_set_value(self): 79 | d = Dropdown(['a', 'b', 'c']) 80 | d.value = 'b' 81 | self.assertEqual('b', d.value) 82 | d.value = 'c' 83 | self.assertEqual('c', d.value) 84 | 85 | def test_validation(self): 86 | d = Dropdown(['a', 'b', 'c']) 87 | 88 | for good_object in ('a', 'b', 'c', u'a'): 89 | d.value = good_object 90 | 91 | for bad_object in (0, []): 92 | with self.assertRaises(TypeError): 93 | d.value = bad_object 94 | 95 | for bad_object in ('not in it'): 96 | with self.assertRaises(ValueError): 97 | d.value = bad_object 98 | 99 | def test_def_change_callback(self): 100 | xs = [] 101 | d = Dropdown(['a', 'b']) 102 | @d.def_change_callback 103 | def _(): 104 | xs.append(1) 105 | 106 | d.value = 'b' 107 | self.assertEqual([1], xs) 108 | -------------------------------------------------------------------------------- /test/test_element.py: -------------------------------------------------------------------------------- 1 | import json 2 | from browsergui import Element, Container, Event, Click 3 | 4 | from . import BrowserGUITestCase 5 | 6 | class ElementTest(BrowserGUITestCase): 7 | 8 | def test_construction(self): 9 | Element(tag_name="a") 10 | -------------------------------------------------------------------------------- /test/test_events.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import xml.dom.minidom 3 | 4 | from browsergui.events import Event, Click 5 | 6 | class EventTest(unittest.TestCase): 7 | def test_enable_server_notification(self): 8 | tag = xml.dom.minidom.parseString('').documentElement 9 | Click.enable_server_notification(tag) 10 | self.assertEqual( 11 | 'notify_server({target_id: this.getAttribute("id"), type_name: event.type})', 12 | tag.getAttribute('onclick')) 13 | 14 | def test_disable_server_notification(self): 15 | tag = xml.dom.minidom.parseString('').documentElement 16 | Click.enable_server_notification(tag) 17 | Click.disable_server_notification(tag) 18 | self.assertFalse(tag.getAttribute('onclick')) 19 | 20 | with self.assertRaises(KeyError): 21 | Click.disable_server_notification(tag) 22 | 23 | def test_from_dict(self): 24 | d = dict(type_name=Click.javascript_type_name, target_id="foo") 25 | e = Event.from_dict(d) 26 | self.assertIsInstance(e, Click) 27 | self.assertEqual('foo', e.target_id) 28 | -------------------------------------------------------------------------------- /test/test_grid.py: -------------------------------------------------------------------------------- 1 | from browsergui import Grid, Text 2 | from . import BrowserGUITestCase 3 | 4 | def grid_of_text_xml(*textss): 5 | return '{}
'.format( 6 | ''.join( 7 | '{}'.format( 8 | ''.join( 9 | '{}'.format(text) 10 | if text is not None 11 | else '' 12 | for text in texts)) 13 | for texts in textss)) 14 | 15 | class ButtonTest(BrowserGUITestCase): 16 | def test_construction(self): 17 | Grid(n_rows=3, n_columns=3) 18 | 19 | with self.assertRaises(TypeError): 20 | Grid(n_rows=-3, n_columns=3) 21 | 22 | with self.assertRaises(TypeError): 23 | Grid(n_rows='hi', n_columns=3) 24 | with self.assertRaises(TypeError): 25 | Grid(n_rows=3, n_columns='hi') 26 | 27 | with self.assertRaises(TypeError): 28 | Grid([['hi']]) 29 | 30 | def test_constructor_guesses_dimensions(self): 31 | g = Grid([[Text('a')], [Text('b'), None, Text('d')]]) 32 | self.assertEqual(2, g.n_rows) 33 | self.assertEqual(3, g.n_columns) 34 | 35 | def test_getitem(self): 36 | a, b, c, d = Text('a'), Text('b'), Text('c'), Text('d') 37 | g = Grid([[a,b], [c,d]]) 38 | 39 | with self.assertRaises(TypeError): 40 | g[0] 41 | with self.assertRaises(TypeError): 42 | g[0,'hi'] 43 | with self.assertRaises(IndexError): 44 | g[3,0] 45 | 46 | self.assertEqual(a, g[0,0]) 47 | self.assertEqual([a,b], g[0,:]) 48 | self.assertEqual([a,c], g[:,0]) 49 | 50 | def test_setitem(self): 51 | a, b, c, d = Text('a'), Text('b'), Text('c'), Text('d') 52 | g = Grid([[a], [c, d]]) 53 | 54 | g[0, 1] = b 55 | self.assertEqual(g, b.parent) 56 | self.assertEqual([a,b,c,d], list(g.children)) 57 | self.assertEqual(b, g[0,1]) 58 | self.assertUnstyledHTMLLike(grid_of_text_xml(['a','b'], ['c','d']), g) 59 | 60 | t = Text('t') 61 | g[0,1] = t 62 | self.assertIsNone(b.parent) 63 | self.assertEqual(g, t.parent) 64 | self.assertEqual([a,t,c,d], list(g.children)) 65 | self.assertEqual(t, g[0,1]) 66 | self.assertUnstyledHTMLLike(grid_of_text_xml(['a','t'], ['c','d']), g) 67 | 68 | def test_setitem__marks_dirty(self): 69 | g = Grid([[Text('before')]]) 70 | with self.assertMarksDirty(g): 71 | g[0,0] = Text('after') 72 | 73 | def test_delitem(self): 74 | a, b, c, d = Text('a'), Text('b'), Text('c'), Text('d') 75 | g = Grid([[a], [c, d]]) 76 | 77 | del g[1,0] 78 | self.assertIsNone(c.parent) 79 | self.assertEqual([a,d], list(g.children)) 80 | self.assertUnstyledHTMLLike(grid_of_text_xml(['a',None],[None,'d']), g) 81 | 82 | def test_delitem__marks_dirty(self): 83 | g = Grid([[Text('a')]]) 84 | with self.assertMarksDirty(g): 85 | del g[0,0] 86 | 87 | def test_set_n_rows(self): 88 | a, b, c, d = Text('a'), Text('b'), Text('c'), Text('d') 89 | g = Grid([[a, b], [c, d]]) 90 | 91 | g.n_rows = 1 92 | self.assertEqual(g, a.parent) 93 | self.assertEqual(g, b.parent) 94 | self.assertIsNone(c.parent) 95 | self.assertIsNone(d.parent) 96 | self.assertEqual([a,b], list(g.children)) 97 | self.assertEqual(1, g.n_rows) 98 | with self.assertRaises(IndexError): 99 | g[1,0] 100 | self.assertUnstyledHTMLLike(grid_of_text_xml(['a','b']), g) 101 | 102 | g.n_rows = 2 103 | self.assertIsNone(g[1,0]) 104 | self.assertEqual([a,b], list(g.children)) 105 | g[1,0] = c 106 | self.assertEqual(g, c.parent) 107 | self.assertEqual([a,b,c], list(g.children)) 108 | self.assertUnstyledHTMLLike(grid_of_text_xml(['a','b'],['c',None]), g) 109 | 110 | def test_set_n_rows_to_0(self): 111 | g = Grid(n_rows=2, n_columns=1) 112 | g.n_rows = 0 113 | self.assertUnstyledHTMLLike('
', g) 114 | g.n_rows = 1 115 | self.assertUnstyledHTMLLike('
', g) 116 | 117 | def test_set_n_columns_to_0(self): 118 | g = Grid(n_rows=1, n_columns=2) 119 | g.n_columns = 0 120 | self.assertUnstyledHTMLLike('
', g) 121 | g.n_columns = 1 122 | self.assertUnstyledHTMLLike('
', g) 123 | 124 | 125 | def test_set_n_columns(self): 126 | a, b, c, d = Text('a'), Text('b'), Text('c'), Text('d') 127 | g = Grid([[a, b], [c, d]]) 128 | 129 | g.n_columns = 1 130 | self.assertEqual(g, a.parent) 131 | self.assertEqual(g, c.parent) 132 | self.assertIsNone(b.parent) 133 | self.assertIsNone(d.parent) 134 | self.assertEqual([a,c], list(g.children)) 135 | self.assertEqual(1, g.n_columns) 136 | with self.assertRaises(IndexError): 137 | g[0,1] 138 | self.assertUnstyledHTMLLike(grid_of_text_xml(['a'],['c']), g) 139 | 140 | g.n_columns = 2 141 | self.assertIsNone(g[0,1]) 142 | self.assertEqual([a,c], list(g.children)) 143 | g[0,1] = b 144 | self.assertEqual(g, b.parent) 145 | self.assertEqual([a,b,c], list(g.children)) 146 | self.assertUnstyledHTMLLike(grid_of_text_xml(['a','b'], ['c', None]), g) 147 | 148 | def test_set_dimensions__marks_dirty(self): 149 | g = Grid([[Text('a')]]) 150 | with self.assertMarksDirty(g): 151 | g.n_rows = 2 152 | with self.assertMarksDirty(g): 153 | g.n_rows = 0 154 | with self.assertMarksDirty(g): 155 | g.n_rows = 1 156 | with self.assertMarksDirty(g): 157 | g.n_columns = 2 158 | with self.assertMarksDirty(g): 159 | g.n_columns = 0 160 | with self.assertMarksDirty(g): 161 | g.n_columns = 1 162 | 163 | def test_tag(self): 164 | self.assertUnstyledHTMLLike('
', Grid(n_rows=0, n_columns=0)) 165 | self.assertUnstyledHTMLLike('
', Grid(n_rows=0, n_columns=1)) 166 | self.assertUnstyledHTMLLike('
', Grid(n_rows=1, n_columns=0)) 167 | self.assertUnstyledHTMLLike('
', Grid(n_rows=1, n_columns=1)) 168 | self.assertUnstyledHTMLLike('
', Grid(n_rows=1, n_columns=2)) 169 | 170 | def test_cell_styling(self): 171 | g = Grid(n_rows=1, n_columns=1) 172 | self.assertEqual('border: 1px solid black', g.tag.childNodes[0].childNodes[0].getAttribute('style')) 173 | -------------------------------------------------------------------------------- /test/test_gui.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import contextlib 3 | import time 4 | from browsergui import GUI, Text, Button, Event, Click 5 | 6 | from . import BrowserGUITestCase 7 | 8 | 9 | class GUITest(BrowserGUITestCase): 10 | 11 | def run_quietly(self, gui): 12 | gui.run(quiet=True, open_browser=False) 13 | 14 | @contextlib.contextmanager 15 | def running_in_background(self, gui, stop_running=True): 16 | t = threading.Thread(target=self.run_quietly, args=[gui]) 17 | t.start() 18 | time.sleep(0.01) # give server time to boot up 19 | yield 20 | if stop_running: 21 | gui.stop_running() 22 | t.join(0.01) # give server time to shut down 23 | if t.is_alive(): 24 | raise AssertionError('gui did not stop running') 25 | 26 | def test_construction(self): 27 | gui = GUI() 28 | 29 | GUI(Text("left"), Text("hi")) 30 | 31 | with self.assertRaises(TypeError): 32 | gui = GUI(0) 33 | 34 | def test_event_dispatch(self): 35 | decoy1 = Button() 36 | button = Button() 37 | decoy2 = Button() 38 | 39 | xs = [] 40 | button.callback = (lambda: xs.append(1)) 41 | 42 | gui = GUI(decoy1, button, decoy2) 43 | 44 | gui.dispatch_event(Click(target_id=button.id)) 45 | self.assertEqual([1], xs) 46 | 47 | def test_run(self): 48 | # Just make sure that modifications before/during/after runs don't blow up, 49 | # and that stop_running() terminates the run()-thread. 50 | gui = GUI(Text('before first run')) 51 | 52 | with self.running_in_background(gui): 53 | gui.body.append(Text('during first run')) 54 | 55 | gui.body.append(Text('before second fun')) 56 | 57 | with self.running_in_background(gui): 58 | gui.body.append(Text('during second run')) 59 | 60 | def test_run__raises_if_running(self): 61 | gui = GUI() 62 | 63 | with self.running_in_background(gui): 64 | with self.assertRaises(RuntimeError): 65 | self.run_quietly(gui) 66 | 67 | def test_stop_running__raises_if_not_running(self): 68 | gui = GUI() 69 | with self.assertRaises(RuntimeError): 70 | gui.stop_running() 71 | 72 | # Also make sure it raises if the GUI ran in the past, but then stopped. 73 | with self.running_in_background(gui): 74 | pass 75 | with self.assertRaises(RuntimeError): 76 | gui.stop_running() 77 | -------------------------------------------------------------------------------- /test/test_image.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import tempfile 3 | import base64 4 | from browsergui import Image 5 | from . import BrowserGUITestCase 6 | 7 | class ImageTest(BrowserGUITestCase): 8 | def set_file(self, contents, extension): 9 | if self.file is not None: 10 | self.file.close() 11 | 12 | self.file_format = extension 13 | self.file_contents = contents 14 | self.file = tempfile.NamedTemporaryFile(suffix=('' if extension is None else '.'+extension)) 15 | self.file.write(contents) 16 | self.file.flush() 17 | 18 | def setUp(self): 19 | self.file = None 20 | self.set_file(contents=b'initial contents', extension='png') 21 | 22 | def tearDown(self): 23 | self.file.close() 24 | 25 | def test_construction_from_filename(self): 26 | image = Image(self.file.name) 27 | self.assertEqual(self.file.name, image.filename) 28 | self.assertEqual(self.file_format, image.format) 29 | self.assertEqual(self.file_contents, image.data) 30 | 31 | def test_reload_data(self): 32 | image = Image(self.file.name) 33 | old_contents = self.file_contents 34 | self.assertEqual(old_contents, image.data) 35 | 36 | new_contents = b'new contents' 37 | self.file.seek(0, 0) 38 | self.file.write(new_contents) 39 | self.file.truncate() 40 | self.file.flush() 41 | 42 | self.assertEqual(old_contents, image.data) 43 | image.reload_data() 44 | self.assertEqual(new_contents, image.data) 45 | 46 | def test_reload_data__marks_dirty(self): 47 | image = Image(self.file.name) 48 | with self.assertMarksDirty(image): 49 | image.reload_data() 50 | 51 | def test_construction_from_filename__file_not_found(self): 52 | expected_error_type = FileNotFoundError if sys.version_info > (3, 3) else IOError 53 | with self.assertRaises(expected_error_type): 54 | Image('/nonexistent.png') 55 | 56 | def test_construction_from_filename__filename_has_no_format(self): 57 | self.set_file(contents=b'whatever', extension=None) 58 | with self.assertRaises(ValueError): 59 | Image(self.file.name) 60 | 61 | def test_data_not_settable(self): 62 | with self.assertRaises(AttributeError): 63 | Image(self.file.name).data = b'new contents' 64 | 65 | def test_tag(self): 66 | expected_html = ''.format(self.file_format, base64.b64encode(self.file_contents).decode('ascii')) 67 | self.assertHTMLLike(expected_html, Image(self.file.name)) 68 | -------------------------------------------------------------------------------- /test/test_link.py: -------------------------------------------------------------------------------- 1 | from browsergui import Link 2 | from . import BrowserGUITestCase 3 | 4 | class LinkTest(BrowserGUITestCase): 5 | def test_construction(self): 6 | link = Link('I am a link!', 'http://google.com') 7 | self.assertEqual('I am a link!', link.text) 8 | self.assertEqual('http://google.com', link.url) 9 | 10 | def test_set_url__marks_dirty(self): 11 | link = Link('foo', 'http://example.com') 12 | with self.assertMarksDirty(link): 13 | link.url = 'http://google.com' 14 | 15 | def test_tag(self): 16 | self.assertHTMLLike('
Google', Link('Google', 'http://google.com')) 17 | -------------------------------------------------------------------------------- /test/test_list.py: -------------------------------------------------------------------------------- 1 | from browsergui import List, Text 2 | from . import BrowserGUITestCase 3 | 4 | def list_of_texts_xml(*strings): 5 | return '
    {}
'.format(''.join('
  • {}
  • '.format(s) for s in strings)) 6 | 7 | class ListTest(BrowserGUITestCase): 8 | 9 | def test_construction(self): 10 | List() 11 | List(items=[List()]) 12 | List(numbered=True) 13 | List(numbered=False) 14 | 15 | def test_children(self): 16 | first = List() 17 | second = List() 18 | top = List(items=(first, second)) 19 | self.assertEqual(list(top.children), [first, second]) 20 | 21 | def test_getitem(self): 22 | first = Text('1') 23 | second = Text('2') 24 | top = List(items=(first, second)) 25 | 26 | self.assertEqual(top[0], first) 27 | self.assertEqual(top[1], second) 28 | with self.assertRaises(IndexError): 29 | top[2] 30 | 31 | def test_delitem(self): 32 | first = Text('1') 33 | second = Text('2') 34 | top = List(items=(first, second)) 35 | 36 | del top[0] 37 | self.assertIsNone(first.parent) 38 | self.assertEqual(list(top.children), [second]) 39 | self.assertEqual(second, top[0]) 40 | with self.assertRaises(IndexError): 41 | top[1] 42 | 43 | self.assertHTMLLike(list_of_texts_xml('2'), top) 44 | 45 | def test_delitem__marks_dirty(self): 46 | l = List([Text('a')]) 47 | with self.assertMarksDirty(l): 48 | del l[0] 49 | 50 | def test_setitem(self): 51 | first = Text('1') 52 | second = Text('2') 53 | new = Text('new') 54 | top = List(items=(first, second)) 55 | 56 | top[0] = new 57 | self.assertEqual(top[0], new) 58 | self.assertIsNone(first.parent) 59 | self.assertEqual(top, new.parent) 60 | self.assertEqual(list(top.children), [new, second]) 61 | 62 | self.assertHTMLLike(list_of_texts_xml('new', '2'), top) 63 | 64 | def test_setitem__marks_dirty(self): 65 | l = List([Text('a')]) 66 | with self.assertMarksDirty(l): 67 | l[0] = Text('b') 68 | 69 | def test_insert(self): 70 | first = Text('1') 71 | second = Text('2') 72 | third = Text('3') 73 | fourth = Text('4') 74 | top = List() 75 | 76 | top.insert(0, second) 77 | self.assertEqual(top, second.parent) 78 | self.assertEqual(list(top.children), [second]) 79 | 80 | top.insert(0, first) 81 | self.assertEqual(list(top.children), [first, second]) 82 | 83 | top.insert(99, fourth) 84 | self.assertEqual(list(top.children), [first, second, fourth]) 85 | 86 | top.insert(-1, third) 87 | self.assertEqual(list(top.children), [first, second, third, fourth]) 88 | 89 | self.assertHTMLLike(list_of_texts_xml('1', '2', '3', '4'), top) 90 | 91 | def test_insert__marks_dirty(self): 92 | l = List() 93 | with self.assertMarksDirty(l): 94 | l.append(Text('a')) 95 | 96 | def test_children_must_be_elements(self): 97 | with self.assertRaises(TypeError): 98 | List(items=[0]) 99 | 100 | def test_set_numbered__marks_dirty(self): 101 | l = List(numbered=True) 102 | with self.assertMarksDirty(l): 103 | l.numbered = False 104 | with self.assertMarksDirty(l): 105 | l.numbered = True 106 | 107 | def test_tag(self): 108 | self.assertHTMLLike('
      ', List(numbered=False)) 109 | self.assertHTMLLike('
        ', List(numbered=True)) 110 | self.assertHTMLLike('
        1. hi
        ', List(items=[Text("hi")])) 111 | -------------------------------------------------------------------------------- /test/test_number_field.py: -------------------------------------------------------------------------------- 1 | import fractions 2 | from browsergui import NumberField 3 | from . import BrowserGUITestCase 4 | 5 | class NumberFieldTest(BrowserGUITestCase): 6 | def test_validation(self): 7 | n = NumberField() 8 | 9 | for good_object in (None, 0, 1, 1.1, -1.1, fractions.Fraction(1,1)): 10 | n.value = good_object 11 | 12 | for bad_object in (1j, []): 13 | with self.assertRaises(TypeError): 14 | n.value = bad_object 15 | -------------------------------------------------------------------------------- /test/test_server.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import browsergui 3 | import threading 4 | import sys 5 | if sys.version_info >= (3, 0): 6 | from http.client import HTTPConnection 7 | else: 8 | from httplib import HTTPConnection 9 | 10 | from browsergui._server import make_request_handler_class_for_gui, ThreadedHTTPServer 11 | 12 | class ServerTest(unittest.TestCase): 13 | def setUp(self): 14 | self.gui = browsergui.GUI() 15 | self.handler_class = make_request_handler_class_for_gui(self.gui, quiet=True) 16 | self.server = ThreadedHTTPServer(('localhost', 0), self.handler_class) 17 | 18 | def tearDown(self): 19 | self.server.socket.close() 20 | 21 | def request(self, *args, **kwargs): 22 | timeout = kwargs.pop('timeout', 1) 23 | thread = threading.Thread(target=self.server.handle_request) 24 | thread.daemon = True 25 | thread.start() 26 | conn = HTTPConnection('localhost', self.server.socket.getsockname()[1]) 27 | conn.request(*args, **kwargs) 28 | result = conn.getresponse().read() 29 | 30 | thread.join(timeout) 31 | if thread.is_alive(): 32 | raise Exception('request-handling thread timed out') 33 | 34 | return result 35 | 36 | def test_is_responsive(self): 37 | # This test assumes that the first request to /command will not block. 38 | # (At the time of writing, that's true, because the GUI sends a startup 39 | # command that wipes out and rewrites the entire document. If that stops 40 | # being true, this test will need to be changed.) 41 | self.assertTrue(self.request('GET', '/command')) 42 | -------------------------------------------------------------------------------- /test/test_slider.py: -------------------------------------------------------------------------------- 1 | from fractions import Fraction 2 | import datetime 3 | from browsergui import Slider, FloatSlider, IntegerSlider 4 | from . import BrowserGUITestCase 5 | 6 | class SliderTest(BrowserGUITestCase): 7 | 8 | def test_range_validation(self): 9 | s = FloatSlider(min=0, max=5) 10 | 11 | for good_value in [0, 1, 5]: 12 | s.value = good_value 13 | 14 | for bad_value in [-1, 6]: 15 | with self.assertRaises(ValueError): 16 | s.value = bad_value 17 | 18 | def test_bounds(self): 19 | s = FloatSlider(min=0, max=5) 20 | self.assertEqual(s.min, 0) 21 | self.assertEqual(s.max, 5) 22 | 23 | def test_set_bounds(self): 24 | s = FloatSlider(min=0, max=5) 25 | s.value = 3 26 | s.min = 1 27 | self.assertEqual(s.min, 1) 28 | self.assertEqual(s.value, 3) 29 | s.min = 3 30 | self.assertEqual(s.min, 3) 31 | self.assertEqual(s.value, 3) 32 | 33 | with self.assertRaises(ValueError): 34 | s.min = 4 35 | 36 | s.min = 0 37 | self.assertEqual(s.min, 0) 38 | 39 | s.max = 4 40 | self.assertEqual(s.max, 4) 41 | self.assertEqual(s.value, 3) 42 | s.max = 3 43 | self.assertEqual(s.max, 3) 44 | self.assertEqual(s.value, 3) 45 | 46 | with self.assertRaises(ValueError): 47 | s.max = 1 48 | 49 | def test_set_bounds__marks_dirty(self): 50 | s = FloatSlider(min=0, max=5) 51 | with self.assertMarksDirty(s): 52 | s.min = 1 53 | with self.assertMarksDirty(s): 54 | s.max = 4 55 | 56 | def test_set_value(self): 57 | s = FloatSlider(min=0, max=5) 58 | s.value = 3 59 | self.assertEqual(s.value, 3) 60 | 61 | with self.assertRaises(ValueError): 62 | s.value = -1 63 | with self.assertRaises(ValueError): 64 | s.value = 6 65 | 66 | self.assertEqual(s.value, 3) 67 | 68 | class FloatSliderTest(BrowserGUITestCase): 69 | 70 | def test_type_validation(self): 71 | s = FloatSlider(min=0, max=5) 72 | for good_value in [1, 2**0.5, Fraction(1, 10)]: 73 | s.value = good_value 74 | 75 | for bad_value in [[], '3', float('NaN'), float('inf'), float('-inf')]: 76 | with self.assertRaises(TypeError): 77 | s.value = bad_value 78 | 79 | class IntegerSliderTest(BrowserGUITestCase): 80 | def test_type_validation(self): 81 | s = IntegerSlider(min=0, max=10) 82 | 83 | for good_object in [0, 1, 10]: 84 | s.value = good_object 85 | 86 | for bad_object in [0.0, 1.2, 1j, []]: 87 | with self.assertRaises(TypeError): 88 | s.value = bad_object 89 | 90 | _DATE_ZERO = datetime.date(2015, 1, 1) 91 | class DateSlider(Slider): 92 | DISCRETE = True 93 | @staticmethod 94 | def value_to_number(x): 95 | if not isinstance(x, datetime.date): 96 | raise TypeError('expected date, got {}'.format(type(x).__name__)) 97 | return int(round((x-_DATE_ZERO).total_seconds() / datetime.timedelta(days=1).total_seconds())) 98 | @staticmethod 99 | def value_from_number(x): 100 | return _DATE_ZERO + datetime.timedelta(days=x) 101 | class DateSliderTest(BrowserGUITestCase): 102 | def test_type_validation(self): 103 | s = DateSlider(min=datetime.date(2015, 1, 1), max=datetime.date(2015, 1, 31)) 104 | 105 | for good_object in [datetime.date(2015, 1, 8)]: 106 | s.value = good_object 107 | 108 | for bad_object in [0, datetime.datetime(2015, 1, 1, 12, 0, 0)]: 109 | with self.assertRaises(TypeError): 110 | s.value = bad_object 111 | -------------------------------------------------------------------------------- /test/test_styler.py: -------------------------------------------------------------------------------- 1 | from browsergui import Text 2 | 3 | from . import BrowserGUITestCase 4 | 5 | class StylerTest(BrowserGUITestCase): 6 | 7 | def setUp(self): 8 | self.element = Text('') 9 | self.tag = self.element.tag 10 | self.css = self.element.css 11 | 12 | def test_setitem__sets_style(self): 13 | self.css['k'] = 'v' 14 | self.assertEqual('k: v', self.tag.getAttribute('style')) 15 | 16 | def test_setitem__marks_dirty(self): 17 | with self.assertMarksDirty(self.element): 18 | self.css['k'] = 'v' 19 | 20 | def test_delitem__sets_style(self): 21 | self.css['a'] = '1' 22 | self.css['b'] = '2' 23 | del self.css['a'] 24 | self.assertEqual('b: 2', self.tag.getAttribute('style')) 25 | 26 | def test_delitem__deletes_style_if_empty(self): 27 | self.css['a'] = '1' 28 | del self.css['a'] 29 | self.assertNotIn('style', self.tag.attributes.keys()) 30 | 31 | def test_delitem__marks_dirty(self): 32 | self.css['k'] = 'v' 33 | 34 | with self.assertMarksDirty(self.element): 35 | del self.css['k'] 36 | -------------------------------------------------------------------------------- /test/test_styling.py: -------------------------------------------------------------------------------- 1 | from browsergui import Text 2 | from . import BrowserGUITestCase 3 | 4 | class StylingTest(BrowserGUITestCase): 5 | def setUp(self): 6 | self.text = Text('Hi!') 7 | 8 | def test_initial_css(self): 9 | self.assertNotIn('color', self.text.css) 10 | self.assertEqual('red', Text('hi', css={'color': 'red'}).css['color']) 11 | 12 | def test_set_styles(self): 13 | self.text.css['color'] = 'red' 14 | self.assertEqual('red', self.text.css['color']) 15 | 16 | def test_delete_styles(self): 17 | self.text.css['color'] = 'red' 18 | del self.text.css['color'] 19 | self.assertNotIn('color', self.text.css) 20 | -------------------------------------------------------------------------------- /test/test_text.py: -------------------------------------------------------------------------------- 1 | from browsergui import Text 2 | from . import BrowserGUITestCase 3 | 4 | class TextTest(BrowserGUITestCase): 5 | def test_construction(self): 6 | text = Text("blah") 7 | self.assertEqual(text.text, "blah") 8 | 9 | with self.assertRaises(TypeError): 10 | Text(0) 11 | 12 | def test_tag(self): 13 | self.assertHTMLLike('Hi', Text('Hi')) 14 | 15 | def test_set_text__marks_dirty(self): 16 | t = Text('foo') 17 | with self.assertMarksDirty(t): 18 | t.text = 'bar' 19 | -------------------------------------------------------------------------------- /test/test_text_field.py: -------------------------------------------------------------------------------- 1 | from browsergui import TextField 2 | from browsergui.events import Input 3 | from . import BrowserGUITestCase 4 | 5 | 6 | class TextFieldTest(BrowserGUITestCase): 7 | def test_constructor(self): 8 | self.assertEqual('foo', TextField(value='foo').value) 9 | self.assertEqual('foo', TextField(placeholder='foo').placeholder) 10 | 11 | def test_set_value(self): 12 | e = TextField() 13 | e.value = 'foo' 14 | self.assertEqual('foo', e.value) 15 | self.assertEqual('foo', e.tag.getAttribute('value')) 16 | 17 | def test_set_value__marks_dirty(self): 18 | e = TextField() 19 | with self.assertMarksDirty(e): 20 | e.value = 'foo' 21 | 22 | def test_set_placeholder(self): 23 | e = TextField() 24 | e.placeholder = 'foo' 25 | self.assertEqual('foo', e.placeholder) 26 | self.assertEqual('foo', e.tag.getAttribute('placeholder')) 27 | 28 | def test_set_placeholder__marks_dirty(self): 29 | e = TextField() 30 | with self.assertMarksDirty(e): 31 | e.placeholder = 'foo' 32 | 33 | def test_change_callback(self): 34 | xs = [] 35 | e = TextField(change_callback=(lambda: xs.append(1))) 36 | e.value = 'hi' 37 | self.assertEqual([1], xs) 38 | 39 | xs = [] 40 | e.change_callback = (lambda: xs.append(2)) 41 | e.value = 'bye' 42 | self.assertEqual([2], xs) 43 | 44 | def test_validation(self): 45 | t = TextField() 46 | 47 | for good_object in ('', 'abc', u'abc', 'a b c'): 48 | t.value = good_object 49 | 50 | for bad_object in (None, 0, [], ()): 51 | with self.assertRaises(TypeError): 52 | t.value = bad_object 53 | 54 | def test_def_change_callback(self): 55 | xs = [] 56 | t = TextField() 57 | @t.def_change_callback 58 | def _(): 59 | xs.append(1) 60 | 61 | t.value = 'flub' 62 | self.assertEqual([1], xs) 63 | -------------------------------------------------------------------------------- /test/test_viewport.py: -------------------------------------------------------------------------------- 1 | from browsergui import Text, Viewport 2 | from . import BrowserGUITestCase 3 | 4 | class TextTest(BrowserGUITestCase): 5 | def test_construction(self): 6 | viewport = Viewport(Text('hi'), width=100, height=200) 7 | 8 | def test_construction_requires_dimensions(self): 9 | with self.assertRaises(TypeError): 10 | Viewport(Text('hi')) 11 | 12 | with self.assertRaises(TypeError): 13 | Viewport(Text('hi'), width=100) 14 | 15 | with self.assertRaises(TypeError): 16 | Viewport(Text('hi'), height=100) 17 | 18 | def test_construction_requires_element_child(self): 19 | with self.assertRaises(TypeError): 20 | Viewport(width=100, height=200) 21 | with self.assertRaises(TypeError): 22 | Viewport(0, width=100, height=200) 23 | 24 | def test_construction_requires_numeric_dimensions(self): 25 | Viewport(Text('hi'), width=100.0, height=200) 26 | 27 | with self.assertRaises(TypeError): 28 | Viewport(Text('hi'), width='a', height=200) 29 | 30 | def test_construction_requires_positive_dimensions(self): 31 | with self.assertRaises(ValueError): 32 | Viewport(Text('hi'), width=-10, height=200) 33 | with self.assertRaises(ValueError): 34 | Viewport(Text('hi'), width=200, height=-10) 35 | 36 | def test_dimensions_are_gettable_and_settable(self): 37 | viewport = Viewport(Text('hi'), width=200, height=100) 38 | self.assertEqual(200, viewport.width) 39 | self.assertEqual(100, viewport.height) 40 | 41 | viewport.width = 50 42 | self.assertEqual(50, viewport.width) 43 | 44 | viewport.height = 70 45 | self.assertEqual(70, viewport.height) 46 | 47 | def test_dimensions_must_be_numeric(self): 48 | viewport = Viewport(Text('hi'), width=200, height=100) 49 | with self.assertRaises(TypeError): 50 | viewport.width = 'a' 51 | with self.assertRaises(TypeError): 52 | viewport.height = 'a' 53 | 54 | self.assertEqual(200, viewport.width) 55 | self.assertEqual(100, viewport.height) 56 | 57 | def test_set_dimensions__marks_dirty(self): 58 | viewport = Viewport(Text('hi'), width=100, height=100) 59 | with self.assertMarksDirty(viewport): 60 | viewport.width = 200 61 | with self.assertMarksDirty(viewport): 62 | viewport.height = 200 63 | 64 | def test_set_target(self): 65 | old_target = Text('hi') 66 | viewport = Viewport(old_target, width=100, height=100) 67 | new_target = Text('bye') 68 | viewport.target = new_target 69 | self.assertIs(new_target, viewport.target) 70 | self.assertIs(viewport, new_target.parent) 71 | self.assertIsNone(old_target.parent) 72 | 73 | def test_set_target__marks_dirty(self): 74 | viewport = Viewport(Text('hi'), width=100, height=100) 75 | with self.assertMarksDirty(viewport): 76 | viewport.target = Text('bye') 77 | 78 | def test_tag(self): 79 | viewport = Viewport(Text('Hi'), width=50, height=60) 80 | self.assertHTMLLike('
        Hi
        ', viewport) 81 | -------------------------------------------------------------------------------- /test/test_xml_tag_shield.py: -------------------------------------------------------------------------------- 1 | from browsergui.elements import XMLTagShield 2 | 3 | from . import BrowserGUITestCase 4 | 5 | class XMLTagShieldTest(BrowserGUITestCase): 6 | 7 | def setUp(self): 8 | self.shield = XMLTagShield(tag_name='a') 9 | 10 | def test_children(self): 11 | self.assertEqual([], self.shield.children) 12 | left = XMLTagShield('l') 13 | right = XMLTagShield('r') 14 | self.shield.tag.appendChild(left.tag) 15 | self.assertEqual([left], self.shield.children) 16 | self.shield.tag.appendChild(right.tag) 17 | self.assertEqual([left, right], self.shield.children) 18 | self.shield.tag.removeChild(left.tag) 19 | self.assertEqual([right], self.shield.children) 20 | 21 | def test_children__deep(self): 22 | intermediate = self.shield.tag.ownerDocument.createElement('x') 23 | left = XMLTagShield('l') 24 | right = XMLTagShield('r') 25 | self.shield.tag.appendChild(intermediate) 26 | self.shield.tag.appendChild(right.tag) 27 | intermediate.appendChild(left.tag) 28 | self.assertEqual([left,right], self.shield.children) 29 | 30 | def test_children__prunes_at_immediate_children(self): 31 | child = XMLTagShield('c') 32 | grandchild = XMLTagShield('gc') 33 | self.shield.tag.appendChild(child.tag) 34 | child.tag.appendChild(grandchild.tag) 35 | self.assertEqual([child], self.shield.children) 36 | 37 | def test_parent(self): 38 | parent = XMLTagShield('p') 39 | parent.tag.appendChild(self.shield.tag) 40 | self.assertEqual(parent, self.shield.parent) 41 | 42 | def test_parent_deep(self): 43 | parent = XMLTagShield('p') 44 | intermediate = self.shield.tag.ownerDocument.createElement('x') 45 | parent.tag.appendChild(intermediate) 46 | intermediate.appendChild(self.shield.tag) 47 | self.assertEqual(parent, self.shield.parent) 48 | 49 | def test_ancestors(self): 50 | self.assertEqual([], self.shield.ancestors) 51 | 52 | child = XMLTagShield('c') 53 | grandchild = XMLTagShield('gc') 54 | self.shield.tag.appendChild(child.tag) 55 | child.tag.appendChild(grandchild.tag) 56 | self.assertEqual([child, self.shield], grandchild.ancestors) 57 | 58 | def test_orphaned(self): 59 | self.assertTrue(self.shield.orphaned) 60 | XMLTagShield('p').tag.appendChild(self.shield.tag) 61 | self.assertFalse(self.shield.orphaned) 62 | 63 | def test_root(self): 64 | self.assertEqual(self.shield, self.shield.root) 65 | 66 | parent = XMLTagShield('p') 67 | parent.tag.appendChild(self.shield.tag) 68 | self.assertEqual(parent, self.shield.root) 69 | 70 | intermediate = self.shield.tag.ownerDocument.createElement('x') 71 | grandparent = XMLTagShield('gp') 72 | grandparent.tag.appendChild(intermediate) 73 | intermediate.appendChild(parent.tag) 74 | self.assertEqual(grandparent, self.shield.root) 75 | --------------------------------------------------------------------------------