7GUIs Pyscript - Explanations and Details

Tags: Python PyScript

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."

def on_click(event): add_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

1
2
3
4
<py-button>
    def on_click(event):
        add_one()
</py-button>

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

1
2
3
4
5
6
7
<p id="counter-target">Some Placeholder Text</p>
<py-button>
    def on_click(event):
        add_one()
</py-button>
<br>
<py-script src="./counter.py"></py-script>

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

1
2
3
4
5
6
<p id="counter-target">Some Placeholder Text</p>
<py-button>
    def on_click(event):
        add_one()
</py-button>
<py-script src="counter.py"></py-script>

counter.py

1
2
3
4
5
6
7
8
internalCount = 0
target = "counter-target"
PyScript.write(target, str(internalCount), append=False)

def add_one():
    global internalCount
    internalCount += 1
    PyScript.write(target, str(internalCount), append=False)

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

Celcius

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<input id="my-input" style="background-color: lightgray;">

<py-script>
from js import document, console
from pyodide import create_proxy

def _log_input_to_console(e):
    console.log("The value of the input is currently " + e.target.value)

log_input_to_console = create_proxy(_log_input_to_console)

document.getElementById("my-input").addEventListener("input", log_input_to_console)
</py-script>
3
<input id="my-input" style="background-color: lightgray;">

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.

4
5
from js import document, console
from pyodide import create_proxy

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.

 7
 8
 9
10
11
12
def _log_input_to_console(e)
    console.log("The value of the input is currently " + e.target.value)
        
log_input_to_console = create_proxy(_log_input_to_console)
        
document.getElementByID("my-input").addEventListener("input", log_input_to_console)

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:

from js import document, console from pyodide import create_proxy def _log_input_to_console(e): console.log("The value of the input is currently " + e.target.value) log_input_to_console = create_proxy(_log_input_to_console) document.getElementById("my-input").addEventListener("input", log_input_to_console)

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<py-script src="./temperature.py"></py-script>
<div class="grid grid-cols-2 p-4 m-auto bg-blue-100 border-2 justify-items-center">
    <div>
        <h4 class="font-semibold">Fahrenheit</h4>
        <input id = "f-temp" class="w-3/4 bg-white border-4">
    </div>
    <div>
        <h4 class="font-semibold">Celcius</h4>
        <input id="c-temp" class="w-3/4 bg-white border-4">
    </div>

temperature.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
from js import document

#This is necessary for reasons I don't understand
from pyodide import create_proxy

write_in_progress = False

def isTemp(input_temp):
    try:
        _ = float(input_temp)
    except Exception as err:
        return False
    
    return True

def _f(self, *args, **kwargs):
    global write_in_progress
    if write_in_progress:
        return
    else:
        write_in_progress = True
        f_input = document.getElementById("f-temp")
        c_output = document.getElementById("c-temp")
        input_value = f_input.value
        if isTemp(input_value):
            c_output.value = round((int(float(input_value)) - 32) * (5/9), 2)
        else:
            c_output.value = ""
        write_in_progress = False

def _c(self, *args, **kwargs):
    global write_in_progress
    if write_in_progress:
        return
    else:
        write_in_progress = True
        c_input = document.getElementById("c-temp")
        f_output = document.getElementById("f-temp")
        input_value = c_input.value
        if isTemp(input_value):
            f_output.value = round((int(float(input_value)) * (9/5)) + 32, 2)
        else:
            f_output.value = ""
        write_in_progress = False

f_change = create_proxy(_f)
c_change = create_proxy(_c)

document.querySelector("#f-temp").addEventListener("input", f_change)
document.querySelector("#c-temp").addEventListener("input", c_change)

Scroll to see complete code

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<py-script src="./flight.py"></py-script>
<div class="grid grid-rows-4 p-4 bg-blue-100 border-2 justify-items-left">
    <div>
        <select name="flight-mode" id="flight-mode-select">
            <option value="one">One Way</option>
            <option value="round">Round Trip</option>
        </select>
    </div>
    <div>
        <h4 class="font-semibold">Departure Date</h4>
        <input id = "dep" class="w-3/4 bg-white border-4">
    </div>
    <div>
        <h4 class="font-semibold">Return Date</h4>
        <input id="ret" class="w-3/4 bg-white border-4">
    </div>
    <div>
        <button id="book-flight" class="p-2 my-2 bg-green-200 border-2 border-gray-400 rounded-lg">Book Flight</button>
        <p id="flight-info" class="italic">Flight Info will go here</p>
    </div>
