Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Improved CEdit control

0.00/5 (No votes)
7 Dec 2014 1  
CEdit derived control with additional editing options and multilevel undo/redo.

Introduction

The CEdit control provides only basic editing functionality, like cut/copy/paste and single level undo. It is interesting to note that undo for the single line CEdit control reverts all consecutive text changes, while in multiline controls, only the last character change is undone. This inconsistency may be pretty annoying for the end user particularly if a dialog contains both single line and multiline edit controls.

I decided to improve undo/redo functionality of the edit control and to allow multiple levels of undo/redo. In addition, I chose to include some editing options like deleting text from the current cursor position up to the next/previous character block or to the end/start of text. Finally, the control needs to support standard keyboard shortcuts like Ctrl + A, Ctrl + Z, and Ctrl + Y. Advanced features like syntax highlighting and auto-completion has been left out since they are handled in the derived classes.

Background

To implement the above requirements, the class CEditEx derived from the CEdit control has been defined. CEditEx intercepts editing actions in order to create the corresponding undo/redo operations. After the undo/redo operation is created and pushed to the undo stack, editing command is passed to the base class for further processing. This way it is guaranteed that derived classes (e.g., the class responsible for auto-completion) receive the same messages as if they were derived directly from the CEdit class.

Implementation

Follows a brief implementation description with the most important points of interest.

Multilevel Undo and Redo

Multilevel undo/redo is implemented using the Command design pattern. All commands are derived from the abstract CEditExCommand that has pure virtual methods DoUndo and DoExecute. These methods are implemented in the derived classes. Commands are stored in the CCommandHistory class that consists of undo and redo stacks.

extendedcedit/CommandsClassDiagram.png

Note that all the above classes are defined as local in the CEditEx class, making the entire code compact and easy to reuse.

As already mentioned, editing actions must be intercepted to create undo/redo commands. It is worthwhile to identify which messages must be handled:

  • WM_CHAR in order to track characters typed;
  • WM_PASTE in order to capture text that will be pasted from clipboard;
  • WM_CUT and WM_CLEAR (which are generated by the Delete command on the context menu) in order to capture text that will be removed;
  • WM_UNDO in order to call undo operation from CCommandHistory. This message must not be passed to the base CEdit class since it would handle it in its own way.

The above messages are handled in the overridden WindowProc() method:

LRESULT CEditEx::WindowProc(UINT message, WPARAM wParam, LPARAM lParam)
{
    switch (message)
    {
    case WM_CHAR:
        {
            wchar_t wChar = static_cast<wchar_t>(wParam);
            int nCount = lParam & 0xFF;
            if (iswprint(wChar)) {
                CString newText(wChar, nCount);
                CreateInsertTextCommand(newText);
            }
            // ...
        }
        break;
    case WM_PASTE:
        CreatePasteCommand();
        break;
    case WM_CUT:
    case WM_CLEAR: // delete command from the context menu
        if (!IsSelectionEmpty())
            m_commandHistory.AddCommand(new CDeleteSelectionCommand(this, 
                                        CDeleteSelectionCommand::Selection));
        break; 
    case WM_UNDO:
        Undo();
        return TRUE; // we did the undo and shouldn't pass the message to base class
    }
    return CEdit::WindowProc(message, wParam, lParam);
}

CreateInsertTextCommand() and CreatePasteCommand() are private methods that create commands for the corresponding editing operations. Undo() is the overridden implementation which pops a command from the undo stack and executes it.

It should be noted that Alt + Back keys combination generates WM_UNDO message which is handled inside overridden WindowProc() method. However, Alt + Shift + Back generates the same message, although this combination is meant to redo a command. Therefore, additional check must be done before calling the overriden Undo() method:

// ...
// Alt + Shift + Back also generates WM_UNDO so 
// we must distinguish it to execute redo operation
if ((GetKeyState(VK_BACK) & 0x8000) & (GetKeyState(VK_SHIFT) & 0x8000))
    Redo();
else
    Undo();

Unicode Control Characters

On recent versions of Windows edit controls, the context menu contains additional items like ones to enter specific Unicode control characters. Most of these Unicode control characters are important when combining left-to-right and right-to-left writing.

extendedcedit/UnicodeControlCharacters.png

When the user selects one of these control characters from the menu, the WM_CHAR message is sent to the CEdit control. But contrary to WM_CHAR messages generated by keyboard input, this one has repeat count (contained in the lParam parameter) always equal to 0. Thereafter, this message is handled separately inside the WindowProc() method:

