Adding a Cursor and Keyboard Support to the Clipboard History Plug-in
Welcome to Part 4 of the series showing how to build plug-ins for Visual Studio using DXCore.
So far we've:
In today's post we'll add a cursor to indicate the selected clipboard entry. We'll also add keyboard support so we can move the cursor around. By the end of today's post, we should be pasting entries from the clipboard history into the editor (or any other text field inside Visual Studio)!
Adding a Cursor
We need a way to show which clipboard history element is selected. I want to keep this simple, so I'm thinking about placing each CodeView inside a slightly larger Panel, and then change the BackColor of the panel that corresponds to the selected cursor position.
First, let's declare two static fields to represent the cursor position:
static int _CursorRow = 0;
static int _CursorColumn = 0;
Now that we have these fields, we can add a SetIndex method to compliment the GetIndex method that we added previously:
private static void SetIndex(int index)
{
_CursorRow = index / ClipboardHistory.ColumnCount;
_CursorColumn = index % ClipboardHistory.ColumnCount;
}
And we need fields to hold the size and color of the border:
static int CursorBorder = 3;
internal static Color SelectedBorderColor = Color.Red;
I'm making SelectedBorderColor internal because I'm thinking we might want to eventually allow developers to set this from an options page or settings file.
Next, an array of Panels to go with our _CodeViews array:
static Panel[] _Borders = new Panel[ClipboardHistory.LastIndex + 1];
The new borders will need to be created. We can do that inside a revised CreateViews method:
private static void CreateViews()
{
for (int i = 0; i <= ClipboardHistory.LastIndex; i++)
{
_CodeViews[ i ] = new CodeView();
_CodeViews[ i ].Location = new Point(CursorBorder, CursorBorder);
_Borders[ i ] = new Panel();
_Borders[ i ].Controls.Add(_CodeViews[ i ]);
_FrmClipHistory.Controls.Add(_Borders[ i ]);
}
}
Notice I'm setting the Location of each CodeView in the CreateViews method since that will never change.
Next, we need to change our PositionViews method so it works with the parenting borders we just added:
private static void PositionViews()
{
if (_FrmClipHistory == null)
return;
Rectangle clientRect = _FrmClipHistory.ClientRectangle;
int width = clientRect.Width / ClipboardHistory.ColumnCount;
int height = clientRect.Height / ClipboardHistory.RowCount;
Size viewSize = new Size(width - CursorBorder * 2, height - CursorBorder * 2);
Size borderSize = new Size(width, height);for (int row = 0; row < ClipboardHistory.RowCount; row++)
for (int column = 0; column < ClipboardHistory.ColumnCount; column++)
{
int index = GetIndex(row, column);
CodeView thisView = _CodeViews[index];
Panel thisBorder = _Borders[index];
if (thisView == null || thisBorder == null)
continue;
thisBorder.Size = borderSize;
thisBorder.Location = new Point(width * column, height * row);
thisView.Size = viewSize;
}
}
The CleanUpViews method will also need to clean up our _Borders array:
private static void CleanUpViews()
{
for (int i = 0; i <= ClipboardHistory.LastIndex; i++)
{
_CodeViews[ i ] = null;
_Borders[ i ] = null;
}
}
And we'll need a few utility methods (and one property) to make it easier to work with the cursor:
internal static int CursorIndex
{
get
{
return GetIndex(_CursorRow, _CursorColumn);
}
set
{
SetIndex(value);
KeepCursorInBounds();
}
}
private static void ShowCursor()
{
SetBorderColor(CursorIndex, SelectedBorderColor);
}
private static void HideCursor()
{
if (_FrmClipHistory == null)
return;
SetBorderColor(CursorIndex, _FrmClipHistory.BackColor);
}
private static void SetBorderColor(int index, Color backgroundColor)
{
if (index >= 0 && index <= ClipboardHistory.LastIndex)
{
Panel thisBorder = _Borders[index];
if (thisBorder != null)
thisBorder.BackColor = backgroundColor;
}
}
And finally, we'll call ShowCursor from inside our ShowClipboardHistory method:
public static void ShowClipboardHistory()
{
if (_FrmClipHistory != null)
return;
_FrmClipHistory = new FrmClipHistory();
try
{
CreateViews();
PositionViews();
UpdateViews();
ShowCursor();
_FrmClipHistory.ShowDialog(CodeRush.IDE);
}
finally
{
CleanUpViews();
if (_FrmClipHistory != null)
_FrmClipHistory.Dispose();
_FrmClipHistory = null;
}
}
We'll call HideCursor later, after we implement keyboard interaction. For now, let's run and test (remember to copy source code to the clipboard a few times before pressing Ctrl+Shift+Insert)....
I get this:
That'll work. Next, we'll implement a keyboard interface so we can move that cursor around and ultimately insert text into the editor....
Keyboard Interaction
I have a pretty clear picture of how I want this to work: The arrow keys will drive the selection, and pressing Right arrow when the selected element is on the far right will simply wrap the cursor around to the far left (and vice versa) in the same row. Similar wrap-around behavior will occur in response to Up/Down arrow keys when the cursor is at the top/bottom, respectively (with the cursor staying in the same column). Pressing Enter will accept the entry, close the dialog, and insert that entry on the clipboard. And it would be nice if that text was then pasted into the edit window (or whatever text control had focus prior to bringing up this dialog). Pressing Escape will cancel and close the dialog.
So, how do we get notified when those keys are pressed? It turns out in Windows Forms, it's not as straight forward to make this work as it is in WPF, which is where I've spent a lot of my time in the past few months. The existing WinForms key down and key pressed events remain ominously silent in spite of furious typing on the keyboard. To get a preview of keys pressed on the form, regardless of which control has focus, you need to override the ProcessCmdKey method in the form. Here we can check for the keys we are interested in (Enter, Escape, and the arrow keys), and in turn call methods to move the cursor, close the form, and paste the text.
The ProcessCmdKey method looks like this:
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
const int WM_KEYDOWN = 0x100;if (msg.Msg == WM_KEYDOWN)
{
switch (keyData)
{
case Keys.Enter:
CloseAndPaste();
return true;case Keys.Escape:
Close();
return true;
case Keys.Left:
MoveCursor(0, -1);
return true;
case Keys.Right:
MoveCursor(0, 1);
return true;
case Keys.Up:
MoveCursor(-1, 0);
return true;
case Keys.Down:
MoveCursor(1, 0);
return true;
}
}
return base.ProcessCmdKey(ref msg, keyData);
}
Note that returning true in each of the shortcut-trapping case blocks above prevents the control with focus from receiving the key.
MoveCursor looks like this:
private static void MoveCursor(int rowDelta, int columnDelta)
{
HideCursor();
_CursorRow += rowDelta;
_CursorColumn += columnDelta;
KeepCursorInBounds();
ShowCursor();
}
KeepCursorInBounds and its helper method KeepValueInBounds both ensure the _CursorRow and _CursorColumn fields hold legal values, even when the cursor position wraps from one side of the form to the another:
private static void KeepValueInBounds(ref int value, int upperLimit)
{
if (value < 0)
value += upperLimit;
if (value >= upperLimit)
value -= upperLimit;
}
private static void KeepCursorInBounds()
{
KeepValueInBounds(ref _CursorRow, ClipboardHistory.RowCount);
KeepValueInBounds(ref _CursorColumn, ClipboardHistory.ColumnCount);
}
CloseAndPaste needs to set the clipboard text to the selected item from the clipboard history, and it needs to set a new internal static field, PasteOnClose to true. We'll add code to check that field in a moment, but we don't want to do any actual pasting from here, because the form is still alive and will be active inside this method because it is being called from the ProcessCmdKey instance method.
internal static bool PasteOnClose;
private void CloseAndPaste()
{
PasteOnClose = true;
string thisText = ClipboardHistory.GetText(CursorIndex);
if (thisText != Clipboard.GetText() && !String.IsNullOrEmpty(thisText)) // Need to put text on clipboard.
Clipboard.SetText(thisText);
NativeMethods.PostMessage(Handle, WindowMessage.WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
}
Note that there is one small problem caused by the code above. The call to Clipboard.SetText is going to trigger our ClipboardChanged event handler back in our plug-in (and we'll want to ignore this one).
Since PasteOnClose is internal, we can check that field from our ClipboardChanged handler, and if true, that means we can ignore this ClipboardChanged event. So our handler, over in PlugIn1.cs, can now look like this:
private void PlugIn1_ClipboardChanged(ClipboardChangedEventArgs ea)
{
if (!FrmClipHistory.PasteOnClose)
ClipboardHistory.AddEntry(ea.Details);
}
We need to remember to set PasteOnClose to false in our Paste method (more on this below) so we don't introduce any lingering state bugs. This will ensure the historical entries do not reorder (see the ClipboardHistory.AddEntry method) simply due to a paste from the Clipboard History form.
Switching back to FrmClipHistory.cs, the IndexIsValid method looks like this:
private static bool IndexIsValid(int index)
{
return index >= 0 && index <= ClipboardHistory.LastIndex;
}
And changes to ShowClipboardHistory look like this:
public static void ShowClipboardHistory()
{
if (_FrmClipHistory != null)
return;
_FrmClipHistory = new FrmClipHistory();
PasteOnClose = false;
try
{
CreateViews();
PositionViews();
UpdateViews();
ShowCursor();
_FrmClipHistory.ShowDialog(CodeRush.IDE);
}
finally
{
CleanUpViews();
if (_FrmClipHistory != null)
_FrmClipHistory.Dispose();
_FrmClipHistory = null;
}
if (PasteOnClose)
Paste();
}
Finally we come to the Paste method. This turned out to be tricky. At first I tried sending a WM_Paste message, but I found that many controls ignored this message. Next I tried sending the WM_KeyDown and WM_KeyUp messages for Ctrl+V, however, I found that some controls responded to the WM_KeyDown and others responded only to the WM_Char. Furthermore, I learned that the controls that handled WM_Char were expecting to see 22 ASCII, which is Ctrl+V, however the controls listening to WM_KeyDown were expecting to see simply the letter V (and they also check the state of the Control key at the time the message arrives). Ultimately I settled upon the following code, which appears to be the minimum needed to generate a paste with all the controls I tested with (including Visual Studio's main edit window):
using DevExpress.CodeRush.Win32;
private static void Paste()
{
PasteOnClose = false;
if (CodeRush.Editor.HasFocus)
CodeRush.Command.Execute("Edit.Paste");
else
{
HWND activeControl = NativeMethods.GetFocus();
IntPtr V = new IntPtr(NativeMethods.GetKeyValue(Keys.V));
const int CtrlV = 22; // ASCII for Ctrl+V.
NativeMethods.SetKeyPressed(Keys.Control, true);
NativeMethods.SendMessage(activeControl, WindowMessage.WM_KEYDOWN, V, IntPtr.Zero);
NativeMethods.SendMessage(activeControl, WindowMessage.WM_CHAR, new IntPtr(CtrlV), IntPtr.Zero);
NativeMethods.SendMessage(activeControl, WindowMessage.WM_KEYUP, V, IntPtr.Zero);
NativeMethods.SetKeyPressed(Keys.Control, false);
}
}
Note that since we're dealing with all these windows messages, we must add a namespace reference to DevExpress.CodeRush.Win32.
Testing Keyboard Interaction
Let's test our work. Click Run once again....
In the second instance of Visual Studio, open some source code and copy different selections a number of times.
Press Ctrl+Shift+Insert to bring up the Clipboard History. Select an entry with the arrow keys. Press Escape to cancel. The form should close. Repeat these steps, but press Enter instead to paste the selected entry. You should find the code correctly inserts itself into any active edit control inside Visual Studio.
Tomorrow we'll add some neat code to calculate a good position for the form (based on what we'll be pasting into), and we'll show how we can update the clipboard history when the form is visible and the clipboard changes from another application. See you then!