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.
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: if (!IsSelectionEmpty())
m_commandHistory.AddCommand(new CDeleteSelectionCommand(this,
CDeleteSelectionCommand::Selection));
break;
case WM_UNDO:
Undo();
return TRUE; }
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:
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.
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);
}
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)
{
if ((GetKeyState(VK_SHIFT) & 0x8000) != 0)
DeleteToTheEnd();
else
DeleteToTheBeginningOfNextWord();
return TRUE;
}
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)
DeleteFromTheBeginning();
else
DeleteFromTheBeginningOfWord();
return TRUE;
}
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'):
if ((GetKeyState(VK_CONTROL) & 0x8000) != 0)
{
SetSel(0, -1);
return TRUE;
}
break;
case _T('Z'):
if ((GetKeyState(VK_CONTROL) & 0x8000) != 0)
{
Undo();
return TRUE;
}
break;
case _T('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:
if (pMsg->wParam == VK_BACK)
{
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:
- when control is filled with the initial text from parent's
OnInitDialog()
method and
- 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:
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)
{
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.
There are two approaches for context menu modification:
- replace it by a completely new custom menu or
- 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).