LRESULT CEditEx::WindowProc(UINT message, WPARAM wParam, LPARAM lParam)
{
    switch (message)
    {
    case WM_CHAR:
        {
            wchar_t wChar = static_cast<wchar_t>(wParam);
            int nCount = lParam & 0xFF;
            if (iswprint(wChar))
            {
                CString newText(wChar, nCount);
                CreateInsertTextCommand(newText);
            }
            // special case for Unicode control characters inserted from the context menu
            else if (nCount == 0)
            {
                CString newText(wChar);
                CreateInsertTextCommand(newText);
            }
        }
        break;
    // ...
    }
    return CEdit::WindowProc(message, wParam, lParam);
}

Additional Block Deletion Operations

Additional block deletion operations include:

  • delete text from current cursor position up to the end of block (keyboard shortcut Ctrl + Del);
  • delete text from current cursor position to the end of text (Ctrl + Shift + Del);
  • delete text from the beginning of text block to the current cursor position (Ctrl + Back);
  • delete text from the beginning of text to current cursor position (Ctrl + Shift + Back).

Text block delimiters are identified by character type changes between space character, punctuation, or alphanumeric character, like in the Visual Studio source code editor. Keyboard shortcuts are captured in the overridden PreTranslateMessage() method where the WM_KEYDOWN message is handled:

BOOL CEditEx::PreTranslateMessage(MSG* pMsg)
{
    switch (pMsg->message)
    {
    case WM_KEYDOWN:
        if (PreTranslateKeyDownMessage(pMsg->wParam) == TRUE)
            return TRUE;
        break;
    // ...
    }
    return CEdit::PreTranslateMessage(pMsg);
}

BOOL CEditEx::PreTranslateKeyDownMessage(WPARAM wParam)
{
    switch (wParam)
    {
    // ...
    case VK_DELETE:
        return DoDelete();
    case VK_BACK:
        return DoBackspace();
    }
    return FALSE;
}

BOOL CEditEx::DoDelete()
{
    if ((GetKeyState(VK_CONTROL) & 0x8000) != 0)
    {
        // Ctrl + Delete + Shift
        if ((GetKeyState(VK_SHIFT) & 0x8000) != 0)
            DeleteToTheEnd();
        // Ctrl + Delete
        else
            DeleteToTheBeginningOfNextWord();
        return TRUE;
    }
    // simple delete
    if (IsSelectionEmpty() == false)
        m_commandHistory.AddCommand(new CDeleteSelectionCommand(this, 
                                    CDeleteSelectionCommand::Selection));
    else
    {
        if (GetCursorPosition() < GetWindowTextLength())
            m_commandHistory.AddCommand(new CDeleteCharacterCommand(this, false));
    }
    return FALSE;
}

BOOL CEditEx::DoBackspace()
{
    if ((GetKeyState(VK_CONTROL) & 0x8000) != 0)
    {
        if ((GetKeyState(VK_SHIFT) & 0x8000) != 0)
            // Ctrl + Shift + Back
            DeleteFromTheBeginning();
        else
            // Ctrl + Back
            DeleteFromTheBeginningOfWord();
        return TRUE;
    }
    // plain Back
    if (IsSelectionEmpty() == false)
        m_commandHistory.AddCommand(new CDeleteSelectionCommand(this, 
                                    CDeleteSelectionCommand::Selection));
    else
    {
        if (GetCursorPosition() > 0)
            m_commandHistory.AddCommand(new CDeleteCharacterCommand(this, true));
    }
    return FALSE;
}

DeleteToTheEnd(), DeleteToTheBeginningOfNextWord(), DeleteFromTheBeginning(), and DeleteFromTheBeginningOfWord() are methods that simply extend the current selection and delete the selection applying the corresponding command.

Keyboard shortcuts

This is the easiest task: the PreTranslateMessage() method has to handle the WM_KEYDOWN method for the following shortcuts:

  • Ctrl + A to select entire text;
  • Ctrl + Z to undo an action;
  • Ctrl + Y to redo an action

and call the corresponding operations:

