Scrolling with the Mouse

Design & Implementation of a Win32 Text Editor

Originally I had planned to keep all of the mouse-related concepts together in one tutorial. The problem is, does mouse-scrolling belong in the mouse tutorial (part#5) or the scrolling tutorial (part#3)? In the end I've decided to make it a separate topic by itself. Actually it has worked quite well because it gives a good sense of progression between basic mouse selection (with no scrolling) and a fully working selectable, scrollable control.

Scrolling with the mouse

The basic idea behind "mouse scrolling" is to cause the window to scroll when the mouse is dragged outside the window whilst making a selection. Almost any application that hosts a window with scrollbars will support mouse-scrolling in some form or fashion. Try it with whatever browser you are using right now - select some text and whilst holding the left-button down, drag the mouse outside the browser window. The contents is automatically scrolled into view.

The very first step we must take is to detect when the mouse leaves the window and initiate some kind of appropriate scrolling action. Fortunately we already have TextView::Scroll (written in part#3) which we will be able to use for this tutorial.

Now, take a quick look at Notepad and you will see how it handles mouse-scrolling. Notepad (or rather the embedded EDIT control) works by detecting when the mouse leaving the confines of the window, but it only does this whilst the mouse is moving. As soon as the EDIT control stops receiving mouse-movement, the scrolling stops. So to achieve this very basic functionality we first add some code to our WM_MOUSEMOVE handler which can detect when the mouse is outside the TextView:

RECT rect;
POINT pt = { mx, my };

// get the non-scrolling area (an even no. of lines)
GetClientRect(m_hWnd, &rect);

rect.bottom -= rect.bottom % m_nLineHeight;

// detect where mouse is
if(PtInRect(&rect, pt) == FALSE) {
    // mouse is outside window, scroll in that direction

This basic method is not enough for a "grown-up" editor so we need to take this one step further. We will use a timer to generate regular scrolling events. However we won't just stop there - the other issue that we will look at is what to do about variable-speed scrolling. For example, many controls scroll their window-content slowly when the mouse is just outside the window, and speed up incrementally when the mouse gets further and further away. The picture below should help to illustrate this idea.

The inner red rectangle represents the client-area of the window. When the mouse is inside this region no scrolling is required. As the mouse move further and further away (represented by the red arrows) the window should be scrolled in that direction at the appropriate speed.

// If mouse is within client area, we don't need to scroll
if(PtInRect(&rect, pt))
{
    if(m_nScrollTimer != 0)
    {
        KillTimer(m_hWnd, m_nScrollTimer);
        m_nScrollTimer = 0;
    }
}
// If mouse is outside window, start a timer in
// order to generate regular scrolling intervals
else
{
    if(m_nScrollTimer == 0)
    {
        m_nScrollCounter = 0;
        m_nScrollTimer   = SetTimer(m_hWnd, 1, 10, 0);
    }
}

Variable speed scrolling

There are several methods which we can use to create variable-speed scrolling - however all methods are based around WM_TIMER and the SetTimer API. The first method is to reprogram the timer interval each time the mouse gets closer/further away from the window, resulting in a faster/slower rate of WM_TIMER messages being received by the window. When we receive a WM_TIMER, we scroll +1/-1 in whatever direction the mouse is. This is a little messy because it causes the timer to be reset each time SetTimer is called. See the following from excert MSDN on SetTimer :

"If the hWnd parameter is not NULL and the window specified by hWnd already has a timer with the value nIDEvent, then the existing timer is replaced by the new timer. When SetTimer replaces a timer, the timer is reset. Therefore, a message will be sent after the current time-out value elapses, but the previously set time-out value is ignored."

I'm unsure if this behaviour will cause us problems (i.e stuttering movement) or not. But the main reason I want to avoid this technique is that it doesn't support 2-dimensional scrolling very well. Imagine the following scenario: the mouse is held quite a distance from the top of the TextView (the vertical scrolling direction), but is only just outside on the left side (the horizontal direction). Which direction do we choose to base our scrolling speed on? The answer is, we can't have both fast scrolling (vertically) and slow scrolling (horizontally) at the same time with this technique.

The next method is to therefore use a constant timer interval set at a slowish rate. As the mouse moves further from the window (in either dimension), instead of speeding up the scrolling, the distance that is scrolled is increased - i.e. slow scrolling would be 1-line-at-a-time using the slow timer interval, faster scrolling would be 3-lines-at-a-time, and so on. This is a perfectly reasonable method and you can observe many controls using it for their scrolling.

The last method is similar to the previous one, but instead we program the timer to have a high repeat rate (i.e. 10ms). This fast interval allows us to scroll the window quickly when the mouse is at it's furthest from the window. And as we move the mouse closer, we can simply "skip" processing selected WM_TIMER messages. For example, we would process every WM_TIMER for full-speed scrolling, 1 out of 2 for half speed and 1 out of every 5 for very slow speeds. This results in smoother scrolling (we always scroll a line-at-a-time) but requires more CPU because more redrawing needs to be done. It has the advantage that we only ever scroll 1 line at a time so it is a little simpler from a coding and debugging point of view.

I opted for method#3 for Neatpad, simply because it was easier.

Avoiding flicker

Perhaps the reason that many text-editors exhibit flickering artifacts when they are scrolled up and down is because mouse-scrolling (in general) is so hard to get right. I do hate any sign of flickering though so it is very important from my point to view to ensure that Neatpad suffers no such problems.

There are two basic manifestations of "scroll flicker" that can be found in some applications. Both occur because the mouse selection and scrolling are not correctly synchronized. A lot of the time people don't notice these problems because most text files contain lines of varying length, and it isn't until you scroll a solid block of text that you begin to see what is going wrong. The animated gifs below hopefully illustrate the two problems - well, I hope they do because they took me ages to make! Once you've had enough of the animations just click the "stop" button in your browser window and they should stop.

The first flicker problem occurs because as the mouse moves outside of the window, the selection is redrawn, briefly extending outside of the window. Then the window is scrolled, bringing the end-of-selection back into view. It has the effect that the area to the left of the cursor appears to toggle between selected and unselected states. It's quite unsettling in my opinion but many controls exhibit this problem, including many standard Windows controls. We can prevent this problem by "clipping" the mouse coordinates to the edge of the window before we work out where the selection end-point should be.

The second case is almost the same as the first, except this time the scrolling happens first, which causes the whole display to scroll down. The selection is extended upwards after the scrolling, which again results in some fairly nasty flickering - this time because the unselected area on the first line is (wrongly) scrolled downwards and briefly appears as unselected text - then it is correctly repainted as highlighted. We can also prevent this problem by using clipping when we scroll the window - in this case, restricting the area that we scroll to a specific region which doesn't include the top/bottom lines.

It is not easy to get the mouse-selection and scrolling correctly synchronized. The basic problem is, we can't really treat these two actions as separate events because (obviously) they must occur at the same time. Adding to the problem is the fact that we have already written the scrolling and selection functionality, and it would be nice if we could still keep these as "separate" as possible so the code is kept clean. Let's have a brief recap of what we have so far:

  • TextView::Scroll(int dx, int dy)
    This routine scrolls the entire display in the specified direction, and also updates the scrollbar positions at the same time.
  • TextView::MouseCoordToFilePos(...)
    Retrieves the cursor position under the mouse, taking the scrollbar positions into account.
  • TextView::InvalidateRange(...)
    Redraws the specified range of text, also using scrollbar positions.

The basic strategy to synchronized mouse movement is outlined below.

  1. Work out the correct clipping region, based on the direction we are scrolling. i.e. if we are scrolling up, we exclude the top line from being scrolled. If we scroll left, we exclude the left-most column. This can all happen inside the existing TextView::Scroll routine.
  2. Scroll the window using ScrollWindowEx, but use the clipping rectangle worked out in step#1.
  3. Work out the new cursor position and selection end points. The scrolling had the effect of modifying the scrollbar positions (because we used TextView::Scroll), so we must work out the new cursor position after this scrolling has taken place.
  4. Redraw the region that wasn't scrolled (i.e. the area outside of the clipping rectangle).

In other words, all we are really doing is scrolling a sub-region of the window and then manually repainting the area we didn't scroll once the cursor/selection endpoint has been placed appropriately. How we fit all this together will determine how successful we will be.

ScrollWindowEx

Let's look at ScrollWindowEx so we know what scrolling facilities we have at our disposal:

int ScrollWindowEx(
  HWND   hWnd,            // handle to window
  int    dx,              // horizontal scrolling
  int    dy,              // vertical scrolling
  RECT * prcScroll,       // client area              [optional]
  RECT * prcClip,         // clipping rectangle       [optional]
  HRGN   hrgnUpdate,      // handle to update region  [optional]  
  RECT * prcUpdate,       // invalidated region       [optional]
  UINT   flags            // scrolling options
);

I have highlighted (in bold) the two optional parameters that we will be using - but first a brief recap of all parameters is probably appropriate at this stage:

  • hWnd is the window that we want to scroll. In our case this will be m_hWnd (the TextView!).
  • dx and dy specify the direction (in pixels) to scroll the window. These values can be positive or negative and can therefore be used to scroll in any direction.
  • prcScroll is a pointer to a RECT structure that defines the area to scroll. If this parameter is NULL then the entire client area is scrolled. Unless that is, ....
  • prcClip is a pointer to a RECT structure that defines a "clipping rectangle". This rectangle is the client coordinates of the window area that could be scrolled. No area outside of this rectangle will be affected by the scrolling.
  • hrgnUpdate is a HRGN (a handle-to-region). It is optional, but if specified, it must be initialized as a valid region (it doesn't matter what to). When ScrollWindowEx returns, hrgnUpdate represents the area of the window that has become invalid after the scrolling. This could be an irregular shape if we are scrolling in two dimensions at once.
  • prcUpdate is another RECT structure and receives the bounding rectangle of the update-region (above). It is not terribly useful because hrgnUpdate can be a COMPLEXREGION so a simple RECT structure cannot represent these regions accurately.
  • flags is a simple 32bit value. Usually this value is set to SW_INVALIDATE, to tell ScrollWindowEx to automatically invalidate the region that becomes invalid after the scroll, and add it to the window's update region. The next time the window processes WM_PAINT this invalid region is redrawn. This is the identical region that would be stored in hrgnUpdate (if this parameter was specified). However if we pass zero here, hrgnUpdate does not get invalidated, which would give us the chance to redraw this region later on if we so desired.

Due to the way we are scrolling (we always scroll away from the region we are protecting) it doesn't matter if we use the prcScroll or prcClip rectangles - both rectangles would hold the same values and the effect will be identical. In the sourcecode I have elected to use prcClip just because it is a little more obvious what it is being used for.

Scrolling Example

At this point we need to look at a scrolling example so that we are sure that we understand exactly what is happening with clipping rectangles, and update/invalid regions.

The picture above shows the Neatpad window before any scrolling has taken place. The Window is going to be scrolled upwards and left at the same time (i.e. -1, -1 in text-character-units). However, this means that we scroll the content down one line and right one character position (in order to expose new content at the top/left edges). In other words, the dx and dy parameters to ScrollWindowEx are positive.

Before the ScrollWindowEx function is invoked we define the clipping rectangle to be used. The cross-hatch shaded area represents content outside of the clipping rectangle - although this region is never invalidated or modified by the scrolling it is basically "dirty" and must be redrawn manually. The clipping rectangle we pass to ScrollWindowEx covers the clientarea of content/text that is not shaded. In this example, we took the top/left corner of the client-area and offset it to create the clipping rectangle.

The window after ScrollWindowEx. You should be able to notice that the content has scrolled down one line and right by one character. The cross-hatch/shaded area stays where it is because it is outside of the clipping rectangle. The inverted region represents the area that became invalid after the scrolling took place. I have chosen inverted colours purely to illustrate this area - in reality these pixels do not get modified by ScrollWindowEx and are only updated because the SW_INVALIDATE flag is specified.

Note: If we were using the hrgnUpdate parameter, this HRGN object would be modified to exactly fit the area represented by the inverted colours. It is quite an odd shape (an upside-down "L"), and for this example ScrollWindowEx returns COMPLEXREGION even though we're not using hrgnUpdate.

Both the cross-hatch and inverted regions need to be updated. For simplicity's sake we will use SW_INVALIDATE when we call ScrollWindowEx which will invalidate (and consequently update) the "inverted" region. However this leaves the cross-hatch region - we must manually create a HRGN which describes this area and call InvalidateRgn at some later point to redraw it. See below for how this is achieved.

Synchronized Scrolling

The first thing we must do is develop support for this "clipped" scrolling. I have rewritten the existing TextView::Scroll routine and called it TextView::ScrollRgn. It has an extra parameter now which specifies whether or not to return a handle to the region that is invalid after the scrolling (and indirectly controls the clipping behaviour).

When fReturnUpdateRgn is true, the scrolling is performed with the appropriate clipping, and a HRGN is returned to the caller. When fReturnUpdateRgn is false, the entire window is scrolled normally (i.e. with no clipping area defined).

HRGN TextView::ScrollRgn(int dx, int dy, bool fReturnUpdateRgn)
{
    RECT clip;
    GetClientRect(m_hWnd, &clip);

    // adjust the clipping rectangle fReturnUpdateRgn is false

    // do the scrolling
    ScrollWindowEx(m_hWnd, 
                   -dx * m_nFontWidth, 
                   -dy * m_nFontHeight, 
                   NULL,                   // scroll the entire window
                   &clip,                  // clip the non-scrolling part
                   NULL,
                   NULL,
                   SW_INVALIDATE
                   );

    if(fReturnUpdateRgn)
    {
        RECT client;
        GetClientRect(m_hWnd, &client);

        HRGN hrgnClient  = CreateRectRgnIndirect(&client);
        HRGN hrgnUpdate  = CreateRectRgnIndirect(&clip);

        // create a region that represents the area outside the
        // clipping rectangle (i.e. the part that is never scrolled)
        CombineRgn(hrgnUpdate, hrgnClient, hrgnUpdate, RGN_XOR);

        DeleteObject(hrgnClient);
        return hrgnUpdate;
    }
    return NULL;
}

It is important that we preserve the existing TextView::Scroll functionality so we make this a wrapper function around TextView::ScrollRgn and specify false for fReturnUpdateRgn (i.e. make ScrollRgn scroll the entire window as normal).

VOID TextView::Scroll(int dx, int dy)
{
     ScrollRgn(dx, dy, false);
}

One thing I should mention is the units used by Scroll and ScrollRgn. These are always "text" units (i.e. line/character based) rather than pixel coordinates. The ScrollRgn function converts these to pixel coordinates when it is time to scroll. Also understand that when I write Scroll(-1, -1) this scrolls the document up/left - however the screen content is scrolled down/right to achieve this.

The function below is the "almost" full implementation of the TextView::OnTimer routine. The only part that has been omitted is the code that calculates what the values for dx and dy should be - its clearer without this going on so you can look in the sourcecode download to see how it is done.

LONG TextView::OnTimer()
{
    // [omitted] work out scrolling increments
    int dx, dy;

    // do the scroll but return the region to be manually painted
    HRGN hrgnUpdate = ScrollRgn(dx, dy, true);

    if(hrgnUpdate != NULL)
    {
        // do a "fake" WM_MOUSEMOVE to get the new cursor position
        OnMouseMove(0, mouse_x, mouse_y);

        // manually repaint the update region
        InvalidateRgn(m_hWnd, hrgnUpdate, FALSE);

        DeleteObject(hrgnUpdate);
        UpdateWindow(m_hWnd);
    } 
}

The function fulfills all our criteria for smooth, synchronized scrolling. Firstly the window content is scrolled - however a HRGN is returned which specifies an invalid region that needs manually repainting. The text-caret and selection offsets are computed after doing the scroll (when the m_nxScrollPos variables become valid) - this is achieved by manually calling OnMouseMove and reusing the code that was already there. Just in case the mouse was moved whilst the timer went off, we also take advantage of the fact that OnMouseMove will also invalidate any area affected by selection change.

The very last thing to occur is to manually invalidate the region returned by ScrollRgn. The invalid window areas are finally repainted when UpdateWindow is called, using the updated cursor and selection offsets.

Neatpad additions

Most of this tutorial series will be focussed on the TextView component of Neatpad. I don't intend to cover the development of Neatpad in any great detail unless it is directly related to the support of the TextView. Instead I'll just give a brief mention of what has been added and let the readers study the sourcecode at their leisure.

This time around an Options dialog has been implemented which allows you to select the font and colours used by Neatpad. The dialog is fairly complete and the settings are saved to the registry each time Neatpad exits. The second options-pane doesn't do anything yet, but I have left it in as a "todo" which will be implemented at some point in the future. The code can be found in the Neatpad directory, in the Options.c and OptionsFont.c files.

A new message (TXM_SETCOLOR) has been added to the TextView in order to support programmatic control of colour settings:

#define TXM_SETCOLOR (TXM_BASE + 5)
// wParam = TXC_xxx index value
// lParam = RGB color#define TextView_SetColor(hwndTV, nIndex, rgbColor)

To send the message, use the TextView_SetColor macro. There are two parameters in addition to the window-handle. nIndex is a zero-based value taken from the TXC_xxx range of numbers. rgbColor is (you guessed it) a COLORREF RGB colour. For example, to set the selection background colour, use the following code:

TextView_SetColor(hwndTV, TXC_HIGHLIGHT, RGB(200,100,240));

There is one nice feature about this message which I hope people will like. If you want to set the colour to one of the predefined system colours (i.e. COLOR_WINDOWTEXT used with GetSysColor), then use the SYSCOL macro (defined in TextView.h):

TextView_SetColor(hwndTV, TXC_HIGHLIGHT, SYSCOL(COLOR_3DFACE));

The SYSCOL macro creates a "special" RGB value which the TextView recognises as a system-colour, not a plain RGB value. You only need to set the colour this way once, and subsequent changes in system colour schemes are automatically reflected in the TextView.

Coming up in Part 7

Hopefully I have given a good overview and explanation of how to scroll using the mouse-selection. Although it's not a particularly technical subject, it is fairly difficult to iron out the finer subtleties of mouse and timer interactions, and to be able to visualise these interactions whilst designing something like this (and then write about it!).

Onto the next tutorial then. Part 7 will be something a little simpler (I need to give my brain a rest!), so I will be implementing support for borders and margins. Margins will be provide us the ability to show line numbers and custom icons in a "selection" area (i.e. like Visual Studio uses for placing breakpoints). I also want to provide a "printer margin" on the right-side so that the printable area of a text document is distinguishable from any text that might get clipped due to printing.

You've probably noticed that so far all of the tutorials have been focussed around the graphical / user-interface aspects of Neatpad. This is a deliberate tactic as I feel it is important to have a good foundation before diving into complicated memory/file-management techniques. From experience I know it is easy to get distracted because of a half-finished GUI so I want to get all the GUI details completed.