Diagrams + Cairo + Gtk + Mouse picking

Diagrams is the best library for drawing diagrams in Haskell. But can it be used as part of a user interface so you can interact with parts of a diagram?

The answer is: yes.


In this article we walk through a simple example that combines Diagrams, its Cairo backend and Gtk, to show a diagram and determine which parts of the diagram the mouse is over.

The full source code for this example is at the bottom of this article, but we’ll also look at some excerpts in detail.

Let’s start with the diagram to be shown. It’s a simple picture of a house that a child might draw. The only special thing we do here is tag parts of the diagram with the value function. Each part – the wall, door, handle, roof, chimney and smoke – has a single string attached to it.

-- The diagram to be drawn, with features tagged by strings.
prettyHouse :: QDiagram Cairo R2 [String]
prettyHouse =
    let smoke = mconcat [ circle 0.05 # moveTo (p2(x,y))
                        | (x,y) <- [ (0.75,1.85), (0.8,2) ] ]
                  # stroke # fc grey
        roof = fromVertices [p2(0,1), p2(0.5,1.75), p2(1,1)]
                 # mapLoc closeTrail # pathFromLocTrail # stroke
                 # fc blue
        chimney = fromVertices [p2(0.7,1.45), p2(0.7,1.7),
                                p2(0.8,1.7),  p2(0.8,1.3)]
                 # mapLoc closeTrail # pathFromLocTrail # stroke
                 # fc green
        wall = square 1 # stroke # fc yellow
                 # alignBL # moveTo (p2(0,0))
        door = rect 0.2 0.4 # stroke # fc red
                 # alignBL # moveTo (p2(0.4,0))
        handle = circle 0.02 # stroke # fc black
                 # moveTo (p2(0.55,0.2))
    in mconcat [ smoke   # value ["smoke"]
               , roof    # value ["roof"]
               , chimney # value ["chimney"]
               , handle  # value ["handle"]
               , door    # value ["door"]
               , wall    # value ["wall"]
               ]

Now we can query the points in the diagram to see what’s there.

λ: sample prettyHouse (p2(0,0)) 
["wall"]
λ: sample prettyHouse (p2(0,1)) 
["roof"]
λ: sample prettyHouse (p2(0.55,0.2)) 
["wall","door","handle"]
λ: sample prettyHouse (p2(2,0.2)) 
[]

With this we’re actually most of the way there. However, the coordinate system of the diagram is not the same as the mouse coordinates we’ll get from Gtk. We could take the mouse coordinates and transform them into diagram coordinates, but let’s do it the other way around and make the diagram coordinates as close to the mouse coordinates as possible.

Gtk’s mouse coordinates have the origin (0,0) at the top left of the canvas. So first we move the diagram’s origin to the top left:

alignTL $ prettyHouse

Then we scale the diagram so that it’s the same width as the canvas:

scaleUToX 250 . freeze . alignTL $ prettyHouse

(The freeze makes the line widths scale up too.)

Now we are almost done: our mouse and diagram coordinates are the same size. The last wrinkle is that in diagrams-land positive Y-coordinates go up, but in Gtk-land positive Y-coordinates go down. We could reflect the diagram before we sample from it, but I found it easier just to negate the Y-coordinate.

λ: sample (scaleUToX 250 . freeze . alignTL $ prettyHouse) (p2 (100,-200))
["roof"]
λ: sample (scaleUToX 250 . freeze . alignTL $ prettyHouse) (p2 (100,-300))
["wall"]

And we’re done! The rest of the program is basically just Gtk plumbing.

Here’s the whole program.

import Control.Monad (void)
import Control.Monad.IO.Class (liftIO)
import Data.List (intercalate)
import Diagrams.Backend.Cairo
import Diagrams.Backend.Cairo.Internal
import Diagrams.Prelude
import Graphics.UI.Gtk

main :: IO ()
main = do
  -- Ordinary Gtk setup.
  void initGUI
  w <- windowNew
  da <- drawingAreaNew
  w `containerAdd` da
  void $ w `on` deleteEvent $ liftIO mainQuit >> return True

  -- Take our diagram, move the origin to the top left, and scale it
  -- to the size of the drawing area.
  let scaledHouse = scaleUToX 250 . freeze . alignTL $ prettyHouse

  -- Render the diagram on the drawing area.
  void $ da `on` exposeEvent $ liftIO $ do
    dw <- widgetGetDrawWindow da
    let (_,r) = renderDia Cairo
                          (CairoOptions "" (Width 250) PNG False)
                          scaledHouse
    renderWithDrawable dw r
    return True

  -- When the mouse moves, show the coordinates and the objects under
  -- the pointer.
  void $ da `on` motionNotifyEvent $ do
    (x,y) <- eventCoordinates
    liftIO $ do
      -- We negate the "y" coordinate when sampling because in Gtk the
      -- "down" direction is positive, but in our diagram the "up"
      -- direction is positive.
      putStrLn $ show (x,y) ++ ": "
                   ++ intercalate " " (sample scaledHouse (p2(x,-y)))
      return True

  -- Run the Gtk main loop.
  da `widgetAddEvents` [PointerMotionMask]
  widgetShowAll w
  mainGUI

-- The diagram to be drawn, with features tagged by strings.
prettyHouse :: QDiagram Cairo R2 [String]
prettyHouse =
    let smoke = mconcat [ circle 0.05 # moveTo (p2(x,y))
                        | (x,y) <- [ (0.75,1.85), (0.8,2) ] ]
                  # stroke # fc grey
        roof = fromVertices [p2(0,1), p2(0.5,1.75), p2(1,1)]
                 # mapLoc closeTrail # pathFromLocTrail # stroke
                 # fc blue
        chimney = fromVertices [p2(0.7,1.45), p2(0.7,1.7),
                                p2(0.8,1.7),  p2(0.8,1.3)]
                 # mapLoc closeTrail # pathFromLocTrail # stroke
                 # fc green
        wall = square 1 # stroke # fc yellow
                 # alignBL # moveTo (p2(0,0))
        door = rect 0.2 0.4 # stroke # fc red
                 # alignBL # moveTo (p2(0.4,0))
        handle = circle 0.02 # stroke # fc black
                 # moveTo (p2(0.55,0.2))
    in mconcat [ smoke   # value ["smoke"]
               , roof    # value ["roof"]
               , chimney # value ["chimney"]
               , handle  # value ["handle"]
               , door    # value ["door"]
               , wall    # value ["wall"]
               ]

Christopher Mears, 20 February 2014