BOOL CEditEx::PreTranslateKeyDownMessage(WPARAM wParam)
{
    switch (wParam)
    {
    case _T('A'):
        // Ctrl + 'A'
        if ((GetKeyState(VK_CONTROL) & 0x8000) != 0)
        {
            SetSel(0, -1);
            return TRUE;
        }
        break;
    case _T('Z'):
        // Ctrl + 'Z'
        if ((GetKeyState(VK_CONTROL) & 0x8000) != 0)
        {
            Undo();
            return TRUE;
        }
        break;
    case _T('Y'):
        // Ctrl + 'Y'
        if ((GetKeyState(VK_CONTROL) & 0x8000) != 0)
        {
            Redo();
            return TRUE;
        }
        break;
    // ...
}

Redo() is, like the Undo() method, an overridden implementation that gets a command from the redo stack and executes it.

For single line edit controls, Alt + Back generates the WM_UNDO message by default, so no additional code is required. However, in multiline mode, CEdit simply ignores this keyboard shortcut! Therefore, it is necessary to capture the Alt + Back shortcut through the generated WM_SYSCHAR message in PreTranslateMessage() and to call the Undo() method:

BOOL CEditEx::PreTranslateMessage(MSG* pMsg)
{
    switch (pMsg->message)
    {
    // ...
    case WM_SYSCHAR:
        // Alt + Back
        if (pMsg->wParam == VK_BACK)
        {
            // for single-line Alt + Back generates
            // WM_UNDO message but not for multiline!
            // Therefore we need to capture
            // this keyboard shortcut for multiline control
            if (IsMultiLine())
            {
                if (GetKeyState(VK_SHIFT) & 0x8000)
                    Redo();
                else
                    Undo();
            }
            return TRUE;
        }
        break;
    }
    return CEdit::PreTranslateMessage(pMsg);
}

Handling Changes Caused by SetWindowText() Function

Initial implementation of the control didn't track changes caused by call of SetWindowText() method. This can be annoying, especially when control content is modified by DDX_Text() function (which calls SetWindowText() function), as noticed in one of comments. To avoid this, WM_SETTEXT message handling has been included into CEditEx::WindowProc(). However, it has to be noted that there are two cases when WM_SETTEXT message shouldn't be tracked:

  1. when control is filled with the initial text from parent's OnInitDialog() method and
  2. when SetWindowText() is called from a method inside command history to perform Undo/Redo commands.

Neglecting the first case would allow user to perform Undo operations beyond initial state, resulting with empty control content in the end. This is easily resolved by checking if control is visible; if control is not visible yet, CSetTextCommand command is not added to the history.

Second case would result with clutter of commands in the history. We need somehow to resolve if text is set by external call or from within command history. To achieve this, a simple trick is employed. WM_SETTEXT message uses only lParam parameter (which is actually the pointer to the string to be set), while wParam is equal to NULL. To distinguish messages which must not be pushed to command history, all internal calls from command history are made by setting wParam to the value of edit control handle. For example:

void CEditEx::InsertText(const CString& textToInsert, int nStart)
{
    ASSERT(nStart <= GetWindowTextLength());
    CString text;
    GetWindowText(text);
    text.Insert(nStart, textToInsert);
    SendMessage(WM_SETTEXT, (WPARAM)m_hWnd, (LPARAM)text.GetString());
}

Consequently, the message handling code looks like:

LRESULT CEditEx::WindowProc(UINT message, WPARAM wParam, LPARAM lParam)
{
    switch (message)
    {
    // ...
    case WM_SETTEXT:
        // add event to history only if control is already visible (to prevent
        // initial call of DDX_Text to be added to command history) and
        // if message has been sent from outside the control (i.e. wParam is NULL)
        if (IsWindowVisible() && (HWND)wParam != m_hWnd)
            m_commandHistory.AddCommand(new CSetTextCommand(this));
        break;
    }
    return CEdit::WindowProc(message, wParam, lParam);
}

Updating the Context Menu

The original context menu contains Undo (WM_UNDO), Cut (WM_CUT), Copy (WM_COPY), Paste (WM_PASTE), Delete (WM_CLEAR), and Select All (EM_SETSEL) entries. If the default context menu is used, then the only problem is how to correctly enable and disable the Undo menu entry. One simple way is to implement the WM_ENTERIDLE message handler and check if it has been called because the menu has been displayed. Then the handle to the menu is obtained and the WM_UNDO command is enabled or disabled checking if the undo command history is empty:

BEGIN_MESSAGE_MAP(CEditEx, CEdit)
    ON_WM_CONTEXTMENU()
    ON_WM_ENTERIDLE()
END_MESSAGE_MAP()

void CEditEx::OnContextMenu(CWnd* pWnd, CPoint point)
{
    m_contextMenuShownFirstTime = true;
    CEdit::OnContextMenu(pWnd, point);
}

void CEditEx::OnEnterIdle(UINT nWhy, CWnd* pWho)
{
    CEdit::OnEnterIdle(nWhy, pWho);
    if (nWhy == MSGF_MENU)
    {
        // update context menu only first time it is displayed
        if (m_contextMenuShownFirstTime)
        {
            m_contextMenuShownFirstTime = false;
            UpdateContextMenuItems(pWho);
        }
    }
}

void CEditEx::UpdateContextMenuItems(CWnd* pWnd)
{
    MENUBARINFO mbi = {0};
    mbi.cbSize = sizeof(MENUBARINFO);
    ::GetMenuBarInfo(pWnd->m_hWnd, OBJID_CLIENT, 0, &mbi);
    HMENU hMenu = mbi.hMenu;
    if (m_commandHistory.CanUndo()) 
        ::EnableMenuItem(hMenu, WM_UNDO, MF_BYCOMMAND | MF_ENABLED);
    else
        ::EnableMenuItem(hMenu, WM_UNDO, MF_BYCOMMAND | MF_GRAYED);
}

Before discussing how to modify default context menu, it should be outlined that the built-in context menu is localization aware and will display the entries in the localized language even if the rest of the application is not localized, as visible from the screenshot below.

extendedcedit/ChineseWindows.png

There are two approaches for context menu modification:

  1. replace it by a completely new custom menu or
  2. modify default menu on the fly.

The second approach requires less effort: it is enough to modify the menu inside the WM_ENTERIDLE message handler. For example, to insert the Redo menu entry below Undo, the above code of UpdateContextMenuItems() should include a call to the InsertMenu() function:

void CEditEx::UpdateContextMenuItems(CWnd* pWnd)
{
    // ...
    int pos = FindMenuPos(pMenu, WM_UNDO);
    if (pos == -1)
        return;

    static TCHAR* strRedo = _T("&Redo");
    MENUITEMINFO mii;
    mii.cbSize = sizeof(MENUITEMINFO);
    mii.fMask = MIIM_ID | MIIM_STATE | MIIM_STRING;
    mii.fType = MFT_STRING;
    mii.fState = m_commandHistory.CanRedo() ? MF_ENABLED : MF_DISABLED;
    mii.wID = ID_EDIT_REDO;
    mii.dwTypeData = strRedo;
    mii.cch = _tclen(strRedo);
    VERIFY(pMenu->InsertMenuItem(pos + 1, &mii, TRUE));
}

FindMenuPos() is a helper method:

UINT CEditEx::FindMenuPos(CMenu* pMenu, UINT myID)
{
    for (UINT pos = pMenu->GetMenuItemCount() - 1; pos >= 0; --pos)
    {
        MENUITEMINFO mii;
        mii.cbSize = sizeof(MENUITEMINFO);
        mii.fMask = MIIM_ID;
        if (pMenu->GetMenuItemInfo(pos, &mii, TRUE) == FALSE)
            return -1;

        if (mii.wID == myID)
            return pos;
    }
    return -1;
}

Moreover, we could also display keyboard shortcuts in the context menu:

void CEditEx::UpdateContextMenuItems(CWnd* pWnd)
{
    // ...
    AppendKeyboardShortcuts(pMenu, WM_UNDO, _T("Ctrl+Z"));
    AppendKeyboardShortcuts(pMenu, ID_EDIT_REDO, _T("Ctrl+Y"));
    AppendKeyboardShortcuts(pMenu, WM_CUT, _T("Ctrl+X"));
    AppendKeyboardShortcuts(pMenu, WM_COPY, _T("Ctrl+C"));
    AppendKeyboardShortcuts(pMenu, WM_PASTE, _T("Ctrl+V"));
    AppendKeyboardShortcuts(pMenu, WM_CLEAR, _T("Del"));
    AppendKeyboardShortcuts(pMenu, EM_SETSEL, _T("Ctrl+A"));
}

void CEditEx::AppendKeyboardShortcuts(CMenu* pMenu, UINT id, LPCTSTR shortcut)
{
    CString caption;
    if (pMenu->GetMenuString(id, caption, MF_BYCOMMAND) == 0)
        return;

    caption.AppendFormat(_T("\t%s"), shortcut);
    MENUITEMINFO mii;
    mii.cbSize = sizeof(MENUITEMINFO);
    mii.fMask = MIIM_STRING;
    mii.fType = MFT_STRING;
    mii.dwTypeData = caption.GetBuffer();
    mii.cch = caption.GetLength();
    pMenu->SetMenuItemInfo(id, &mii);
}

Screenshot below shows the resulting menu.

However, besides additional flickering caused by the menu being rearranged while already shown, this approach can create inconsistencies if translation for the inserted menu entry is not provided since the original menu entries are displayed in the localized language.

Replacing the built-in context menu with a completely new one requires more effort, especially if we want to mimic the default context menu with Unicode support entries and other specific stuff. On the other hand, through controlled localization, this menu would become consistent with the rest of our application.

How to Use the Code

Simply include the EditEx.h and EditEx.cpp files into your project and replace all CEdit class instances with the CEditEx class.

History

  • 10 October 2011 - initial version
  • 7 December 2014 - external call of SetWindowText() handling added, Alt + Shift + Back keys combination handling

Dedication

In memory of my dad Bojan Šribar (1931 - 2011).

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here