Peg Solitaire Narrative - Mouse Rollover

Peg Solitaire Narrative

The third version of Peg Solitaire adds mouse-over highlighting of occupied spacs. Changes to the code include storing the actual bezier path for the peg rather than just the peg rectangle, plus the code necessary for supporting mouse moved events.

Taking a look at CHPegBoard.h, the changes were:



CHPegBoard.h:
#import <Cocoa/Cocoa.h>

#define PEG_COUNT 15

enum {
    CHPegBoard_NO_PEG = -1
};

@interface CHPegBoard : NSView
{
    BOOL pegStates[PEG_COUNT];	// YES - occupied, NO - empty
    NSRect pegRects[PEG_COUNT];	// where the peg lives
    NSBezierPath *pegPaths[PEG_COUNT];	// where the peg lives

    BOOL recalcPegPaths;
    int selectedPeg;
}

@end // CHPegBoard

Now for the code in the peg view. First, initWithFrame: is extended to initialze the recalcPegPaths and selectedPeg values. This is necessary because their useful default values are non-zero. Had they been zero, no explicit initialization would be necessary.


CHPegBoard.m:
- (id) initWithFrame: (NSRect) frame
{
    if (self = [super initWithFrame: frame]) {
        int i;
        // alternate them on and off, for initial development
        for (i = 0; i < PEG_COUNT; i++) {
            pegStates[i] = ((i % 2) == 0) ? YES : NO;
        }

        recalcPegPaths = YES;
        selectedPeg = CHPegBoard_NO_PEG;
    }

    return (self);

} // initWIthFrame

Then there is some housekeeping for supporing mouse moved events. First the window containing the view needs to be informed that the view accepts the motion events. By default NSViews don't get sent motion events since a lot of them get generated, and if you don't need them, it's a lot of wasted work. Another requirement of getting mouse motion events is that the view needs to be the first responder, which is the head of the responder chain. The responder chain is Cocoa's way of letting groups of objects have cracks at keyboard events and handle menu and control actions.

awakeFromNib is a good place to set the mouse motion attribute for the view since it gets called after all the Interface Builder connections have been made:


// nib file has been loaded and all connections made in interface builder
// have been made

- (void) awakeFromNib
{
    // we want mouse motion events so we can highlight pegs as the user
    // mouses over them

    [[self window] setAcceptsMouseMovedEvents: YES];

} // awakeFromNib

And to become first responder, we have to announce to the world that we actually can accept first responder status.
// we accept first responder status.  This is necessary to receive
// mouse motion events

- (BOOL) acceptsFirstResponder
{
    return (YES);

} // acceptsFirstResponder

In Interface Builder, you need to make sure that the CHPegView is the window's initial first responder by control-dragging from the window to the pegboard, and setting the initialFirstResponder outlet.

Now that we're done with the housekeeping for handling mouse motion events, it's time to actually handle them. Cocoa invokes the view's mouseMoved: method when an event happens. First the mouse location is converted from window coordinates to view coordinates, then the pegs are looped over looking for a match (the actual construction of the bezier path for the pegs will come a little later in this narrative) Finally, the view is told to redraw itself (eventually) if the peg changes. The extra variable in the code (candidatePeg) is there so that we don't trigger a redraw on every mouse moved event, since the selection will only change in a fraction of the events. The pegboard drawing code will look at the selectedPeg variable to decide how it wants to render the peg.
- (void) mouseMoved: (NSEvent *) event
{
    // get the mouse position in view coordinates
    NSPoint mouse;
    mouse = [self convertPoint: [event locationInWindow]  fromView: nil];

    // see if it is over a peg
    int candidatePeg = CHPegBoard_NO_PEG;

    int i;
    for (i = 0; i < PEG_COUNT; i++) {
        
        // see if a peg at an occupied spot
        if (pegStates[i]) {

            // encloses the mouse point
            if ([pegPaths[i] containsPoint: mouse]) {
                candidatePeg = i;
                break;
            }
        }
    }

    // only redraw if the currently selected peg (if any) has changed

    if (selectedPeg != candidatePeg) {
        selectedPeg = candidatePeg;
        [self setNeedsDisplay: YES];
    }

} // mouseMoved

The last of the brand new code (vs changes to existing code) is an override of setFrame:. Calculating the peg locations on every draw is pretty wasteful, although with the insanely fast computers these days, it's not noticable. But on general principles, I want the peg location calculation code to only be called when necessary. Our setFrame: will set the recalcPegPaths flag, and then at the next redraw we'll recalculate the paths for the pegs. Why not calculate the peg paths in this method? You could do that, and my first iteration of this actually recalculated the peg locations, but I had ended up copying and pasting some lines of code to do it, so I figured just keep all that stuff in one place.
// this gets called by Cocoa when the view gets resized.
// when that happens, we need to recalculate the paths for the pegs

