Lecture: Graphing Calculator

Dear Computer

Chapter 5: Objects

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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
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:

Work through the practice exams. That's the way to study for the exam next Tuesday.
Go through the Tech Check Ruby Exam on Canvas, which will direct you to install the LockDown browser. Bring your 2FA device to the exam.
Complete the middle quiz as desired.

See you next time.

Sincerely,

P.S. It's time for a haiku!

Why is it method? Surely there's a better name But meth was taken
← MixinsLab: Objects →