กก

กก

Splitter Bar


splitter
A splitter bar is a useful control that is not part of the Windows' common bag of controls. How difficult is it to implement it? Not so difficult, as it turns out, once you know the basics of Windows API. The description that follows might seem complicated at first, but you'll be learning several very important techniques that can be used over and over in a variety of situations. Working with child windows, mouse capture, drawing using xor mode, just to mention a few.
 

A splitter bar is a window. More precisely, it's a child window. It is positioned between two other child windows--we'll call these left and right panes, respectively (or top and bottom, for a horizontal splitter). There must also be a main window that fathers the three children.

Without further ado, here's the code in WinMain that sets up the stage.


// Create top window class
TopWinClassMaker topWinClass (WndProcMain, ID_MAIN, hInst, ID_MAIN);
topWinClass.Register ();

// Create child pane classes
WinClassMaker paneClass (WndProcPane, IDC_PANE , hInst);
paneClass.SetSysCursor (IDC_IBEAM);
paneClass.SetDblClicks ();
paneClass.Register ();

Splitter::RegisterClass (hInst);

// Create top window
ResString caption (hInst, ID_CAPTION);
TopWinMaker topWin (caption, ID_MAIN, hInst);
topWin.Create ();
topWin.Show (cmdShow);

First, we register classes. The top window class is associated with its window procedure WndProcMain, which we'll examine in a moment. The two child panes share the same window class associated with WndProcPane. Next, our own splitter class is registered (we'll see the code soon). Finally, the top window is created and displayed. Child windows are created dynamically during the initialization of the parent window.

Here's the window procedure of the top window.


LRESULT CALLBACK WndProcMain (HWND hwnd, 
                             UINT message, 
WPARAM wParam, 
LPARAM lParam)
{
    Controller * pCtrl = GetWinLong<Controller *> (hwnd);

    switch (message)
    {
    case WM_CREATE:
        try
        {
            pCtrl = new Controller (hwnd, 
                                    reinterpret_cast<CREATESTRUCT *>(lParam));
            SetWinLong<Controller *> (hwnd, pCtrl);
        }
        catch (char const * msg)
        {
            MessageBox (hwnd, msg, "Initialization", 
                MB_ICONEXCLAMATION | MB_OK);
            return -1;
        }
        catch (...)
        {
            MessageBox (hwnd, "Unknown Error", "Initialization", 
                MB_ICONEXCLAMATION | MB_OK);
            return -1;
        }
        return 0;
    case WM_SIZE:
        pCtrl->Size (LOWORD(lParam), HIWORD(lParam));
        return 0;
    case MSG_MOVESPLITTER:
        pCtrl->MoveSplitter (wParam);
        return 0;
    case WM_DESTROY:
        SetWinLong<Controller *> (hwnd, 0);
        delete pCtrl;
        return 0;
    }

    return ::DefWindowProc (hwnd, message, wParam, lParam);
}

It's a pretty standard window procedure, except for one message, MSG_MOVESPLITTER. This is our own, user-defined message that is sent by the splitter control to the parent window. But first, let's have a look at the main window controller.


class Controller
{
public:
    Controller (HWND hwnd, CREATESTRUCT * pCreat);
    ~Controller ();
    void Size (int cx, int cy);
    void MoveSplitter (int x);

private:

    enum { splitWidth = 8 };    // width of splitter

    // User Interface
    HWnd            _hwnd;          //Main controller window
    HWnd            _leftWin;
    HWnd            _rightWin;
    HWnd            _splitter;
    int             _splitRatio;    // in per cent
    int             _cx;
    int             _cy;

};

It has a handle to itself, the two child panes, and the splitter window. It also stores the current split ratio, in percent.

The controller's constructor is responsible for the creation of child windows.


Controller::Controller (HWND hwnd, CREATESTRUCT * pCreat)
   :
    _hwnd (hwnd),
    _leftWin (0),
    _rightWin (0),
    _splitter (0),
    _splitRatio (50)
{
    // Create child windows
    {
        ChildWinMaker     leftWinMaker (IDC_PANE, _hwnd, ID_LEFT_WINDOW);
        leftWinMaker.Create ();
        _leftWin.Init (leftWinMaker);

        leftWinMaker.Show ();
    }

    {
        ChildWinMaker  rightWinMaker (IDC_PANE, _hwnd, ID_RIGHT_WINDOW);
        rightWinMaker.Create ();
        _rightWin.Init (rightWinMaker);

        rightWinMaker.Show ();
    }

    Splitter::MakeWindow (_splitter, _hwnd, ID_SPLITTER);
}

