Click here to Skip to main content
15,903,523 members
Articles / Programming Languages / C

Check It Out 2.0, One Step Win32 SDK C Checked Combo and List Boxes

Rate me:
Please Sign up or sign in to vote.
5.00/5 (10 votes)
23 Dec 2021LGPL311 min read 8.8K   951   12   6
This article describes adding checkboxes to the standard combobox and listbox control.
This article describes a drop in method for converting standard out of the toolbox combo and list box controls into owner drawn checked combo and list boxes. The article explores an alternative to message reflection for environments that do not support reflection.

Table of Contents

Introduction

Some time ago I published checked Combobox and Listbox [^] controls. The approach I took involved creating a custom window class to encapsulate a combobox or listbox and then owner-draw the controls within while routing messages to the parent dialog without. In response to some helpful comments I decided to make a few improvements to the controls, specifically to return Item Data storage functionality to the control (I had previously used it to store the check state of the item), and to eliminate the need to register a custom control class.

The code with this article demonstrates a different simpler approach that can be used to modify the look and behavior of a standard control, using vanilla Win32 C, while retaining code encapsulation within a customization module. We'll get into the details later meanwhile let's have a look at the controls.

The Checked Combobox

Starting with a valid window handle returned from CreateWindowEx() or, as in the demo, obtained from the dialog designer, pass the handle reference to InstallownerdrawCkComboHandler() after which items may be added.

