7GUIs Pyscript - Explanations and Details
Published May 10, 2022
This post is a companion to my project The 7 GUIs in PyScript - I recommend checking out that page first. Viewing on Desktop is highly recommended.
The Seven Guis is a set of typical challenges in GUI programming. Implementations abound, in lower-level frameworks like tcl and Qt to modernist frameworks like React and Svelte. Let's see what it takes to implement them in PyScript.
Counter
"The task is to build a frame containing a label or read-only textfield T and a button B. Initially, the value in T is “0” and each click of B increases the value in T by one."
We can break the parts of this initial problem into a few key questions, with answers:
How do buttons works in PyScript
When you add a <py-button> tag to your page, on page-load, PyScript (specifically the code in pybutton.ts adds a <button> tag to the DOM with all the same classes that the py-button tag had. Then, if the Python code inside the py-button tag defines an on_focus
or on_click
method, callbacks are registered in Javascript to cause those methods to run on focus/click as appropriate.
So, to create a "Count" button to increment our counter, we can do something as simple as:
my-page.html
|
|
How do we put output from a script in a specific place on a page?
Of course, we'll need to actually define that add_one
function somewhere, to add one to...
Well, I guess we'll want somewhere to display the count as well. Lets add a paragraph tag to our HTML code, and give it an id so we can refer to it later:
<p id="counter-target">Some Placeholder Text</p>
We could choose to leave the tag empty for now, or perhaps have a "0" there to hide a bit of ugliness as the page loads, but I having placeholder text will give us a clearer view of what's happening when.
To actually change the content of our new tag, we can use the PyScript.write() function, which takes an element_id
as a string, a value
to replace/append there (another string), and an optional append
argument to tell whether the new content should be appended (as a new div) or replace the existing content.
So, to start our count at zero and have it increment each time we press the "Count" button, our code could look something like:
myPage.html
|
|
How do we seperate Python code into external files?
For cleanliness, let's put our code in a separate file called counter.py
. To include use this in our html page, we simple use the src
attribute of the py-script tag to specify an additional external source. Thus, our complete solution looks like:
myPage.html
|
|
counter.py
|
|
Temperature Converter
"The task is to build a frame containing two textfields TC and TF representing the temperature in Celsius and Fahrenheit, respectively. Initially, both TC and TF are empty. When the user enters a numerical value into TC the corresponding value in TF is automatically updated and vice versa. When the user enters a non-numerical string into TC the value in TF is not updated and vice versa. The formula for converting a temperature C in Celsius into a temperature F in Fahrenheit is C = (F - 32) * (5/9) and the dual direction is F = C * (9/5) + 32."
Fahrenheit
Celsuis
Since we're handle user-inputted text for this project, we'll need to learn a bit about how PyScript interacts with Javascript event listeners. Let's look at a stripped-down example:
sample-event-handling.html
|
|
|
|
First, we create the html element we want to target. We'll give it the unique id "my-input" so we can select it later. (You'll probably want a more specific descriptor than this.) The styling is just to make it easier to find on the screen, if you drop just this code into a blank page.
|
|
Next, we'll import some useful modules. Through some Pyodide dark incantation magic, importing from JS gives us a Python mapping of a Javascript module directly! So now we have access to the JS 'document' and 'console' objects, though we could also directty import anything in the Javascript global scope. How cool is that.
|
|
This is where the real magic happens. We'll define our python function using the usual def functionname():
syntax. It will take one parameter, which i've called e
, which will be passed the Javascript event that triggered this function. These events have many, many useful properties and methods we can access - in this case, the value
property gives us the value of the inputbox that triggered this event.
The trick is, because of how Pyodide interacts with Javascript promises, we can't just use this Python function as our event handler. We'll need to create a Javascript proxy object for it using create_proxy. This returns a new proxy object that we can use directly as our event handler. (This issue is common enough that its included in Pyodide's FAQ.
Once we have our proxy object, we can again lean on that magic js-to-python mapping to use Javascript's own querySelector
and addEventListener methods to add a callback that will run our method whenever the specified event happens - in this case, "input". Note that this is not the "on-" version of the event keywords; that is, it's "input" not "oninput"; "click", not "onclick", and so on.
And here's that example running live:
Open the developer console and type here:
With this in place, if you type into the inputbox, you should see its contents being output to the console with each keystroke. If you want to have it log (or take any other action) only when the input is submitted/enter is pressed... I think the best option is to wrap the input in a form
tag and use the "submit" event to handle it, but I'm not %100 sure what best practice is there.
The full code of the Temperature Converter is as follows:
my-page.html
|
|
temperature.py
|
|
Flight Booker
"The task is to build a frame containing a combobox C with the two options “one-way flight” and “return flight”, two textfields T1 and T2 representing the start and return date, respectively, and a button B for submitting the selected flight. T2 is enabled iff C’s value is “return flight”. When C has the value “return flight” and T2’s date is strictly before T1’s then B is disabled. When a non-disabled textfield T has an ill-formatted date then T is colored red and B is disabled. When clicking B a message is displayed informing the user of his selection (e.g. “You have booked a one-way flight on 04.04.2014.”). Initially, C has the value “one-way flight” and T1 as well as T2 have the same (arbitrary) date (it is implied that T2 is disabled).
Departure Date
Return Date
Flight Info will go here
Not too many additional puzzle pieces to fill in here, after the first two examples. We'll make use of the disabled
property to control whether the 'return' inputbox is active or not, setting it to true
to disable the box. We'll also use the innerText
property of the <p>
tag at the bottom of the GUI to set its text when the user presses the 'book-flight' button.
As mentioned in the Temperature Converter section, we cannot call our Python functions directly from event handlers - we'll need to use pyodide.create_proxy to create a Javascript proxy of our function, and have the event trigger that.
The full code of this solution is as follows:
my-page.html
|
|
flight.py
|
|
Timer
The task is to build a frame containing a gauge G for the elapsed time e, a label which shows the elapsed time as a numerical value, a slider S by which the duration d of the timer can be adjusted while the timer is running and a reset button R. Adjusting S must immediately reflect on d and not only when S is released. It follows that while moving S the filled amount of G will (usually) change immediately. When e ≥ d is true then the timer stops (and G will be full). If, thereafter, d is increased such that d > e will be true then the timer restarts to tick until e ≥ d is true again. Clicking R will reset e to zero.
Elapsed Time:
Seconds
Duration
We'll explore a slightly different style of interactivity with this one - using an infinite loop to constantly update the timer as tracked, and update the values of the onscreen label and slider. Before we jump into this infinite loop, we'll set up an event listener to handle pressing the 'reset' button.
But note! By doing this, we'll trap the Python interpretter in an infinite loop, and it won't be able to do anything else. Which is fine, so long as you're only running a single "script" on one page... but if you look at the source of this very page, for example, you've notice timer.py
is imported at the very end of the body
section. Why? Because if we get trapped in an infinite loop at this point in the page, we'll never even load the following examples!
Handily, we don't actually need an separate event handler to handle the changing of the input slider (though that would also be a valid away to do it). Instead, we can directly read the value of the slider each time through out loop using the value
property of the slider to get its current value.
The full code of this solution is as follows:
my-page.html
|
|
timer.py
|
|
CRUD
The task is to build a frame containing the following elements: a textfield Tprefix, a pair of textfields Tname and Tsurname, a listbox L, buttons BC, BU and BD and the three labels as seen in the screenshot. L presents a view of the data in the database that consists of a list of names. At most one entry can be selected in L at a time. By entering a string into Tprefix the user can filter the names whose surname start with the entered prefix—this should happen immediately without having to submit the prefix with enter. Clicking BC will append the resulting name from concatenating the strings in Tname and Tsurname to L. BU and BD are enabled iff an entry in L is selected. In contrast to BC, BU will not append the resulting name but instead replace the selected entry with the new name. BD will remove the selected entry. The layout is to be done like suggested in the screenshot. In particular, L must occupy all the remaining space.
Filter Prefix:
Name:
Surname:
This is the first challenge where we get to play a little bit with DOM manipulation. So far we've only been reading/manipulating the values of inputs and textboxes - now we'll actually add and remove elements.
To do this, we'll use the document.creteElement() method, which takes a tag name as a string (like p
or div
) and creates a tag of that type. We can then set the value
of that tag (if appropriate for an input-like object), its text
, innerHTML
, and so on. We can then add that tag as a child of an existing DOM element by calling myOtherElement.appendChild(myNewTagElement)
.
I will admit to somewhat brute-forcing the issue removal and replacement of 'database' entries by wiping the list view of all entries and re-displaying them each time the user takes an action the modifies the list. This is certainly not the most efficient way to handle things. For a better example of managing the state of a list of objects, see the Circle Drawer example.
I also took the opportunity to introduce Dataclasses here, a really useful tool if you haven't encountered them before. They really simply small container classes - no more writing __str__
, __repr__
, or even __init__
by hand! There's a great video about Dataclasses from mCoding the explains this in greater detail.
The full code of this solution is as follows:
my-page.html
|
|
crud.py
|
|
Circle Drawer
The task is to build a frame containing an undo and redo button as well as a canvas area underneath. Left-clicking inside an empty area inside the canvas will create an unfilled circle with a fixed diameter whose center is the left-clicked point. The circle nearest to the mouse pointer such that the distance from its center to the pointer is less than its radius, if it exists, is filled with the color gray. The gray circle is the selected circle C. Right-clicking C will make a popup menu appear with one entry “Adjust diameter..”. Clicking on this entry will open another frame with a slider inside that adjusts the diameter of C. Changes are applied immediately. Closing this frame will mark the last diameter as significant for the undo/redo history. Clicking undo will undo the last significant change (i.e. circle creation or diameter adjustment). Clicking redo will reapply the last undoed change unless new changes were made by the user in the meantime.
Oh boy we get to play with the canvas! There are almost-certainly Javascript libraries for handling onscreen objects as sprites, with undo-redo perhaps, but the whole point of this challenge is to learn by doing. So I'll start with a bare canvas object and work up from there.
When thinking about a somewhat-involved challenge like this, it's useful to break it down into managable chunks. I figured I'd get circles being drawn with a mouse-click, then figure out the right-click-to-change-size functionality, then work on undo/redo.
Thanks again to Pyodide's marvelous JS-to-Python mapping, we can directly use all the methods available in the CanvasRenderingContext2D object to draw to our existing canvas. The arc
method is perfect for drawing circles, and stroke
or fill
actually place the drawn strokes on the canvas.
With just those simple functions in place, if we hook up an eventListener to listen for the mousedown
event, which relies on our _draw_circle function, we can pretty quickly begin clicking away:
canvas-context-examples.py
|
|
As far as handling the custom right-click menu, I found this guide from geeksforgeeks to be useful. Basically, you create a div
somewhere on your page that holds the contents of your new menu. Then you set its style to display:none
so it doesn't actually appear. When you want it to show up, to change its left
and top
properties to match the current position of the mouse and set its display stlye to block
. Voila, the div appears where you clicked.
The nice thing about handing the menu as a div (as opposed to, say, defining our own custom piece of interactive GUI) is that we can make use of all the functionality that native HTML elements provide already. Our menu can have labels, inputs of any kind, even addtional canvases.
There's a little extra legwork to do to make sure that the browser's native right-click menu doesn't also appear. With e
as the event that the eventListener passed to our function, we can prevent the default right-click menu from opening by calling e.preventDefault()
, e.stopPropagation()
, and returning false
from our handler function.
Finally, for the undo/redo functionality, we need to actually start tracking our circles as objects. This is the point when Circle became a Dataclass in the code. We also need to track the changes-in-diameter that are made to the circles, so a ResizeOperation Dataclass was born. Each time the user takes an action, a new object (Circle or ResizeOperation) is appended to a list of actions, and a pointer to the most-recent action is incremented by one. When the user presses undo, if the pointed-to action is a ResizeOperation, we reverse the resizing of the appropriate Circle, and either way, the pointer is decremented by 1. We then set the rendering function to only draw circles that exist earlier than our pointer in our list of actions. A redo operation is similar, resizing circles as necessary and incrementing the pointer. Finally, we adjust out functions for drawing new circles and resizing them to always truncate the list of actions after the current point, and set the pointer to the end of our list of actions.
If the preceding paragaph was just so much word-spaghetti, the full code of this solution is as follows:
my-page.html
|
|
circle.py
|
|
Spreadsheet
The task is to create a simple but usable spreadsheet application. The spreadsheet should be scrollable. The rows should be numbered from 0 to 99 and the columns from A to Z. Double-clicking a cell C lets the user change C’s formula. After having finished editing the formula is parsed and evaluated and its updated value is shown in C. In addition, all cells which depend on C must be reevaluated. This process repeats until there are no more changes in the values of any cell (change propagation). Note that one should not just recompute the value of every cell but only of those cells that depend on another cell’s changed value. If there is an already provided spreadsheet widget it should not be used. Instead, another similar widget (like JTable in Swing) should be customized to become a reusable spreadsheet widget.
I will entirely own to not-quite-finishing this challenge, in that I didn't actually implement the 'cells-can-refer-to-other-cells' component of it that actually makes it a Spreadsheet and not a big grid of calculators. Ah well, perhaps you'll forgive me. The error handling is also quite bad.
This is the first time I've had cause to use the <py-env>
tag in these challenges. This takes a toml-style list of additional modules to import from PYPI (via micropip), as well as a list of additional local paths that one can import from. In my case, I broke out my code into a couple of additional Python files, so my <py-env>
tag looked like this:
py-env-example.html
|
|
I could spend a whole post talking about the logic in formula-parser.py
, but since this is really more of a PyScript adventure and not so much just Python, I'll leave you to explore that code on your own if you're interested. Let's talk about the setup/HTML parts.
The grid cells themselves are all input
tags, which are generated at runtime by the create_cells()
function. Each one is assigned an ID based on its column and row, which we'll use later to read and assign contents to it. We'll store the representation of our data separately as a Spreadsheet object, and use that to render the contents of each input as needed.
The Spreadsheet object has to be a bit clever, since it seeds to be able to hold both the input the user typed into a cell, as well as determine the value of an input (if it's an equation) and present that back to the interface. To that end, the UI can ask either getRawValue()
to retrieve what the user actually typed in, or getRenderedValue()
to process the equation represented by the raw value, if any.
The full code of this solution is as follows:
my-page.html
|
|
cells-table.py
|
|
spreadsheet.py
|
|
formula_parser.py
|
|
If you've made it this far down the page, I'm truly honored. I'm just a guy who loves Python and playing with code, and I'd love to hear what you think of PyScript.