When the user drags the splitter bar, the parent receives the MSG_MOVESPLITTER messages. The parameter wParam contains the new distance of the splitter bar from the left edge of the parent window. In response to such a message, the parent has to resize both child panes and move the splitter. It does it by calling the Size method.


void Controller::MoveSplitter (int x)
{
    _splitRatio = x * 100 / _cx;
    if (_splitRatio < 0)
        _splitRatio = 0;
		
    else if (_splitRatio > 100)
        _splitRatio = 100;
    Size (_cx, _cy);
}

In general, Size is called whenever the user resizes the main window, but as you've just seen, we also call it when the splitter is moved.


void Controller::Size (int cx, int cy)
{
    _cx = cx;
    _cy = cy;
    int xSplit = (_cx * _splitRatio) / 100;
    if (xSplit < 0)
        xSplit = 0;
    if (xSplit + splitWidth >= _cx)
        xSplit = _cx - splitWidth;

    _splitter.MoveDelayPaint (xSplit, 0, splitWidth, cy);
    _leftWin.Move (0, 0, xSplit, cy);
    _rightWin.Move (xSplit + splitWidth, 0, cx - xSplit - splitWidth, cy);

    _splitter.ForceRepaint ();
}

Notice an important trick here. We move the splitter, but delay its repainting until both panes, to its left and to its right, are resized. This technique eliminates some nasty smudging.


 

That's it as far as client code goes. Now you might be interested to see the implementation of the splitter.

First of all, we like to combine related functions into namespaces. You've seen the calls to Splitter::RegisterClass and Splitter::MakeWindow. The Splitter part of these names is the namespace.


namespace Splitter
{
    void RegisterClass (HINSTANCE hInst);
    void MakeWindow (HWnd & hwndSplitter /* out */, 
                     HWnd hwndParent, 
                     int childId);
};

Here's how these functions are implemented


LRESULT CALLBACK WndProcSplitter (HWND hwnd, 
                                       UINT message, 
                                       WPARAM wParam, 
                                       LPARAM lParam);

void Splitter::RegisterClass (HINSTANCE hInst)
{
    WinClassMaker splitterClass (WndProcSplitter, "RsSplitterClass", hInst);
    splitterClass.SetSysCursor (IDC_SIZEWE);
    splitterClass.SetBgSysColor (COLOR_3DFACE);
    splitterClass.Register ();
}

void Splitter::MakeWindow (HWnd & hwndSplitter, HWnd hwndParent, int childId)
{
    ChildWinMaker splitterMaker ("RsSplitterClass", hwndParent, childId);
    splitterMaker.Create ();
    hwndSplitter.Init (splitterMaker);
    splitterMaker.Show ();
}

The mouse cursor, IDC_SIZEWE, that we associate with the splitter class is the standard Size-West-East double arrow. We also set the background brush to COLOR_3DFACE.

The splitter's window procedure deals with splitter's creation/destruction, painting and mouse dragging.


