Setting tab stops in a Windows Forms TextBox control
I was adding a Wizard to one of my applications, and the final
screen of this Wizard was a summary of the user's choices. I
wanted the user to be able to copy this to the Clipboard if
required, and so I'd used a TextBox
rather than the ListView
I might have otherwise used. However, this presented a minor
issue as I'd chosen to use tabs to delimit the information, and
the varying length of text meant that this wasn't aligned as
expected.
Previously I have "dealt" with this issue by cheating - I'd just add extra tabs to force everything to line up. However, I do plan on fully localising this application at some point, not to mention that even using a different font could potentially trigger the text to misalign once more. And so I decided to do it properly this time.
I knew that Win32 Edit
controls (of which the Windows Forms
TextBox
wraps) supported customising tab stops, but this was
functionality the Framework developers did not expose.
Similarly, the RichTextBox
offers the ability to customise tab
stops, but at a selection level and I really couldn't be fussed
with that approach. So I decided to directly invoke the Win32
API to set the tab stops in a TextBox
control. How hard could
it be?
Introducing EM_SETTABSTOPS
I already knew in principle how to do this, by using the
SendMessage
API call and the EM_SETTABSTOPS
message,
although I didn't know the specifics. On checking the
documentation for the message, it stated that when calling
SendMessage
with this message, wParam
is used to define how
the tab stops are set, whilst lParam
has the tab stop values.
The following operations are available
wParam Value | lParam Value | Description |
---|---|---|
0 | 0 | Resets tab stops to default, which is every 32 dialog units |
1 | integer | Sets all tab stops to be every integer dialog units |
2 or more | integer[] | Sets custom tab stops (in dialog units) using each value in integer[]. Although not explicitly documented, the last value in the array is used for any additional tabs the user enters into the control |
Incidentally, there's an odd omission. Almost invariably with the Win32 API, if there's a SET message, there's usually a corresponding GET as well, e.g.
WM_SETTEXT
andWM_GETTEXT
. For some reason though, there is noEM_GETTABSTOPS
- as far as I can tell, there isn't a way of getting tab stop information.
Getting Started
As the EM_SETTABSTOPS
can be called several ways, I'm going to
define 3 different overloads of SendMessage
.
internal static class NativeMethods
{
public const int EM_SETTABSTOPS = 0x00CB;
[DllImport("user32", CharSet = CharSet.Auto)]
public static extern int SendMessage(IntPtr hWnd, int msg, int wParam, int lParam);
[DllImport("user32", CharSet = CharSet.Auto)]
public static extern int SendMessage(IntPtr hWnd, int msg, int wParam, int[] lParam);
[DllImport("user32", CharSet = CharSet.Auto)]
public static extern int SendMessage(IntPtr hWnd, int msg, int wParam, ref int lParam);
}
The documentation for the message states that the
EM_SETTABSTOPS
message doesn't cause theEdit
control to be refreshed, although in my testing this didn't seem to be the case - the control was always repainted. My assumption is theTextBox
control is refreshing itself when receiving certain messages, however I have chosen to also explicitly request a repaint by callingInvalidate
.
Setting all tab stops to be the same fixed value
If you want all tab stops to be the same fixed value with no
variations, you send EM_SETTABSTOPS
with wParam
set to 1
and lParam
to the new tab size.
NativeMethods.SendMessage(textBox.Handle, NativeMethods.EM_SETTABSTOPS, 1, tabSize);
Setting custom tab stops
To specify tab stops of different sizes, you send
EM_SETTABSTOPS
with wParam
with a value greater than one,
and lParam
with an array of tab stop positions.
Although the documentation states that this should be greater than one, I observed (on Windows 10 1809) that unless
wParam
was equal to the length of the array I was passing in, the control didn't behave exactly as expected.
NativeMethods.SendMessage(textBox.Handle, NativeMethods.EM_SETTABSTOPS, tabStops.Length, tabStops);
Important! The array of tab stops is specified as absolute positions, not the size of each stop, e.g each item in the array should be the sum of all previous entries plus the size of the tab stop. So for example, if you wanted tab sizes of
100
,60
and60
dialog units, the values to send would be100
,160
and220
.
Resetting tab stops
To reset tab stops, we send the EM_SETTABSTOPS
message to our
TextBox
with a wParam
value of 0
. lParam
is unused in
this case so I send 0
as well.
NativeMethods.SendMessage(textBox.Handle, NativeMethods.EM_SETTABSTOPS, 0, 0);
Introducing Dialog Units
I mentioned above that tab stops are expressed in terms of dialog units. But what are these? I certainly wasn't familiar with them, pretty much all API calls I've ever used express these types of values in plain pixels.
I can't actually find a dedicated topic in Microsoft's documentation, but essentially a dialog unit is a device independent way of specifying position and size information for dialog controls. I believe they are mostly used in resource templates, but as these are generally the province of C++ applications they aren't actually something I've used before.
As to the actual values of a dialog unit, they are equal to the average width, in pixels, of the characters in the font used by the dialog; the vertical base unit is equal to the height, in pixels, of the font.
There are also a set of base units, which are the same calculations but for the system font. I wonder how many modern Windows developers have seen the system font, given it hasn't been in widespread used since Windows 3.0!
Converting from Dialog Unit to Pixels
In order to convert from dialog units to pixels, you are
supposed to be able to use the MapDialogRect
function, by
passing in the dialog handle and a RECT
structure populated
with the values to convert. The function will modify the RECT
with the converted values, at least in theory. Unfortunately
this is where things get complicated as the documentation states
this function accepts only handles returned by one of the
dialog box creation functions; handles for other windows are not
valid. However, as Windows Forms doesn't use the dialog
functions for creating windows, I was not able to get this API
call to perform a conversion.
The documentation for GetDialogBaseUnits
notes that you
can use GetTextMetrics
to calculate the values for a given
font. I'd already written about this some time ago and so I
did test this but the results were inconclusive, so I wonder if
MapDialogRect
is doing additional actions rather than just
returning the average.
(In a move that doesn't surprise me in the slightest given my experiences with this functionality so far, there isn't a function to take pixel values and convert them into dialog units.)
Fortunately, it doesn't really matter1 as I suppose you don't really want pixel perfect tab stops. I certainly didn't, I just wanted rough values so "columns" would line up, and we can do a naive characters to dialog units conversion by multiplying the number of characters by 4. Not perfect, but good enough.
Auto detecting tab stops
I mentioned in the article preamble that I wanted to reformat summary text so that columns would align. Below is a function that will calculate rough tab stop positions based on a text string.
Note: This is rough and ready code and is doing a lot of string manipulation via
string.Split
so isn't very efficient at all. Originally I was going to count characters from tabs to avoid any type of string processing at all, but I've more than ran out of the time I'd allocated for this article.
public static int[] AutoDetectTabStops(string text)
{
int[] result;
if (!string.IsNullOrEmpty(text) && text.IndexOf('\t') != -1)
{
List<int> tabStops;
string[] lines;
tabStops = new List<int>();
lines = text.Split(_lineSeparators, StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < lines.Length; i++)
{
string[] cells;
cells = lines[i].Split(_cellSeparators);
for (int j = 0; j < cells.Length; j++)
{
int estimatedDialogUnits;
estimatedDialogUnits = cells[j].Length * 4;
if (tabStops.Count <= j)
{
tabStops.Add(estimatedDialogUnits);
}
else if (estimatedDialogUnits > tabStops[j])
{
tabStops[j] = estimatedDialogUnits;
}
}
}
for (int i = 1; i < tabStops.Count; i++)
{
tabStops[i] += tabStops[i - 1];
}
result = tabStops.ToArray();
}
else
{
result = new[] { 32 };
}
return result;
}
public static bool AutoDetectTabStops(this TextBoxBase textBox, string text)
{
if (textBox == null)
{
throw new ArgumentNullException(nameof(textBox));
}
return textBox.SetTabStops(AutoDetectTabStops(text));
}
public static bool AutoDetectTabStops(this TextBoxBase textBox)
{
return textBox.AutoDetectTabStops(textBox.Text);
}
Demonstration project
As usual, a demonstration program is available for the link
below, including a helper class for adding some useful tab stop
related extension methods to the TextBox
and RichTextBox
controls.
1. Also, I lied. I suppose if you were
displaying a ruler for a word processor then you'd very much
want pixel perfect tab stops. I did try doing a basic ruler for
this demonstration, but ended up having to cheat the
calculations as I could get MapDialogRect
to work, I didn't
have time to do things like add scrolling support and at the end
of the day it didn't really add much to the demo.
Update History
- 2019-05-25 - First published
- 2020-11-22 - Updated formatting
Downloads
Filename | Description | Version | Release Date | |
---|---|---|---|---|
TextBoxTabStops.zip
|
Sample project for the Setting tab stops in a Windows Forms TextBox control blog post. |
25/05/2019 | Download |
Leave a Comment
While we appreciate comments from our users, please follow our posting guidelines. Have you tried the Cyotek Forums for support from Cyotek and the community?
Comments
Paul Lehmann
#
how i get teh TextBox.Handle ???
Richard Moss
#
Hello,
It's right there in the article - and you wrote it in your comment. The
Handle
property of most controls will return the underlying hWnd for use with the Windows API. E.g., if my textbox was calledfirstNameTextBox
, to get its handle would beIntPtr handle = firstNameTextBox.Handle
.Regards;
Richard Moss
Brent
#
I get an error when I run your sample and hit any of the SendMessage methods:
System.AccessViolationException HResult=0x80004003 Message=Attempted to read or write protected memory. This is often an indication that other memory is corrupt.
Richard Moss
#
What operating system was this under and was it x86 or x64?