C++
BOOL Main_OnInitDialog(HWND hwnd, HWND hwndFocus, LPARAM lParam)
{
     HWND hCombo = GetDlgItem(hwnd,IDC_COMBO);

     InstallOwnerDrawCkComboHandler(hwnd, &hCombo) ;

     CheckedComboBox_SetFlatStyleChecks(hCombo, TRUE);
     CheckedComboBox_EnableCheckAll(hCombo, TRUE);

     INT idx = 0;
     idx = ComboBox_AddString(hCombo,_T("Red"));
     ComboBox_SetItemData(hCombo, idx, NewString(_T("Roja")));
     
     // Add more items...

That's pretty much all there is to it. No class registrations needed. A word of caution however, any items you might add to the control before the call to InstallOwnerDrawCkComboHandler() will be lost. The customization process is necessarily destructive.

Note: when adding items with data it is necessary to use ComboBox_SetItemData() to attach the data to the item when using this control.

Messages and Macros for the Checked Combobox

Configure the control to do what you want using Windows messages. This control employs the standard combobox messages / macros with a few exceptions.

Using ComboBox_SetText() or sending WM_SETTEXT explicitly to set the control's text will not work since the text displayed in the control is governed by the selections in the drop down.

I have created the following macros to be used in addition to the standard Combobox macros when configuring this control. If you prefer to call SendMessage() or PostMessage() explicitly, please refer to the macro defs in the header for usage.

CheckedComboBox_SetItemCheck

Checks or unchecks an item in a checked combobox control.

C++
VOID CheckedComboBox_SetItemCheck(
   HWND hwndCtl
   INT iIndex
   BOOL fCheck
   );
/*Parameters
hwndCtl
   Handle of a checked combobox.
iIndex
   The zero-based index of the item for which to set the check state.
fCheck
   A value that is set to TRUE to select the item, or FALSE to deselect it.

Return Values
   No return value.*/

CheckedComboBox_GetItemCheck

Gets the checked state of an item in a checked combobox control.

C++
BOOL CheckedComboBox_GetItemCheck(
   HWND hwndCtl
   INT iIndex
   );
/*Parameters
hwndCtl
   Handle of a checked combobox.
iIndex
   The zero-based index of the item for which to get the check state.

Return Values
   Nonzero if the given item is checked, or zero otherwise.*/

CheckedComboBox_SetItemDisabled

Disables or enables an item in a checked combobox control.

C++
VOID CheckedComboBox_SetItemDisabled(
   HWND hwndCtl
   INT iIndex
   BOOL fDisabled
   );
/*Parameters
hwndCtl
   Handle of a checked combobox.
iIndex
   The zero-based index of the item for which to set the check state.
fDisabled
   A value that is set to TRUE to disables the item, or FALSE to enable it.

Return Values
   No return value.*/

CheckedComboBox_GetItemDisabled

Gets the disabled/enabled state of an item in a checked combobox control.

C++
BOOL CheckedComboBox_GetItemDisabled(
   HWND hwndCtl
   INT iIndex
   );
/*Parameters
hwndCtl
   Handle of a checked combobox.
iIndex
   The zero-based index of the item for which to get the check state.

Return Values
   Nonzero if the given item is disabled, or zero otherwise.*/

CheckedComboBox_SetFlatStyleChecks

Sets the appearance of the check boxes.

C++
VOID CheckedComboBox_SetFlatStyleChecks(
   HWND hwndCtl
   BOOL fFlat
   );
/*Parameters
hwndCtl
   Handle of a checked combobox.
fFlat
   TRUE for flat check boxes, or FALSE for standard check boxes.

Return Values
   No return value.*/

CheckedComboBox_EnableCheckAll

Sets the select/deselect all feature.

C++
VOID CheckedComboBox_EnableCheckAll(
   HWND hwndCtl
   BOOL fEnable
   );
/*Parameters
hwndCtl
   Handle of a checked combobox.
fEnable
   TRUE enables right mouse button select/deselect all feature, or FALSE disables feature.

Return Values
   No return value.*/

CheckedComboBox_isParent

Test a window handle to determine if it belongs to the checked combobox.

C++
BOOL CheckedComboBox_isParent(
   HWND hwndCtl
   HWND hwndChild
   );
/*Parameters
hwndCtl
   Handle of a checked combobox.
hwndChild
   Handle of a potential child of the checked combobox EX: hList or hEdit.

Return Values
   TRUE if hwndChild belongs to the checked combobox.*/

CheckedComboBox_Setseparator

Override the default list separator with the one provided.

C++
VOID CheckedComboBox_Setseparator(
   HWND hwndCtl
   LPCTSTR lpsz
   );
/*Parameters
hwndCtl
   Handle of a checked combobox.
lpsz
   A string containing a single separator character.

Return Values
   No return value.*/

Checked Combobox Notifications

The Checked combobox notification messages are the same as those sent by a standard combobox with one exception. I have added the CBCN_ITEMCHECKCHANGED notification to indicate that the check state of an item in the list has changed. The parent window of the checked combobox receives notification messages through the WM_COMMAND message. Here is the itemcheckchanged notification.

C++
CBCN_ITEMCHECKCHANGED
idComboBox = (int) LOWORD(wParam); 	// identifier of checked combobox
hwndComboBox = (HWND) lParam;     	// handle of checked combobox 

The Checked Listbox

Like the Checked combobox, using this control is fairly straight-forward. Again starting with a valid window handle returned from CreateWindowEx() or obtained from the dialog designer, pass the handle reference to InstallOwnerDrawCkListBoxHandler() after which items may be added..

C++
BOOL Main_OnInitDialog(HWND hwnd, HWND hwndFocus, LPARAM lParam)
{
	HWND hList = GetDlgItem(hwnd,IDC_LIST);

	InstallOwnerDrawCkListBoxHandler(hwnd, &hList);

	CheckedListBox_SetFlatStyleChecks(hList, TRUE);
	
	ListBox_AddString(hList,_T("Ford"));
	ListBox_AddString(hList,_T("Toyota"));

  // Add more items...

Messages and Macros for the Checked Listbox

Configure the control to do what you want using Windows messages. This control employs the standard listbox messages / macros with a few exceptions.

I have created the following macros to be used in addition to the standard Listbox macros when configuring this control. If you prefer to call SendMessage() or PostMessage() explicitly, please refer to the macro defs in the header for usage.

CheckedListBox_SetItemCheck

Checks or unchecks an item in a checked listbox control.

C++
VOID CheckedListBox_SetItemCheck(
   HWND hwndCtl
   INT iIndex
   BOOL fCheck
   );
/*Parameters
hwndCtl
   Handle of a checked listbox.
iIndex
   The zero-based index of the item for which to set the check state.
fCheck
   A value that is set to TRUE to select the item, or FALSE to deselect it.

Return Values
   No return value.*/

CheckedListBox_GetItemCheck

Gets the checked state of an item in a checked listbox control.

C++
BOOL CheckedListBox_GetItemCheck(
   HWND hwndCtl
   INT iIndex
   );
/*Parameters
hwndCtl
   Handle of a checked listbox.
iIndex
   The zero-based index of the item for which to get the check state.

Return Values
   Nonzero if the given item is checked, or zero otherwise.*/

CheckedListBox_SetItemDisabled

Disables or enables an item in a checked listbox control.

C++
VOID CheckedListBox_SetItemDisabled(
   HWND hwndCtl
   INT iIndex
   BOOL fDisabled
   );
/*Parameters
hwndCtl
   Handle of a checked listbox.
iIndex
   The zero-based index of the item for which to set the check state.
fDisabled
   A value that is set to TRUE to disables the item, or FALSE to enable it.

Return Values
   No return value.*/

CheckedListBox_GetItemDisabled

Gets the disabled/enabled state of an item in a checked listbox control.

C++
BOOL CheckedListBox_GetItemDisabled(
   HWND hwndCtl
   INT iIndex
   );
/*Parameters
hwndCtl
   Handle of a checked listbox.
iIndex
   The zero-based index of the item for which to get the check state.

Return Values
   Nonzero if the given item is disabled, or zero otherwise.*/

CheckedListBox_SetFlatStyleChecks

Sets the appearance of the check boxes.

C++
BOOL CheckedListBox_SetFlatStyleChecks(
   HWND hwndCtl
   BOOL fFlat
   );
/*Parameters
hwndCtl
   Handle of a checked listbox.
fFlat
   TRUE for flat check boxes, or FALSE for standard check boxes.

Return Values
   No return value.*/

CheckedListBox_EnableCheckAll

Sets the select/deselect all feature.

C++
BOOL CheckedListBox_EnableCheckAll(
   HWND hwndCtl
   BOOL fEnable
   );
/*Parameters
hwndCtl
   Handle of a checked listbox.
fEnable
   TRUE enables right mouse button select/deselect all feature, or FALSE disables feature.

Return Values
   No return value.*/

Checked Listbox Notifications

The Checked listbox notification messages are the same as those sent by a standard listbox with one exception. I have added the LBCN_ITEMCHECKCHANGED notification to indicate that the check state of an item in the list has changed. The parent window of the checked listbox receives notification messages through the WM_COMMAND message. Here is the itemcheckchanged notification.

C++
LBCN_ITEMCHECKCHANGED
idListBox = (int) LOWORD(wParam); // identifier of checked listbox
hwndListBox = (HWND) lParam;      // handle of checked listbox

Design Considerations (Examples from Checked Combo)

While my previous approach to customizing the combobox and listbox involved the creation of an actual custom windows control with customization code encapsulated within a new window class. This time I decided to hook into an existing control's parent in order to intercept WM_DRAWITEM messages and handle them in the customization code module, thereby facilitating ease of use.

Installing the owner draw hook and handler

Ideally one should be able to open the dialog designer, place an existing standard control, set some basic styles EX: CBS_SORT and then in the code pass the handle to a magic method that converts it to a customized control (in this case a checked combobox.) Let's have a look and see what's going on in said magic method.

C++
BOOL InstallOwnerDrawCkComboHandler(HWND hwnd, HWND *pHwndCombo)
{
    if(!*pHwndCombo) return FALSE;

    // Get all necessary info from combobox
    RECT rc = {0};
    GetWindowRect(*pHwndCombo,&rc);
    MapWindowPoints(HWND_DESKTOP, hwnd, (LPPOINT) & rc.left, 2);
    DWORD dwStyle = (DWORD)GetWindowLongPtr(*pHwndCombo,GWL_STYLE);
    DWORD dwExStyle = (DWORD)GetWindowLongPtr(*pHwndCombo,GWL_EXSTYLE);
    DWORD dwID = (DWORD)GetWindowLongPtr(*pHwndCombo,GWL_ID);
    HINSTANCE hInst = (HINSTANCE)GetWindowLongPtr(*pHwndCombo,GWLP_HINSTANCE);

    if(!DestroyWindow(*pHwndCombo)) return FALSE;

    // Remove incompatible style bits if defined
    dwStyle &= ~(CBS_AUTOHSCROLL|CBS_SIMPLE|CBS_DROPDOWN|CBS_OEMCONVERT| \
        CBS_OWNERDRAWVARIABLE|CBS_LOWERCASE|CBS_UPPERCASE);

    // Add the style bits we need
    dwStyle |= (CBS_DROPDOWNLIST|CBS_OWNERDRAWFIXED|CBS_HASSTRINGS);

    // Recreate window with the modified style bits.
    *pHwndCombo = CreateWindowEx(dwExStyle, WC_COMBOBOX, NULL,
                  dwStyle, rc.left, rc.top, WIDTH(rc), HEIGHT(rc),
                  hwnd,(HMENU)dwID, hInst, NULL);

    if (!*pHwndCombo) return FALSE;

    //Set the font to default
    SNDMSG(*pHwndCombo, WM_SETFONT, (WPARAM)GetStockObject(DEFAULT_GUI_FONT), 0);

    WNDPROC wProc;

    if(NULL == g_ParentHandles)
    {
        g_ParentHandles = New_HandleList();
    }

    INT result = HandleList_AddHandle(g_ParentHandles, hwnd);
    if(LIST_FULL == result)
    {
        return FALSE;
    }
    else if(ADDED == result)
    {
        // Subclass the Parent window so that we can intercept messages
        //  but only once per parent.
        wProc = SubclassWindow(hwnd, Parent_Proc);
        SetProp(hwnd, PARENTPROC, wProc);
    }
    // else PREVIOUSLY_ADDED so proceed

    // Subclass combobox and save the old proc
    wProc = SubclassWindow(*pHwndCombo, ODCCombo_Proc);
    SetProp(*pHwndCombo, WPROC, wProc);    

    // Create and store a circular buffer (for Join())
    SetProp(*pHwndCombo, PROPSTORAGE, (HANDLE) calloc(2, sizeof(LPTSTR)));

    // Create storage for the separator character
    SetProp(*pHwndCombo, PROPSEP, (HANDLE)calloc(2, sizeof(TCHAR)));

    // Store the control type id tag
    SetProp(*pHwndCombo, PROPTYPE, (HANDLE)(DWORD*)(1));    

    // Create place to store options
    SetProp(*pHwndCombo, PROPOPTIONS, (HANDLE)(DWORD*)(0));

    return TRUE;
}

My initial idea was to simply modify the styles of an existing combobox to convert it into an owner drawn control. I soon found that some styles cannot be modified once a control is created, which left only one option, destroy the original and replace it with a new one. This is not ideal but a trade off for ease of use. Alternatively one could define the control properly up front in the dialog designer or with CreateWindowEx() and comment out the first section of this code.

Moving on to the middle section we have several lines of code related to subclassing the parent in order to hook its message procedure and intercept the WM_DRAWITEM messages. It is not common to subclass a control's parent, indeed it offers certain challenges and I had to really think through a number of scenarios.

Q: What if someone uses this code to customize 2 or more controls on the same dialog?

A: A second attempt to subclass the parent's proc would be unnecessary. Therefore one should compare handles passed to this procedure with those previously passed and skip subclassing if there is a match.

Q: What if someone uses this code to customize 2 or more controls on different dialogs simultaneously? For example on three dialogs displayed in a tab control?

A: Subclassing several parents and sending their messages to our hook proc Parent_Proc would not be a problem since the setting of a property in each parent using our property tag would succeed as long as there was not another property already attached that had the same tag.

Q: What about the property tag used to set the property on the parent. Perhaps it's already been used?

A: Avoid sensible tags like "wprc" opt for something like "2b1q" which is obscure to all but a few.

I asked myself a lot of such questions and to be honest almost gave up on the idea until I put it all together and saw that yes, it could be done. My solution allows for an unlimited (memory permitting of course) number of checked comboxes on a single dialog and currently supports up to 8 parent dialogs simultaneously. But a developer can easily change the upper limit of stored parent handles if necessary.

In the final part of the procedure I subclass the combobox itself, allocate storage and bind it to the combobox class by setting properties. One of these properties, PROPTYPE, will be used in the WM_DRAWITEM handler to determine if the item requesting drawing is a Checked combobox or not.

Removing hooks and cleanup

Let's say that we have three checked comboboxes on three separate dialogs displayed in a tab control and we close out one tab destroying the associated dialog and a checked combobox with it. We must unhook from that particular dialog and clean up the allocations associated with that particular control. Let's have a look at the destructors.

C++
/// @brief Handle WM_DESTROY combobox message.
///
/// @param hwnd Handle of parent.
///
/// @returns VOID.
static VOID ODCCombo_OnDestroy(HWND hwnd)
{
    //Free ITEM storage and trigger WM_DELETEITEM messages to parent
    ComboBox_ResetContent(hwnd);
}

/// @brief Handle WM_NCDESTROY combobox message.
///
/// @param hwnd Handle of parent.
///
/// @returns VOID.
static VOID ODCCombo_OnNCDestroy(HWND hwnd)
{
    RemoveProp(hwnd, HWNDLISTBOX);
    RemoveProp(hwnd, PROPTYPE);
    RemoveProp(hwnd, PROPOPTIONS);

    LPTSTR *ppStoriage = (LPTSTR*)GetProp(hwnd, PROPSTORAGE);
    if (NULL != ppStoriage)
    {
        free(ppStoriage[0]);
        free(ppStoriage[1]);
        free(ppStoriage);
        RemoveProp(hwnd, PROPSTORAGE);
    }

    LPTSTR szText = (LPTSTR)GetProp(hwnd, PROPTEXT);
    if (NULL != szText)
    {
        free(szText);
        RemoveProp(hwnd, PROPTEXT);
    }

    szText = (LPTSTR)GetProp(hwnd, PROPSEP);
    if (NULL != szText)
    {
        free(szText);
        RemoveProp(hwnd, PROPSEP);
    }

    WNDPROC wp = (WNDPROC)GetProp(hwnd, WPROC);
    if (NULL != wp)
    {
        SetWindowLongPtr(hwnd, GWLP_WNDPROC, (DWORD)wp);
        RemoveProp(hwnd, WPROC);
    }
}

/// @brief Handle WM_NCDESTROY hook message.
///
/// @param hwnd Handle of parent.
///
/// @returns VOID.
static VOID Parent_OnNCDestroy(HWND hwnd)
{
    //Forward message to be handled by other parent subclass 
    // procedures (if any).
    //Note: This must be called first!
    FORWARD_WM_NCDESTROY(hwnd,Parent_DefProc);

    WNDPROC wp = (WNDPROC)GetProp(hwnd, PARENTPROC);
    if (NULL != wp)
    {
        SetWindowLongPtr(hwnd, GWLP_WNDPROC, (DWORD)wp);
        RemoveProp(hwnd, PARENTPROC);

        HandleList_RemoveHandle(g_ParentHandles, hwnd);
    }
    if (HandleList_IsEmpty(g_ParentHandles))
    {
        HandleList_Free(&g_ParentHandles);
    }
}

In the first destructor in this snippet the call to reset content will be handled by the combo's CB_RESETCONTENT handler. Here the item allocations are freed and WM_DELETEITEM messages are triggered to be processed by the parent. Since this must take place before the control is completely destroyed it is handled in response to the first of two destructor messages WM_DESTROY which might be thought of as a WM_PREDESTROY in this context.

The second destructor in this snippet is pretty straight forward all remaining allocations are freed and all properties are removed for each and every Checked combobox customized using this code when they are destroyed. The WM_NCDESTROY message is the second and final destructor message sent to the control.

The third destructor however is dedicated to un-subclassing the parent and removing the unused hook properties while updating the list of parent handles. The list can hold an unlimited number of parent handles but only 8 at a time as it is currently configured, so each time a parent is destroyed it's handle reference is removed from the list. Finally when the last parent window is destroyed the list allocation itself is recycled.

Keeping track of item checks and item data

In my previous treatment of these checked controls [^] I used the item data of each item to store the check state of that item. Unfortunately this didn't allow for the attachment of data to the items. This time I decided to employ a structure with a field for the check state and a field for the item data that would be stored in a combo's list item data property or field. This approach though, came with some challenges.

  • The data structure must be allocated and added to each item when that item is added to the control.
  • The data structure must necessarily be garbage collected when an item is removed from the control, but independently from the item user data which is the responsibility of the user.
  • Every message related to an item that affects the item data must be intercepted and handled in such a way that the user data is either packed into the structure for storage with the check state or unpacked and presented to the user.
  • Unfortunately it is impossible to intercept WM_DELETEITEM messages in the hook proc Parent_Proc and I was unable to hook it using other methods.

For a while I thought I was beat. However I coded around the edges of the problem, addressing those parts I understood, and as I a got closer to the WM_DELETEITEM dilemma it suddenly vanished! Here are some pertinent snippets.

Adding/Inserting an item - First let the combo perform a default addition/insertion then assign our own ITEM structure to the combo's item data. Note that with an owner drawn combobox or listbox with the HASSTRINGS style set, as this one has, the only way to set item data is via the respective SETITEMDATA messages so the overall behavior of the control remains native to the user.

C++
static INT ODCCombo_OnAddString(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    INT index = ODCCombo_DefProc(hwnd,  msg, wParam, lParam);
    LPITEM lpi = (LPITEM)checked_malloc(sizeof(ITEM));
    if(lpi)
    {
        INT iRtn = ODCCombo_DefProc(hwnd, CB_SETITEMDATA, (WPARAM)index, (LPARAM)lpi);
        return (CB_ERR == iRtn || CB_ERRSPACE == iRtn)? iRtn : index;
    }
    //if we get here we were unsuccessful so undo AddString
    ODCCombo_OnDeleteString(hwnd,  msg, (WPARAM)index, lParam);
    return CB_ERR;
}

Setting item data - First get a reference to our ITEM object, then assign the pointer to the lpData member.

C++
static INT ODCCombo_OnSetItemData(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    LPITEM lpi = (LPITEM)ODCCombo_DefProc(hwnd, CB_GETITEMDATA, wParam, 0);
    if(lpi)
    {
        lpi->lpData = lParam;
        return CB_OKAY;
    }
    return CB_ERR;
}

Deleting an item - First get our ITEM structure then set the item data to point to the user's data before freeing our allocation. Finally we call the default process to actually delete the item with the user's data. This will result in the issuance of the WM_DELETEITEM message with the user's data instead of our struct which is the desired default behavior.

Resetting content - Same as deleting an item except we loop through the items and set each item data to point the the user's data, then free the ITEM structure allocations before calling the default process to actually reset the combobox. Again the flurry of WM_DELETEITEM messages will contain the users data.

C++
static INT ODCCombo_OnDeleteString(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    LPITEM lpi = (LPITEM)ODCCombo_DefProc(hwnd, CB_GETITEMDATA, wParam, 0);
    if(lpi)
    {
        ODCCombo_DefProc(hwnd, CB_SETITEMDATA, wParam, lpi->lpData);
        free(lpi);
    }
    return ODCCombo_DefProc(hwnd,  msg, wParam, lParam);
}

Ensure that all item allocations are garbage collected before control expiration - Just a simple one liner in the destructor which I previously discussed. I love it when a plan comes together!

C++
//Free ITEM storage and trigger WM_DELETEITEM messages to parent
ComboBox_ResetContent(hwnd);

Final Comments

I documented this source with Doxygen [^] comments for those that might find it helpful or useful. Your feedback is appreciated.

History

  • 23rd December, 2021: Version 1.0.0.0

License

This article, along with any associated source code and files, is licensed under The GNU Lesser General Public License (LGPLv3)


Written By
United States United States
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionHow do you know Zaparozhets? Pin
Member 380249224-Dec-21 10:30
Member 380249224-Dec-21 10:30 
AnswerRe: How do you know Zaparozhets? Pin
David MacDermot24-Dec-21 11:20
David MacDermot24-Dec-21 11:20 
QuestionVery nice! Love to see win32 stuff. Pin
davercadman24-Dec-21 9:02
davercadman24-Dec-21 9:02 
AnswerRe: Very nice! Love to see win32 stuff. Pin
David MacDermot24-Dec-21 10:36
David MacDermot24-Dec-21 10:36 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA23-Dec-21 18:06
professionalȘtefan-Mihai MOGA23-Dec-21 18:06 
GeneralRe: My vote of 5 Pin
David MacDermot24-Dec-21 13:54
David MacDermot24-Dec-21 13:54 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.