LRESULT CALLBACK WndProcSplitter (HWND hwnd, 
                               UINT message, 
                               WPARAM wParam, 
                               LPARAM lParam)
{
    SplitController * pCtrl = GetWinLong<SplitController *> (hwnd);
    switch (message)
    {
    case WM_CREATE:
        try
        {
            pCtrl = new SplitController (hwnd, 
                               reinterpret_cast<CREATESTRUCT *>(lParam));
            SetWinLong<SplitController *> (hwnd, pCtrl);
        }
        catch (char const * msg)
        {
            MessageBox (hwnd, msg, "Initialization", 
                MB_ICONEXCLAMATION | MB_OK);
            return -1;
        }
        catch (...)
        {
            MessageBox (hwnd, "Unknown Error", "Initialization", 
                MB_ICONEXCLAMATION | MB_OK);
            return -1;
        }
        return 0;
    case WM_SIZE:
        pCtrl->Size (LOWORD(lParam), HIWORD(lParam));
        return 0;
    case WM_PAINT:
        pCtrl->Paint ();
        return 0;
    case WM_LBUTTONDOWN:
        pCtrl->LButtonDown (MAKEPOINTS (lParam));
        return 0;
    case WM_LBUTTONUP:
        pCtrl->LButtonUp (MAKEPOINTS (lParam));
        return 0;
    case WM_MOUSEMOVE:
        if (wParam & MK_LBUTTON)
            pCtrl->LButtonDrag (MAKEPOINTS (lParam));
        return 0;
    case WM_CAPTURECHANGED:
        pCtrl->CaptureChanged ();
        return 0;
    case WM_DESTROY:
        SetWinLong<SplitController *> (hwnd, 0);
        delete pCtrl;
        return 0;
    }
    return ::DefWindowProc (hwnd, message, wParam, lParam);
}

This is all pretty much standard code. The details, as usual, are in the controller's methods. The constructor is very simple.


SplitController::SplitController (HWND hwnd, CREATESTRUCT * pCreat)
    : _hwnd (hwnd), 
      _hwndParent (pCreat->hwndParent)
{}

Painting is more interesting. We have to imitate Windows 2.5-dimensional effects. We do it with a carfuly chosen selection of pens.


class Pens3d
{
public:
    Pens3d ();
    Pen & Hilight () { return _penHilight; }
    Pen & Light () { return _penLight; }
    Pen & Shadow () { return _penShadow; }
    Pen & DkShadow () { return _penDkShadow; }
private:
    Pen        _penHilight;
    Pen        _penLight;
    Pen        _penShadow;
    Pen        _penDkShadow;
};

Pens3d::Pens3d ()
:
    _penLight (GetSysColor (COLOR_3DLIGHT)),
    _penHilight (GetSysColor (COLOR_3DHILIGHT)),
    _penShadow (GetSysColor (COLOR_3DSHADOW)),
    _penDkShadow (GetSysColor (COLOR_3DDKSHADOW))
{}

void SplitController::Paint ()
{
    PaintCanvas canvas (_hwnd);
    {
        PenHolder pen (canvas, _pens.Light ());
        canvas.Line (0, 0, 0, _cy - 1);
    }
    {
        PenHolder pen (canvas, _pens.Hilight ());
        canvas.Line (1, 0, 1, _cy - 1);
    }
    {
        PenHolder pen (canvas, _pens.Shadow ());
        canvas.Line (_cx - 2, 0, _cx - 2, _cy - 1);
    }
    {
        PenHolder pen (canvas, _pens.DkShadow ());
        canvas.Line (_cx - 1, 0, _cx - 1, _cy - 1);
    }
}

The tricky part is the processing of mouse messages, although most of this code is pretty standard. We have to be able to deal with button-down, mouse drag and button-up.


void SplitController::LButtonDown (POINTS pt)
{
    _hwnd.CaptureMouse ();
    // Find x offset of splitter
    // with respect to parent client area
    POINT ptOrg = {0, 0 };
    _hwndParent.ClientToScreen (ptOrg);
    int xParent = ptOrg.x;
    ptOrg.x = 0;
    _hwnd.ClientToScreen (ptOrg);
    int xChild = ptOrg.x;

    _dragStart = xChild - xParent + _cx / 2 - pt.x;

    _dragX = _dragStart + pt.x;

    // Draw a divider using XOR mode
    UpdateCanvas canvas (_hwndParent);
    ModeSetter mode (canvas, R2_NOTXORPEN);
    canvas.Line (_dragX, 0, _dragX, _cy - 1);

}

When the left mouse button is clicked over the client area of the splitter, we perform the following tasks. First, we capture the mouse. The user might, and probably will, drag the mouse cursor outside of the splitter bar. Capturing the mouse will ensure that all mouse messages will now be directed to us, even though the mouse cursor may wander all over the screen.

Next, we convert the local splitter-bar coordinates to the parent window coordinates. We do it by converting the parent's origin to screen coordinates and converting our (splitter's) origin to screen coordinates. The difference gives us our origin with respect to parent's client area. We do some more arithmetics in order to find the x coordinate of the center of the splitter, because that's what we'll be dragging.

