Tutorial - Drawing Graphs with ticklish_ui
The source code for this tutorial can be found here.
Table of Contents
A note on function currying and pymonad
pymonad is a package for monadic style functional programming in
python.
I've written the tutorial using pymonad's curry decorator to
keep function definitions flat because I think it makes them
easier to read and understand. However, the linked code uses
nested functions to accomplish the same behaviour so you should be
able to run the example code without having to install pymonad.
If you're not familiar with function currying, it's a way to turn a function which takes several inputs into several functions which each take a single input. The practical upshot of which is you can apply a function to only some of its arguments and call the resulting function with the remainder of the arguments later.
Here's a quick example:
from pymonad.tools import curry @curry(2) # Because the function takes 2 arguments def add(x, y): return x + y
If you call this function with two arguments, it does exactly what you would expect:
print(add(1, 1))
2
But if you call it with only one argument, it returns a new function which expects the remaining argument.
add_2 = add(2) print(add_2(3)) print(add_2(4)) print(add_2(5))
5 6 7
In this tutorial, currying is used to allow event handlers to take "extra" arguments which we'll discuss more when we get to the event code.
Program structure
The program uses the Model-View-Presenter architectural pattern and consists of four files:
- main.py
- loads all of the components and starts the program
- presenter.py
- the Presenter class which fascilitates communication between the model and the view
- model.py
- Vertex, Edge and Graph data structures
- view.py
- The GraphView class which wraps the actual UI
implemented using
ticklish_ui
The main.py module
We'll start with the easy stuff: main.py's only job is to load
the Presenter, Graph, and GraphView classes, initialize them and
then start the program.
# main.py from presenter import Presenter from view import GraphView from model import Graph Presenter(GraphView(), Graph()).start()
Of course, none of those exist yet, so let's move on!
Handling communication
The job of the Presenter class is to handle communication between
the view and the model. The view and the model can't communicate
with each other directly, they can only communicate with Presenter
which decides what to do and delegates to the model, view, or both.
All of Presenter's methods are pseudo-private: They're not
intended to be called by either the model or the view. The only way
for the view to communicate with Presenter is to generate
events. We use virtual events to define what the view is able to
communicate. The program only lets us do a two things: add vertices
and add edges. But since an edge requires two vertices, we need to
be able to select vertices as well. So, our events will be:
<<AddVertex>>, <<AddEdge>>, and <<SelectVertex>>.
So let's start defining the Presenter class.
# presenter.py class Presenter: def __init__(self, view, model): self.view = view self.model = model view.get_event_stream('<<AddVertex>>') view.get_event_stream('<<SelectVertex>>') view.get_event_stream('<<AddEdge>>') def start(self): self.view.mainloop()
The start method just calls mainloop on the view which will
start the application.
With ticklish_ui we bind events using the get_event_stream
method which returns a light-weight reactive stream object
capturing those events. In the above code we store instances of the
view and model and then define three event streams, one for each of
the events Presenter is prepared to respond to. We're not yet
doing anything with those events but we are capturing them when
they happen.
Defining the UI
The GraphView class defines our actual GUI.
# view.py class GraphView: def __init__(self): self.ui = Application( 'Graphs', # .row1 [RadioGroup('mode', ['Vertex', 'Edge'])], # .row2 [Canvas(640, 480)], ) def get_event_stream(self, event_sequence): return self.ui.get_event_stream(event_sequence) def mainloop(self): self.ui.mainloop()
The get_event_stream and mainloop methods just wrap the methods
on the Application object.
The GUI itself is defined using the ticklish_ui Application
class. The first argument is the window title and all remaining
arguments are lists of ticklish_ui widgets which will be laid out
as rows. The source code includes has a bunch of examples
showing how to use the various widgets.
The above code will produce something that looks like this:
It may look a bit different depending on what the tkinter default theme is
on your system.
Adding Vertices
Alright, let's make this actually do something. We want to add a
vertex to the graph when we click on the canvas, if the
'Vertex' radio button is selected. If 'Edge' is selected we don't do
anything for now. We need to catch button click events
and, like before, we use get_event_stream to do it.
# view.py class GraphView(View): def __init__(self): self.ui = Application( 'Graphs', # .row1 [RadioGroup('mode', ['Vertex', 'Edge'])], # .row2 [Canvas(640, 480).options(name='canvas')], ) self.ui.get_event_stream('<ButtonRelease-1>').by_name('canvas')
Mapping and Filtering Streams
When we have an event stream we use the filter and map methods
to create new streams from old ones adding behaviours as we
go. by_name is a built-in filter for events which filters by the
name of the widget on which the event occurred. The options method
is used to assign additional options to widgets; here we use it to
give the canvas a name. The event stream above catches left-click
events which happen on the canvas. Clicks on the radio buttons are
ignored by this stream.
There's also a by_class filter. The RadioGroup takes a name as
its first argument and all of the radio buttons in that group are
assigned to the same class. In the above code the group as a whole
is called 'mode' while the two buttons belong to the class 'Mode'
(note capitalization.) If we wanted to catch clicks on the radio
buttons we could do this:
self.ui.get_event_stream('<ButtonRelease-1>').by_class('Mode')
And we'd get an event whenever either radio button is clicked. But we won't need that for this application.
Sending the <<AddVertex>> event
Here's the code to send <<AddVertex>> to Presenter.
# view.py class GraphView: def __init__(self): # GUI definition... (self.ui.get_event_stream('<ButtonRelease-1>') .by_name('canvas') .filter(self._mode_equals('Vertex')) .map(self._event_generate('<<AddVertex>>')) )
Both map and filter take functions as arguments. In the case of
filter the function should return a boolean value: if True,
executions continues down the stream; if False, it stops. map
can return any value.
So the above event handler says:
- Given a button click
- If the click occurred on 'canvas'
- And the selected mode is 'Vertex'
- Then generate the event
<<AddVertex>>
The methods _mode_equals and _event_generate are defined like so:
# view.py @curry(3) def _mode_equals(self, mode, _event_ignored): set_mode = self.ui.nametowidget('.row1.mode').variable.get() return set_mode == mode @curry(3) def _event_generate(self, event_sequence, event): self.ui.event_generate(event_sequence, x=event.x, y=event.y) return event
nametowidget and event_generate (no leading underscore) are both
methods on tkinter widgets which you can learn about here. The
Application class automatically names the rows of your GUI rowN
where N is the row number starting with 1.
_mode_equals checks which radio button is currently selected,
compares it to our desired mode and returns a boolean.
_event_generate generates an event and sets its x and y
coordinates to the same as the x, y coordinates of the incomming
event: in this case the position of the mouse cursor when the left
mouse button was clicked.
Both _mode_equals and _event_generate are curried which allows
us to call them with their first arguments — mode and
event_sequence respectively — and return a function as map and
filter expect without having to wrap the call in a lambda:
# ... .map(lambda event: self._event_generate('<<AddVertex>>', event)) # ...
Or, alternatively, define a function inside a function:
def _event_generate(self, event_sequence): def handler(event): self.ui.event_generate(event_sequence, x=event.x, y=event.y) return event return handler
Either of those approaches is fine but currying allows us to write the function in a straight-forward way and then apply only the arguments we want. This is what was meant earlier by using currying to give event handlers extra arguments.
Handling the <<AddVertex>> event
When the <<AddVertex>> event stream in Presenter gets an
event it needs to do three things:
- Extract the x and y coordinates
- Ask the model to create a new vertex
- Ask the view to draw the vertex
We modify the event stream like this:
# presenter.py class Presenter: def __init__(self, view, model): # ... (view.get_event_stream('<<AddVertex>>') .map(self._get_coordinates) .map(self._add_vertex) .map(self._draw_vertex('black')) )
Next we define the methods:
# presenter.py class Presenter: # ... def _get_coordinates(self, event): return event.x, event.y def _add_vertex(self, coordinates): return self.model.add_vertex(*coordinates) @curry(3) def _draw_vertex(self, color, vertex): self.view.draw_vertex(color, vertex) # ...
It's worth noting that although the stream starts out with an
event, functions passed to map can return any value they
want. _get_coordinates returns a tuple which is passed to
_add_vertex; _add_vertex will return a vertex which is passed
to _draw_vertex; and so on. Event streams don't have to remain
event streams, they can transform and process data in whatever way
makes sense to get the job done.
The model and drawing vertices
The model for our graphing application is very simple and fairly
self explanatory. It tracks a list of vertices, a list of edges,
provides methods to create each and a method to find a vertex in
the vicinity of a given x and y coordinate which will be used
later to select vertices. The complete implementation of the model
is:
# model.py from dataclasses import dataclass import math @dataclass class Vertex: x: int y: int @dataclass class Edge: start: Vertex end: Vertex class Graph: def __init__(self): self.vertices = [] self.edges = [] def add_vertex(self, x, y): vertex = Vertex(x, y) self.vertices.append(vertex) return vertex def add_edge(self, start, end): edge = Edge(start, end) self.edges.append(edge) return edge def find_vertex(self, x, y): for v in self.vertices: dist = math.sqrt((v.x - x)**2 + (v.y - y)**2) if dist <= 6: return v
After the vertex has been added, Presenter can ask GraphView to
draw it. Which it does, like so:
# view.py class GraphView: def __init__(self): # Application definition... self.canvas = self.ui.nametowidget('.row2.canvas') # Event stream definitions ... # ... def draw_vertex(self, color, vertex): x1, y1 = vertex.x - 3, vertex.y - 3 x2, y2 = vertex.x + 3, vertex.y + 3 self.canvas.create_oval(x1, y1, x2, y2, fill=color, outline=color) # ...
You can find more about drawing on the canvas here.
We should now be able to add vertices to our graph!
Selecting vertices and adding edges
There's nothing drastically different about handling the remaining functionality so we'll go over it fairly quickly.
Selecting vertices is part of adding an edge so we only select
vertices in 'Edge' mode and we only draw an edge once we've
selected two vertices. For this tutorial I decided to cache the
selections in the Presenter class but in a real application you'd
probably pass the selection on to the view so it could do something
with it. The <<AddEdge>> event will check how many selections have
been made and add an edge only if there are exactly two selections.
In the view, when we click in 'Edge' mode, this is what happens:
# view.py class GraphView(View): def __init__(self): # GUI definition... # Assign button clicks on the canvas to the variable 'click'. click = self.ui.get_event_stream('<ButtonRelease-1>').by_name('canvas') (click .filter(self._mode_equals('Edge')) .map(self._event_generate('<<SelectVertex>>')) .map(self._event_generate('<<AddEdge>>')) )
If we get_event_stream on the same event more than once it will
overwrite our previous stream and our previous handler will stop
working. To get around that we can assign the stream to a variable
and then map and filter to effectively split the stream into
multiple streams. To make everything work we have to modify our
previous <<AddVertex>> code to this:
# view.py (click .filter(self._mode_equals('Vertex')) .map(self._event_generate('<<AddVertex>>')) )
Similarly, we implement the Presenter code to handle the other two events.
# presenter.py class Presenter: def __init__(self, view, model): # Other initialization... self.selections = [] # Other event streams ... (view.get_event_stream('<<SelectVertex>>') .map(self._get_coordinates) .map(self._find_vertex) .filter(self._vertex_exists) .map(self._cache_vertex) .map(self._draw_vertex('red')) ) (view.get_event_stream('<<AddEdge>>') .filter(self._two_selections) .map(self._add_edge) .map(self._update_display) .map(self._clear_selections) )
And the methods which make them work:
# presenter.py class Presenter: # ... def _add_edge(self, _event_ignored): return self.model.add_edge(*self.selections) def _cache_vertex(self, vertex): self.selections.append(vertex) return vertex def _clear_selections(self, _argument_ignored): self.selections = [] def _find_vertex(self, coordinates): return self.model.find_vertex(*coordinates) def _two_selections(self, _event_ignored): return len(self.selections) == 2 def _update_display(self, _argument_ignored): self.view.clear() for v in self.model.vertices: self.view.draw_vertex('black', v) for e in self.model.edges: self.view.draw_edge(e) def _vertex_exists(self, vertex): return vertex is not None
The last thing that needs to be done is define clear and
draw_edge on GraphView:
# view.py class GraphView: # ... def clear(self): self.canvas.delete('all') def draw_edge(self, edge): x1, y1 = edge.start.x, edge.start.y x2, y2 = edge.end.x, edge.end.y self.canvas.create_line(x1, y1, x2, y2)
And that's it!