</div>

flight.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    from js import document
    #This is necessary for reasons I don't understand
    from pyodide import create_proxy
    
    def _flight_mode_change(*args, **kwargs):
        currentMode = document.getElementById("flight-mode-select").value
        if currentMode == 'one':
            document.getElementById("ret").disabled = True
        else:
            document.getElementById("ret").disabled = False
    
    flight_mode_change = create_proxy(_flight_mode_change)
    document.getElementById("flight-mode-select").addEventListener("input", flight_mode_change)
    
    def _book_flight(*args, **kwargs):
        currentMode = document.getElementById("flight-mode-select").value
        departure = document.getElementById("dep").value
        return_flight = document.getElementById("ret").value
    
        if currentMode == 'one':
            document.getElementById("flight-info").innerText = f"You've booked a one-way flight departing on {departure}."
        else:
            document.getElementById("flight-info").innerText = f"You've booked a round-trip flight departing on {departure} and returning on {return_flight}."
    
    book_flight = create_proxy(_book_flight)
    document.getElementById("book-flight").addEventListener("click", book_flight)
    
    flight_mode_change()
    
    

Scroll to see complete code

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.

Ellapsed Time:

32%

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<py-script src="timer.py"></py-script>
<div class="grid grid-rows-4 p-4 m-auto bg-blue-100 border-2 justify-items-start">
    <div class="grid grid-cols-2 justify-items-start">
        <p>Ellapsed Time:</p>
        <progress id="progress-bar" value="32" max="100"> 32% </progress>
    </div>
    <div>
        <p id="seconds">Seconds</p>
    </div>
    <div class="flex flex-row justify-items-center">
        <p>Duration</p>
        <div class="w-full m-auto"><input type="range" min="1" max="100" value="50" id="duration-slider" class="w-72"></div>
    </div>
    <div>
        <button id="reset" class="px-2 my-2 bg-green-200 border-2 rounded-lg">RESET</button>
    </div>
</div>

timer.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import asyncio
from js import document, console
from pyodide import create_proxy

my_time = 0
seconds_element = document.getElementById("seconds")
duration_slider = document.getElementById("duration-slider")
progress_bar = document.getElementById("progress-bar")

def _reset_time(*args, **kwargs):
    console.log("Time reset")
    global my_time
    my_time = 0

reset_time = create_proxy(_reset_time)
document.getElementById("reset").addEventListener("click", reset_time)

while True:
    await asyncio.sleep(.1)
    my_time = round(my_time + .1, 2)
    seconds_element.innerText = str(my_time) + " Seconds"

    min_time = int(duration_slider.min)
    max_time = int(duration_slider.value)

    min_bar = 0
    max_bar = int(progress_bar.max)

    progress_bar.value = ((my_time + 1) - min_time) * (max_bar - 0) / (max_time - min_time + .01)

Scroll to see complete code

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    <py-script src="./crud.py"></py-script>
    <div class="grid p-4 m-auto bg-blue-100 border-2 justify-items-start">
        <div class="p-2 m-auto bg-green-100 rounded-lg">
            <div id="upper-content" class="grid ">
                <div id="filter-box" class="grid w-full grid-cols-2">
                    <p id="filter-label" class="px-4">Filter Prefix:
                    <input type="text" id="filter-input" class="border-2 border-gray-300"></p>
                </div>
                <div id="middle-section" class="grid grid-cols-2">
                    <select id="listbox" size="5" class="h-48 m-4 bg-blue-50">test</select>
                    <div id="name-entry-container" class="grid grid-rows-2 w-96">
                        <div id="firstname-container" class="grid h-8 grid-cols-2 align-middle justify-items-end">
                            <p>Name:</p>
                            <input type="text" id="firstname-input">
                        </div>
                        <div id="surname-container" class="grid h-8 grid-cols-2 align-middle justify-items-end">
                            <p>Surname:</p>
                            <input type="text" id="surname-input">
                        </div>
                    </div>
                </div>
            </div>
            <div id="lower-buttons" class="grid w-full grid-cols-3">
                <button id="create" class="m-4 bg-gray-200 border-2 border-gray-400 rounded-md">Create</button>
                <button id="update" class="m-4 bg-gray-200 border-2 border-gray-400 rounded-md">Update</button>
                <button id="delete" class="m-4 bg-gray-200 border-2 border-gray-400 rounded-md">Delete</button>
            </div>
        </div>
    </div>
