├── .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 '