To give the user dragging feedback, we draw a single vertical line that will be dragged across the client area of the parent window. Notice two important details. The canvas that we are creating are associated with the parent--we are using the parent's window handle. The drawing mode is logical xor (exclusive or). That means that the pixels we are drawing are xor'd with the original pixels. Logical xor has this useful property that if you apply it twice, you restore the original. It's the oldest trick in computer graphics--the poor man's animation technique. You draw something using the xor mode and erase it simply by drawing it again in the xor mode. Just look at the dragging code below.


void SplitController::LButtonDrag (POINTS pt)
{
    if (_hwnd.HasCapture ())
    {
        // Erase previous divider and draw new one
        UpdateCanvas canvas (_hwndParent);
        ModeSetter mode (canvas, R2_NOTXORPEN);
        canvas.Line (_dragX, 0, _dragX, _cy - 1);
        _dragX = _dragStart + pt.x;
        canvas.Line (_dragX, 0, _dragX, _cy - 1);
    }
}

We draw the vertical line in the xor mode using the previous remembered position. Since this is the second time we draw this line in the same place, the net result will be the restoration of the original pixels from under the line. Then we draw the new line in the new position, still in the xor mode. We remember this position, so that next time LButtonDrag is called we'll erase it too. And so on, until finally the user releases the mouse button.


void SplitController::LButtonUp (POINTS pt)
{
    // Calling ReleaseCapture will send us the WM_CAPTURECHANGED
    _hwnd.ReleaseMouse ();
    _hwndParent.SendMessage (MSG_MOVESPLITTER, _dragStart + pt.x);
}

At this point we should erase the vertical line for the last time. However, we don't do it directly--we use a little trick here. We release the mouse capture. As a side effect, Windows sends us the WM_CAPTURECHANGED message. It's during the processing of that message that we actually erase the vertical line.


void SplitController::CaptureChanged ()
{
    // We are losing capture
    // End drag selection -- for whatever reason
    // Erase previous divider
    UpdateCanvas canvas (_hwndParent);
    ModeSetter mode (canvas, R2_NOTXORPEN);
    canvas.Line (_dragX, 0, _dragX, _cy - 1);
}

Why do we do that? It's because Windows can force us to release the mouse capture before the user releases the mouse button. It might happen, for instance, when another application suddenly decides to pop up its window while the user is in the middle of dragging. In such a case our window would never get the button-up message and, if we weren't clever, wouldn't be able to cleanly finish the drag. Fortunately, before taking away the mouse capture, Windows manages to send us the WM_CAPTURECHANGED message, and we take it as a clue to do our cleanup.

Going back to LButtonUp--once the user finishes dragging, we send the parent our special message MSG_MOVESPLITTER, passing it the new position of the center of the splitter bar measured in parent's client area coordinates. You've already seen the client code response to this message.

This is how one might define a client message.


// Reserved by Reliable Software Library
const UINT MSG_RS_LIBRARY        = WM_USER + 0x4000;

// wParam = new position wrt parent's left edge
const UINT MSG_MOVESPLITTER        = MSG_RS_LIBRARY + 1;

Finally, here's an excerpt from a very useful class HWnd that encapsulates a lot of basic Windows APIs that deal with windows. In particular, look at the methods MoveDelayPaint and ForceRepaint that we used in repainting the splitter bar.


class HWnd
{
public:
    void Update ()
    { 
        ::UpdateWindow (_hwnd); 
    }
    // Moving
    void Move (int x, int y, int width, int height)
    {
        ::MoveWindow (_hwnd, x, y, width, height, TRUE);
    }
    void MoveDelayPaint (int x, int y, int width, int height)
    {
        ::MoveWindow (_hwnd, x, y, width, height, FALSE);
    }
    // Repainting
    void Invalidate ()
    {
        ::InvalidateRect (_hwnd, 0, TRUE);
    }
    void ForceRepaint ()
    {
        Invalidate ();
        Update ();
    }
private:
    HWND    _hwnd;
};

 
As usual, you can download the complete source code of the application that was used in this example. Next tutorial talks about bitmaps.