</div>

Scroll to see complete code


crud.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
from js import console
from pyodide import create_proxy
from dataclasses import dataclass, field
from collections import UserList
from random import randint

@dataclass(order=True)
class Entry():
    surname: str    
    firstname: str

class EntryList(UserList):
    def append(self, other):
        super().append(other)
        self.data = sorted(self.data)

entries = EntryList()

def _get_namefields():
    first = document.getElementById("firstname-input").value
    sur = document.getElementById("surname-input").value
    return first, sur

def _update_view():
    console.log("Updating listbox view")
    list = document.getElementById("listbox")
    list.innerHTML = ""

    filter_text = document.getElementById("filter-input").value
    if filter_text == "": filter = None
    else: filter = filter_text

    for entry in entries:
        if filter is None or entry.surname.startswith(filter):
            option = document.createElement('option')
            option.value = entry.firstname + ", " + entry.surname
            option.text = entry.firstname + ", " + entry.surname
            list.appendChild(option)

def _create_entry(*args, **kwargs):
    console.log("Create clicked")
    first, sur = _get_namefields()
    new_entry = Entry(firstname = first, surname = sur)
    entries.append(new_entry)
    _update_view()

def _delete_entry(*args, **kwargs):
    console.log("Delete clicked")
    list = document.getElementById("listbox")
    index = list.selectedIndex
    
    if index >= 0:
        entries.pop(index)
        _update_view()

def _update_entry(*args, **kwargs):
    list = document.getElementById("listbox")
    index = list.selectedIndex

    if index >= 0:
        entries.pop(index)
        _create_entry()

def _filter_key(*args, **kwargs):
    console.log("Filter input changed")
    _update_view()

create_entry = create_proxy(_create_entry)
delete_entry = create_proxy(_delete_entry)
update_entry = create_proxy(_update_entry)
filter_key = create_proxy(_filter_key)

document.getElementById("create").addEventListener("click", create_entry)
document.getElementById("delete").addEventListener("click", delete_entry)
document.getElementById("update").addEventListener("click", update_entry)

document.getElementById("filter-input").addEventListener("input", filter_key)
    

Scroll to see complete code

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
canvas = document.getElementById("circle-canvas")
ctx = canvas.getContext("2d")

def _clear_screen():
    ctx.fillStyle = "white"
    ctx.fillRect(0,0, canvas.width, canvas.height)

def _draw_circle(x, y, radius):
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, 2*pi)
    ctx.stroke()

def _draw_filled_circle(x, y, radius):
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, 2*pi)
    ctx.fillStyle = "gray"
    ctx.fill()

def _on_click(e):
    if e.button == 0: #left mouse button
        _make_new_circle(e.offsetX,e.offsetY, randint(40, 70))

on_click = create_proxy(_on_click)

document.getElementById("circle-canvas").addEventListener("mousedown", on_click)

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<py-script src="circle.py"></py-script>
<div class="grid p-4 m-auto bg-blue-100 border-2">
    <canvas id="circle-canvas" width="500" height="500" class="m-auto border-2" ></canvas>
    <div id="button-holder" class="flex w-full mt-2 justify-evenly"><button id="undo" class="px-6 bg-green-200 border-2 rounded-lg">Undo</button><button id="redo" class="px-6 bg-green-200 border-2 rounded-lg">Redo</button></div>
</div>

<link rel ="stylesheet" type="text/css" href="./context-menu.css">
<div id="right-click-menu" class="absolute bg-gray-200 border-1" 
    style="display: none">
    <div class="mx-4 my-2">
        <p id="circle-slider-label">Adjust diameter of Circle at (x, y)</p>
        <div class="w-full m-auto"><input type="range" min="1" max="100" value="50" id="circle-slider" class="w-5/6"></div>
    </div>
</div>

circle.py

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
from js import document, console, window
from math import pi, sqrt
from pyodide import create_proxy
from random import randint
from dataclasses import dataclass, field

canvas = document.getElementById("circle-canvas")
ctx = canvas.getContext("2d")
#Fill background with white
ctx.fillStyle = "white"
ctx.fillRect(0,0, canvas.width, canvas.height)

