Lecture: Graphing Calculator
Dear students:
Today we explore objects in Ruby by writing a graphing calculator. We'll also visit the Curses library that you'll use in milestone 3 to make a terminal-based user interface for your spreadsheet. You'll use Curses to draw lines and text at arbitrary places in the terminal window. The library was written in a time where object-oriented programming didn't rule the land, so it will feel strange. I think it's worth navigating together.
The reason we use it is that it doesn't take over control of your program like a modern graphical user interface does. GUIs like Swing, React, and Qt are amazing, but they're also domineering. With Curses, our program is still ours. It's not as helpful, which I think is valuable when you are learning.
Grapher
Let's build a graphing calculator in the terminal, which I admit is a strange move. Here's a start that imports the curses
library (installed via gem
), clears the screen, and waits for the user to press a key:
require 'curses'
Curses::init_screen
Curses::getch
There are several reasons to use classes when developing software. The most basic reason is that they are an organizational tool. They put related data and code together and hide its complexity behind a simpler interface.
For the graphing calculator, we know we're going to have a collection of functions and some code to draw them and some axes, so we reflexively create a class:
class Grapher
def initialize
# initialize state
end
def add_function(function)
# TODO
end
def render_axes
# TODO
end
def render_functions
# TODO
end
def render
render_axes
render_functions
end
end
Let's move the Curses initialization into the constructor:
class Grapher
def initialize
Curses::init_screen
end
def render
# ...
Curses::getch
end
# ...
end
grapher = Grapher.new
grapher.render
An instance of Grapher
maintains a list of functions that are to be plotted, so we also add some state for that collection:
class Grapher
def initialize
# ...
@functions = []
end
def add_function(function)
@functions.push(function)
end
# ...
end
Because Ruby is dynamically typed, we don't need an interface or abstract supertype before we can write this polymorphic method. No typechecking is going to be performed. If we pass something that isn't function-like to add_function
, we'll find out when we execute the program.
Windows
Curses lets me treat the terminal as an image. We can move the cursor to any “pixel” and plot a character. Normally we can just print rightward and downward. By default, my canvas is the entire terminal window. But we can also subdivide it into smaller regions using the Window
class.
Let's make two windows. One will be the graph itself, which will take up most of the screen. The other is a one-line message window. We need this second window because debugging a Curses program is really hard. Printing debug messages to standard output doesn't work when Curses is controlling the display.
To determine the sizes of the two windows, we need to know the size of the terminal window. The original Curses library, written in C, has a function getyxmax
that writes into the parameters we give it. The Ruby gem we are using has methods lines
and cols
. We query the dimensions in the constructor after Curses has been initialized:
def initialize
# ...
height = Curses::lines
width = Curses::cols
end
Observe that we don't make the dimensions instance variables. We only need them temporarily to make a couple of windows, like so:
def initialize
# ...
@message_window = Curses::Window.new(1, width, height - 1, 0)
@graph_window = Curses::Window.new(height - 1, width, 0, 0)
end
The parameters describe the dimensions of the windows, and their order is awkward. It's height, width, top, left. The origin of the Curses drawing system is at the top-left corner.
Drawing Axes
To draw single characters on the terminal, we use setpos
to position the cursor and addch
to draw a character at the current position. To draw the axes, we iterate through the coordinates of the middle column and middle row of the graph window:
def render_axes
width = @graph_window.maxx
height = @graph_window.maxy
(0...width).each do |x|
@graph_window.setpos(height / 2, x)
@graph_window.addch('-')
end
(0...height).each do |y|
@graph_window.setpos(y, width / 2)
@graph_window.addch('|')
end
@graph_window.setpos(height / 2, width / 2)
@graph_window.addch('+')
end
The y-coordinate comes first in any Curses functions that expect coordinates.
Functions
We can't write render_functions
until we know more about functions. All functions have a common interface: feed them an x-value and they give back the corresponding y-value. But let's define several abstractions to represent the simplest function families. Here's how we might model a linear function:
class LinearFunction
def initialize(slope, y_intercept)
@slope = slope
@y_intercept = y_intercept
end
def evaluate(x)
return @slope * x + @y_intercept
end
end
And here's how we might model a quadratic function:
class QuadraticFunction
def initialize(a, b, c)
@a = a
@b = b
@c = c
end
def evaluate(x)
return @a * x * x + @b * x + @c
end
end
If this were Java or C++, we would write an abstract Function
class or interface. Then we could write polymorphic code that could deal with functions all across the hierarchy. New function families could be invented, perhaps by my grandchildren, and the polymorphic code would be able to work with them just as well.
In Ruby, we don't need a supertype in order to operate at an abstract, polymorphic level. As long as all functions have an evaluate
method, the graphing calculator will be able to plot them. Here we load up a Grapher
with two functions:
grapher.add_function(LinearFunction.new(1, 2))
grapher.add_function(QuadraticFunction.new(0.01, 0, 0))
Drawing Functions
The last step is implementing render_functions
. It iterates through the list of functions. For each function, it iterates through all the possible x-values in the display and finds their corresponding y-value. We convert the xy-coordinates to the coordinate system used by Curses and plot a character if that position is on screen:
def render_functions
width = @graph_window.maxx
height = @graph_window.maxy
@functions.each do |function|
(0...width).each do |column|
x = column - width / 2
y = function.evaluate(x)
row = (-y + height / 2).round
if row >= 0 && row < height && column >= 0 && column < width
@graph_window.setpos(row, column)
@graph_window.addch('*')
end
end
end
end
This code is extensible. We're not asking each function what type it is. Thanks, polymorphism.
Derivative
Since all our functions have the same interface, we can employ another polymorphic trick: model a Derivative
function. The derivative gives the slope of its host function, which we can approximate by sampling around x
. This class works will approximate the derivative of any function:
class Derivative
def initialize(f)
@f = f
end
def evaluate(x)
delta = 0.01
rise = f(x + delta) - f(x - delta)
run = 2 * delta
rise / run
end
end
Lines
Using hyphens, pipes, and a plus sign to draw the axes feels like a surrender to expedience. Those characters don't connect to form solid lines. In the C Curses library, there are some special constants defined for border characters. Ruby's Curses gem doesn't define them. However, Unicode includes its own set. We should use those characters.
Colors
The graph would be easier to read if each function was plotted in a different color. We enable colors and create a few color pairs with this code right after the call to initscr
:
Curses::start_color
Curses::init_pair(1, Curses::COLOR_MAGENTA, Curses::COLOR_BLACK)
Curses::init_pair(2, Curses::COLOR_CYAN, Curses::COLOR_BLACK)
Curses::init_pair(3, Curses::COLOR_YELLOW, Curses::COLOR_BLACK)
Then we need to draw each function in a different color. We only have three colors but possibly many more functions, so let's cycle through those three colors using the %
operator. We use each_with_index
in render_functions
to give the block an index parameter. We map this number of the color pair ID:
def render_functions(width, height)
@functions.each_with_index do |function, i|
color_id = i % 3 + 1
Curses::attron(Curses::color_pair(color_id))
# ...
Curses::attroff(Curses::color_pair(color_id))
end
end
Debugging
Let's add a message
function that draws a string into the message window. We can use this for communicating with the user and for debugging. The addstr
function draws a sequence of characters starting at the current position:
def message(text)
@message_window.setpos(0, 0)
@message_window.addstr(text)
end
There's one hiccup. The text doesn't show up. That's because Curses maintains a data structure holding the screen contents. That data structure gets updated by addch
and addstr
, but it doesn't get automatically flushed to the terminal window. We must call refresh
when we're ready to update the screen. Let's do that in render
:
def render
render_axes
render_functions
message(":)")
@message_window.refresh
@graph_window.refresh
@graph_window.getch
end
We didn't need to call refresh
before with @graph_window
because getch
calls it automatically. Now we make it explicit.
Mouse
Finally, let's detect a mouse click. First we hide the cursor and enable mouse events:
def initialize
# Hide the cursor.
Curses::curs_set(0)
# Enable left-click mouse events.
Curses::mousemask(Curses::BUTTON1_CLICKED)
# ...
# Have Curses process special keys (and mouse) so that getch can
# process them.
@graph_window.keypad(true)
end
Then we add a full-fledged event loop that shows the coordinates of the mouse click in the top-left corner of the window:
def event_loop
loop do
render
command = @graph_window.getch
if command == 'q'
break
elsif command == Curses::KEY_MOUSE
event = Curses::getmouse
column = event.x - @graph_window.maxx / 2
message("#{column},#{-(event.y - @graph_window.maxy / 2)}")
end
@message_window.refresh
@graph_window.refresh
end
end
TODO
Here's your list of things to do before we meet next:
See you next time.
Sincerely,
P.S. It's time for a haiku!