- (void) setFrame: (NSRect) frame
{
    [super setFrame: frame];

    recalcPegPaths = YES;

} // setFrame

OK, now on to the drawing-related stuff. The first is the creation of the NSBezierPath objects, one for each peg location. recalcPegRectsForRect is largely untouched. An array of rectangles is declared as a local variable rather than an instance variable, and a loop has been added for the creation of circular paths. And the method has been renamed to better reflect what it is doing now
- (void) recalcPegRectsForRect: (NSRect) squareRect
- (void) recalcPegPathsForRect: (NSRect) squareRect
{
    // first make all the rects flush-left

    // these magic constants were determined via tweaking and fiddling

    float pegRadius = squareRect.size.width / 8;
    float pad = (squareRect.size.width - (pegRadius * 5)) / 7.0;
    float rowHeight = squareRect.size.height / 6.0;
    float x, y;

    // union rectangle for each row
    NSRect pegRects[PEG_COUNT];
    NSRect unions[5];

    int i = 0; // which peg we're looking at

    int row;
    for (row = 0; row < 5; row++) {
        y = pad + rowHeight * row;
        x = 0;

        // do the pegs of the row.
        int peg;
        for (peg = 0; peg < 5 - row; peg++) {

            pegRects[i] = NSMakeRect (x, y, pegRadius, pegRadius);
            x += pegRadius + pad;
            
            if (peg == 0) {
                unions[row] = pegRects[i];
            } else {
                unions[row] = NSUnionRect (unions[row], pegRects[i]);
            }
            
            i++;
        }
    }
    
    // now center each rect in our useful area
    
    NSRect centerSquare = [self makeCenterSquareRect];
    i = 0;
    
    for (row = 0; row < 5; row++) {
        NSRect rect = [self centerRect: unions[row]  inRect: centerSquare];
        
        // now update the x values for the row, and update the y
        // for the start of the center rect
        int peg;
        for (peg = 0; peg < 5 - row; peg++) {
            pegRects[i].origin.x += rect.origin.x + centerSquare.origin.x;
            pegRects[i].origin.y += centerSquare.origin.y;
            i++;
        }
    }

    // make the bezier path for each peg
    for (i = 0; i < PEG_COUNT; i++) {
        [pegPaths[i] release];
        pegPaths[i] = [NSBezierPath bezierPathWithOvalInRect: pegRects[i]];
        [pegPaths[i] retain];
    }

} // recalcPegPathsForRect

Note the memory management for the bezier paths. The bezierPathWithOvalInRect: is not an alloc or copy method, so assume the object is auto-released. Since we want to hang on to these paths, retain them. Since we're retaining them, we need a dealloc method to release them when our pegboard goes away.
- (void) dealloc
{
    int i;
    for (i = 0; i < PEG_COUNT; i++) {
        [pegPaths[i] release];
    }

    [super dealloc];

} // dealloc

And now for the real fun, drawing the highlighted peg. The selected peg (if it's a valid peg index) gets drawn as a filled yellow circle with a red outline. (ok, so I don't have the best taste in colors)
// given a peg index, draw it.

- (void) drawPeg: (int) pegIndex
{
    assert (pegIndex >= 0 && pegIndex < PEG_COUNT);

    // right now, just make 'on' pegs black, and 'off' pegs white
    // eventually we can do much snazzier pegs

    if (pegStates[pegIndex]) {

        if (pegIndex== selectedPeg) {
            [[NSColor yellowColor] set];
        } else {
            [[NSColor blackColor] set];
        }

    } else {
        [[NSColor whiteColor] set];
    }
    
    [pegPaths[pegIndex] fill];

    if (pegIndex== selectedPeg) {
        [NSBezierPath setDefaultLineWidth: 5];

        [[NSColor redColor] set];
        [pegPaths[pegIndex] stroke];

        [NSBezierPath setDefaultLineWidth: 1];

    } else  {
        [[NSColor blackColor] set];
        [pegPaths[pegIndex] stroke];

    }

} // drawPeg

And the final change is that drawRect: needs to recalculate the paths when the recalcPegPaths flag is set.
- (void) drawRect: (NSRect) rect
{
    // get the bounds, since the rect might be a smaller area to redraw
    NSRect bounds = [self bounds];

    // make a nice white background to draw on
    [[NSColor whiteColor] set];
    [NSBezierPath fillRect: bounds];

    // this area is where the triangular pegboard will go
    NSRect squareRect = [self makeCenterSquareRect];

    if (recalcPegPaths) {
        [self recalcPegPathsForRect: squareRect];
        recalcPegPaths = NO;
    }

    // draw the board
    [self drawBoardInRect: squareRect];

    // draw the pegs on top of the board
    int i;
    for (i = 0; i < PEG_COUNT; i++) {
        [self drawPeg: i];
    }

    [[NSColor blackColor] set];
    [NSBezierPath strokeRect: bounds];

} // drawRect


webmonster@cocoaheads.org