@dataclass
class Circle():
    x: int
    y: int
    radius: int

@dataclass
class UndoQueue:
    index : int
    q : list() = field(default_factory = list)

@dataclass
class ResizeOperation:
    circle: Circle
    previous_size: int
    new_size: int

uq = UndoQueue(index = -1)
currentResize = ResizeOperation(None, 0, 0)

my_circles = list()
closest_circle_index = -1

def _clear_screen():
    ctx.fillStyle = "white"
    ctx.fillRect(0,0, canvas.width, canvas.height)

def _draw_circle(x, y, radius):
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, 2*pi)
    ctx.stroke()

def _draw_filled_circle(x, y, radius):
    console.log(f"Drawing filled circle at {x}, {y}, with radius {radius}")
    
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, 2*pi)
    ctx.fillStyle = "gray"
    ctx.fill()

def _redraw_all():
    _clear_screen()
    if len(my_circles):
        if closest_circle_index >= 0:
            c = my_circles[closest_circle_index]
            _draw_filled_circle(c.x, c.y, c.radius)
        for c in my_circles:
            _draw_circle(c.x, c.y, c.radius)

def _make_new_circle(x, y, radius):
    new_circle = Circle(x, y, radius)
    my_circles.append(new_circle)

    uq.q = uq.q[:uq.index+1]
    uq.q.append(new_circle)
    uq.index = len(uq.q) - 1
    console.log(f"{uq}")


def _on_click(e):
    global my_circles
    console.log(str(e.offsetX) + " " + str(e.offsetY))
    if (document.getElementById("right-click-menu").style.display == "block"): _hide_menu(e)
    if e.button == 0: #left mouse button
        _make_new_circle(e.offsetX,e.offsetY, randint(40, 70))
        _redraw_all()
    else:
        #if (document.getElementById("right-click-menu").style.display == "block"): _hide_menu(e)
        if closest_circle_index >= 0: _show_menu(e)
        e.preventDefault()
        e.stopPropagation()
        return False

def _no_context(e):
    e.preventDefault()
    e.stopPropagation()
    return False

canvas.oncontextmenu = _no_context
document.getElementById("right-click-menu").oncontextmenu = _no_context

def _show_menu(event):
    console.log("Right mouse button clicked, showing menu")
    menu = document.getElementById("right-click-menu")
    menu.style.display = "block"
    menu.style.left = str(event.pageX) + "px"
    menu.style.top = str(event.pageY) + "px"

    c = my_circles[closest_circle_index]

    label = document.getElementById("circle-slider-label")
    label.innerText = f"Adjust diameter of Circle at ({c.x}, {c.y})"

    slider = document.getElementById("circle-slider")
    slider.value = my_circles[closest_circle_index].radius
    global currentResize
    currentResize.previous_size = slider.value
    currentResize.circle = c

def _change_radius(_):
    slider = document.getElementById("circle-slider")
    new_radius = slider.value
    my_circles[closest_circle_index].radius = new_radius
    _redraw_all()
    

def _hide_menu(e):
    slider = document.getElementById("circle-slider")
    global currentResize
    currentResize.new_size = slider.value

    uq.q = uq.q[:uq.index+1]
    uq.q.append(currentResize)
    uq.index = len(uq.q) - 1

    currentResize = ResizeOperation(None, 0, 0)
    document.getElementById("right-click-menu").style.display = "none"
    _recalc_nearest_circle(e.offsetX, e.offsetY)
        
def _on_move(e):
    #Do not reselect circle when menu is open
    if document.getElementById("right-click-menu").style.display == "block": return 
    if len(my_circles):
        _recalc_nearest_circle(e.offsetX, e.offsetY)

def _recalc_nearest_circle(mouse_x, mouse_y):
        closest_index = -1
        closest_distance = 1000000
        for i, c in enumerate(my_circles):
            dist = sqrt((mouse_x - c.x) ** 2 + (mouse_y - c.y) ** 2)
            if dist < closest_distance: 
                closest_index = i
                closest_distance = dist
        
        global closest_circle_index
        if closest_index != closest_circle_index:
            closest_circle_index = closest_index
            _redraw_all()

on_click = create_proxy(_on_click)
on_move = create_proxy(_on_move)
change_radius = create_proxy(_change_radius)

document.getElementById("circle-canvas").addEventListener("mousedown", on_click)
document.getElementById("circle-canvas").addEventListener("mousemove", on_move)
document.getElementById("circle-slider").addEventListener("input", change_radius)

def _press_undo(e):
    if uq.index < 0:
        console.log(f"Nothing more to undo\r\n{uq}")
        return

    op_to_undo = uq.q[uq.index]
    if type(op_to_undo) == Circle:
        my_circles.pop()
        uq.index -= 1
        if len(my_circles):
            _recalc_nearest_circle(250, 500) #A hack, bottom of the canvas
        _redraw_all()
    elif type(op_to_undo) == ResizeOperation:
        op_to_undo.circle.radius = op_to_undo.previous_size
        uq.index -= 1
        _redraw_all()
    console.log(f"After Undo {uq}\r\n{my_circles}")

def _press_redo(e):
    console.log(f"REDO {uq}\r\n{my_circles}")
    if uq.index == len(uq.q) - 1: 
        console.log(f"Nothing more to redo\r\n{uq}")
        return

    op_to_redo = uq.q[uq.index+1]
    if type(op_to_redo) == Circle:
        my_circles.append(Circle(op_to_redo.x, op_to_redo.y, op_to_redo.radius))
        uq.index += 1
    elif type(op_to_redo) == ResizeOperation:
        op_to_redo.circle.radius = op_to_redo.new_size
        uq.index += 1
    if len(my_circles):
        _recalc_nearest_circle(250, 500)   

    _redraw_all()
    console.log(f"After Redo {uq}\r\n{my_circles}")


press_undo = create_proxy(_press_undo)
press_redo = create_proxy(_press_redo)

document.getElementById("undo").addEventListener("click", press_undo)
document.getElementById("redo").addEventListener("click", press_redo)

Scroll to see complete code

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.

- paths: - ./spreadsheet.py - ./formula_parser.py

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

1
2
3
4
5
6
7
<py-env>
- paths:
    - ./spreadsheet.py
    - ./formula_parser.py
</py-env>
<py-script src="./cells-table.py">
</py-script>

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<link rel ="stylesheet" type="text/css" href="./cells-table.css">
<div class="grid w-full p-4 m-auto bg-blue-100 border-2 rounded-md">
    <div id="spreadsheet-wrapper" class="overflow-x-auto overflow-y-auto h-72">
        <table id="spreadsheet" style="empty-cells:show" class="m-auto bg-white border-2">
            <thead></thead>
            <tbody></tbody>
        </table>
    </div>
</div>
<py-env>
- paths:
    - ./spreadsheet.py
    - ./formula_parser.py
</py-env>
<py-script src="./cells-table.py"></py-script>

cells-table.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
from js import document, console
from pyodide import create_proxy
from spreadsheet import Spreadsheet
import re

columnIndices = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"

document_sheet = document.getElementById("spreadsheet")

sheet = Spreadsheet()

def _handle_input_change(e):
    console.log("Processing Cell" + str(e.target.id))
    render_table(sheet)

def _handle_cell_enter(e):
    code = e.keyCode
    if code == 13:
        e.target.blur()

def _handle_cell_blur(e):
    column = re.search(r'cell-(\w)-(\d+)', e.target.id).group(1)
    row = re.search(r'cell-(\w)-(\d+)', e.target.id).group(2)
    sheet.set((column, row), e.target.value)
    _handle_input_change(e)

handle_cell_enter = create_proxy(_handle_cell_enter)
handle_input_blur = create_proxy(_handle_cell_blur)

def _handle_cell_focus(e):
    console.log(str(e.target.id) + " gained focus")
    _handle_input_change(e)

handle_cell_focus = create_proxy(_handle_cell_focus)

def render_table(s: Spreadsheet):
    for location in s.data:
        id = "cell-" + str(location[0]) + "-" + str(location[1])
        cell_input = document.getElementById(id)
        if cell_input == document.activeElement: 
            cell_input.value = s.getRawValue(location)
        else:
            cell_input.value = s.getRenderedValue(location)

def create_table(num_x, num_y):
    create_header(num_x)
    create_cells(num_x, num_y)

def create_header(num_x):
    header = document.querySelector("#spreadsheet > thead")
    upperLeft = document.createElement("th")
    header.appendChild(upperLeft)
    for i in range(num_x):
        heading = document.createElement("th")
        heading.innerText = columnIndices[i]
        header.appendChild(heading)

def create_cells(num_x, num_y):
    for y in range(num_y):
        row = document.createElement("tr")
        row.classList.add("row", "overflow", "overflow-x-hidden")
        
        #Create row label
        cell = document.createElement("td")
        label = document.createElement("div")
        label.classList.add("w-full", "font-bold", "text-right", "px-2")
        label.innerText = y
        cell.appendChild(label)
        row.appendChild(cell)

        for x in range(num_x):
            sheet.set((columnIndices[x], y), "="+str(x)+"+"+str(y))
            cell = document.createElement("td")
            cell.classList.add("border-2", "border-gray-300", "w-48", "h-6")
            new_input = document.createElement("input")
            new_input.classList.add("w-48", "h-6")
            new_input.id=f"cell-{columnIndices[x]}-{y}"
            new_input.addEventListener("keydown", handle_cell_enter)
            new_input.addEventListener("blur", handle_input_blur)
            new_input.addEventListener("focus", handle_cell_focus)
            cell.appendChild(new_input)
            row.appendChild(cell)
        document_sheet.appendChild(row)

create_table(len(columnIndices),15)
render_table(sheet)
    

Scroll to see complete code


spreadsheet.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from formula_parser import FormulaParser, TokenType
from js import console

class Spreadsheet():
    def __init__(self):
        self.data : dict[tuple, str] = dict()

    def set(self, location: tuple, value: str):
        self.data[location] = value
    
    def getRawValue(self, location: tuple):
        return self.data[location]

    def getRenderedValue(self, location: tuple):
        value = self.data[location] 
        if value and self.data[location][0] == "=":
            tokens = FormulaParser.tokenize(value[1:])
            console.log(f"Tokens: {tokens}")
            if any([t.token_type == TokenType.T_CELL for t in tokens]):
                return FormulaParser.solve_with_references(tokens, self.data)
            else:
                return FormulaParser.solve_full_expression(tokens)
        else:
            return value
    

Scroll to see complete code


formula_parser.py

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
from copy import deepcopy
from enum import Enum, auto
import re
from typing import Iterable

class TokenType(Enum):
    T_NUM = auto()
    T_EMPTY = auto()
    T_PLUS = auto()
    T_MINUS = auto()
    T_DIVIDE = auto()
    T_MULT = auto()
    T_LEFTP = auto()
    T_RIGHTP = auto()
    T_CELL = auto()
    T_END = auto()
    T_EXPRESSION = auto()

arithmetic = {
    "+" : TokenType.T_PLUS,
    "-" : TokenType.T_MINUS,
    "/" : TokenType.T_DIVIDE,
    "*" : TokenType.T_MULT,
    "(" : TokenType.T_LEFTP,
    ")" : TokenType.T_RIGHTP,
}

parens_types = {TokenType.T_LEFTP, TokenType.T_RIGHTP}
my_dear = {TokenType.T_MULT : lambda x,y: x*y, TokenType.T_DIVIDE: lambda x, y: x/y}
aunt_sally = {TokenType.T_PLUS: lambda x,y: x+y, TokenType.T_MINUS: lambda x, y: x - y}

def quiet_index(i: Iterable, obj):
    try:
        index = i.index(obj)
    except ValueError as err:
        return -1

    return index

class ParseError(Exception):
    pass

class Node:
    def __init__(self, token_type, value=None, children=None):
        self.token_type = token_type
        self.value = value
        if children == None: self.children = list()
        else: self.children = children
    
    def __str__(self):
        return f"Node ({self.token_type}):'{self.value}'" + (f" children: {self.children}" if self.children else "")

    def __repr__(self):
        return self.__str__()

    def get_value(self):
        if self.token_type == TokenType.T_NUM:
            return float(self.value)
        else: raise Exception(f"Could not derive value of token {self}")

class FormulaParser():
    #re_ident = r"[a-zA-Z_]\w*" #matches identifiers
    re_decimal = r"-?\d+(\.\d*)?" #matches decimal numbers
    re_cell = r"([a-zA-Z])(\d+)" #group 1 is column, group 2 is row
    #re_range = re_cell + ":" + re_cell #matches a range like A2:B4
    re_operators = r'[\+\-\/\*\(\)]'

    @staticmethod
    def tokenize(value: str):
        if not value or len(value) <= 0: return
        value = ''.join(value.split()) #remove all whitespace

        tokens = list()
        while len(value) > 0:
            token = None
            match = None
            if match := re.match(FormulaParser.re_cell, value):
                token = Node(TokenType.T_CELL, value=match.group())
            elif match := re.match(FormulaParser.re_operators, value):
                token = Node(token_type=arithmetic[match.group()], value = match.group())
            elif match := re.match(FormulaParser.re_decimal, value):
                token = Node(TokenType.T_NUM, value=float(match.group()))
            else:
                raise Exception(f"No further tokens found in {value}")
            tokens.append(token)
            value = value[match.end():]
            
        tokens = [Node(token_type=TokenType.T_LEFTP, value = "start")] + tokens + [Node(token_type=TokenType.T_RIGHTP, value = "end")]
        return tokens

    def get_referenced_cells(token_list: list) -> list:
        return [node for node in token_list if isinstance(node, Node) and node.token_type == TokenType.T_CELL]

    def find_closest_parens(token_list:list) -> tuple:
        leftIndex = -1
        rightIndex = -1
        for i, token in enumerate(token_list):
            if token.token_type == TokenType.T_LEFTP:
                leftIndex = i
            elif token.token_type == TokenType.T_RIGHTP:
                if leftIndex >= 0:
                    return leftIndex, i
                    break
                else:
                    raise Exception ("Left and right parentheses do not match")
        
        return -1, -1

    #-----------------------------------------#

    def solve_full_expression(token_list:list) -> float:
        original_list = deepcopy(token_list)
        while any([t.token_type in parens_types for t in token_list]):
            left_p, right_p = FormulaParser.find_closest_parens(token_list)
            result = FormulaParser.evaluate_simple_expression(token_list[left_p+1:right_p])
            token_list = (token_list[:left_p] if left_p > 0 else []) + [result] + (token_list[right_p + 1:] if (right_p < len(token_list) - 1) else [])
            

        if len(token_list) == 1:
            return token_list[0].value
        
        raise ParseError(f"Failed to parse full expression {original_list}\nFinal tokens were {token_list}")


    def evaluate_simple_expression(token_list:list) -> Node:
        original_list = deepcopy(token_list)
        if len(token_list) == 1:
            return token_list[0]

        token_list = FormulaParser.evaluate_for_single_opset(token_list, my_dear)
        token_list = FormulaParser.evaluate_for_single_opset(token_list, aunt_sally)
        
        if len(token_list) == 1:
            return token_list[0]
        else: raise ParseError(f"Failed to parse simple expression {original_list}\nFinal tokens were {token_list}")

    def evaluate_for_single_opset(token_list:list, operators:dict) -> list:
        while any(ops_to_do := [t.token_type in operators for t in token_list]):
            op_location = ops_to_do.index(True)
            func = operators[token_list[op_location].token_type]

            result = func(token_list[op_location-1].value, token_list[op_location+1].value)
            new_node = Node(TokenType.T_NUM, value = result)

            token_list = (token_list[:op_location-1] if op_location > 1 else []) + [new_node] + (token_list[op_location + 2:] if op_location < len(token_list) - 2 else [])
        return token_list

    def tokenize_and_solve(expression:str) -> float:
        return FormulaParser.solve_full_expression(FormulaParser.tokenize(expression))

    def solve_with_references(token_list:list, data: dict, already_referenced:set = None) -> float:
        #Not yet implemented
        return FormulaParser.solve_full_expression(token_list)

        '''
        Psuedocode:

        Get list of all references in tokens
            if any of these area in our already-referenced set, we have a loop and cannot solve this. Bail!
        Recursively get the values of each of those cells.
            If the cell is a striaght numerical value, just get a node with that value
            If the cell is not a number or tokenizable, BAIL! #REF error
            Tokenize their destination
            If they have references, call this again with self added to the list of referenced cells
            If not, solve them normally with solve_full_expression
        solve_full_expression of this normal expression
        Return
        '''



if __name__ == "__main__":
    values = [
        "2 + 3",
        "2 * 3",
        "2 + 3 * 4",
        "2 * 3 + 4",
        "2 * (3 + 4)",
        "(2 * 3) + 4",
        "(2 + 3) * (4 + 5)"
    ]
    for v in values:
        print(f"{v} = {FormulaParser.tokenize_and_solve(v)}")

    

Scroll to see complete code


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.