Introduction
Why yet another ellipsis control, when the .NET Framework already provides several built-in options to achieve this task? System.Windows.Forms.Label
control comes with an AutoEllipsis
property. System.Drawing.Graphics.DrawString
or System.Windows.Forms.TextRenderer.DrawText
offer a reliable way to make text fit into predefined boundaries. Just have a look at StringTrimming
or TextFormatFlags
enumeration! Not to mention PathCompactPath
API from shlwapi.dll or Static control styles SS_ENDELLIPSIS
and SS_PATHELLIPSIS
.
Unfortunately, the built-in auto ellipsis controls provide no flexibility at all for ellipsis alignment. The text is always trimmed off at the end the string
. This might be an issue, such as in the following example:
C:\Documents and Settings\TPOL\My Documents\Visual Studio 2005\
Projects\MyProject1\Program.cs
C:\Documents and Settings\TPOL\My Documents\Visual Studio 2005\
Projects\MyProject2\Program.cs
The built-in auto ellipsis controls display paths as follows:
C:\Documents and Settings\TPOL\My Documents\Visual...\Program.cs
C:\Documents and Settings\TPOL\My Documents\Visual...\Program.cs
It would be helpful to keep the last part of the path as it is more significant in this case.
C:\...Documents\Visual Studio 2005\Projects\MyProject1\Program.cs
C:\...Documents\Visual Studio 2005\Projects\MyProject2\Program.cs
By the way, Visual Studio 2005 behaves like this in the "File/Recent Files" menu.
Using the Code
This is why I came up with the Ellipsis
class. It is a static
class with a single method:
public static string Compact(string text, Control ctrl, EllipsisFormat options)
The Compact
function trims off argument text
to make it fit into ctrl
boundaries. EllipsisFormat
enumeration is defined as follows:
[Flags]
public enum EllipsisFormat
{
None = 0,
End = 1,
Start = 2,
Middle = 3,
Path = 4,
Word = 8
}
The Ellipsis
class can be used to implement flexible auto ellipsis on various Windows Form controls. I provided two examples in the demo project, one for Label
control, one for TextBox
control.
The TextBoxEllipsis
switches to "full text" mode when it gains focus so its content can be edited as usual. It switches back to "ellipsis" mode when it loses focus.
Inside the code
Find the Correct Size: The Bisection Method
A working ellipse algorithm should find the longest substring that can fit into the control boundaries. The brute force approach would test all substrings by removing characters one by one. The proposed solution uses the bisection method to minimize the number of iterations to get the closest match.
Bisection example:
The algorithm uses the TextRenderer.MeasureText
method to get the size, in pixels, of the specified text drawn on the specified control (using the control's font). The bisection method is implemented as follows (some code has been removed for clarity):
public static readonly string EllipsisChars = "...";
public static string Compact(string text, Control ctrl, EllipsisFormat options)
{
using (Graphics dc = ctrl.CreateGraphics())
{
Size s = TextRenderer.MeasureText(dc, text, ctrl.Font);
if (s.Width <= ctrl.Width)
return text;
int len = 0;
int seg = text.Length;
string fit = "";
while (seg > 1)
{
seg -= seg / 2;
int left = len + seg;
int right = text.Length;
if (left > right)
continue;
if ((EllipsisFormat.Middle & options) ==
EllipsisFormat.Middle)
{
right -= left / 2;
left -= left / 2;
}
else if ((EllipsisFormat.Start & options) != 0)
{
right -= left;
left = 0;
}
string tst = text.Substring(0, left) +
EllipsisChars + text.Substring(right);
s = TextRenderer.MeasureText(dc, tst, ctrl.Font);
if (s.Width <= ctrl.Width)
{
len += seg;
fit = tst;
}
}
if (len == 0)
{
return EllipsisChars;
}
return fit;
}
}
The Compact
method in action:
Trim at a Word Boundary using Regular Expressions
The .NET Framework allows to trim text at a word boundary. We implement it by adjusting the substring bounds with regular expressions:
"\w*\W*"
matches a word followed by whitespaces
"\W*\w*$"
matches whitespaces followed by a word at the end of the string
These matches are subtracted from the substring (according to ellipsis alignment) in order to round up text at a word boundary.
private static Regex prevWord = new Regex(@"\W*\w*$");
private static Regex nextWord = new Regex(@"\w*\W*");
public static string Compact(string text, Control ctrl, EllipsisFormat options)
{
using (Graphics dc = ctrl.CreateGraphics())
{
[..]
int len = 0;
int seg = text.Length;
string fit = "";
while (seg > 1)
{
seg -= seg / 2;
int left = len + seg;
int right = text.Length;
[..]
if ((EllipsisFormat.Word & options) != 0)
{
if ((EllipsisFormat.End & options) != 0)
{
left -= prevWord.Match(text,
0, left).Length;
}
if ((EllipsisFormat.Start & options) != 0)
{
right += nextWord.Match(text,
right).Length;
}
}
string tst = text.Substring(0, left) +
EllipsisChars + text.Substring(right);
s = TextRenderer.MeasureText(dc, tst, ctrl.Font);
if (s.Width <= ctrl.Width)
{
len += seg;
fit = tst;
}
}
if (len == 0)
{
return EllipsisChars;
}
return fit;
}
}
Example of text trimmed at a word boundary:
Trim a Path String
The "path" mode is a feature where the specified text is handled as a file path. The algorithm preserves as much as possible of the drive and filename information:
- c:\directory1\dir...\filename.ext
- c:\...\filename.ext
- ...\filename.ext (this is the shortest possible path, filename and extension are not truncated).
public static string Compact(string text, Control ctrl, EllipsisFormat options)
{
using (Graphics dc = ctrl.CreateGraphics())
{
[..]
string pre = "";
string mid = text;
string post = "";
bool isPath = (EllipsisFormat.Path & options) != 0;
if (isPath)
{
pre = Path.GetPathRoot(text);
mid = Path.GetDirectoryName(text).Substring(pre.Length);
post = Path.GetFileName(text);
}
int len = 0;
int seg = mid.Length;
string fit = "";
while (seg > 1)
{
seg -= seg / 2;
int left = len + seg;
int right = mid.Length;
[..]
string tst = mid.Substring(0, left) +
EllipsisChars + mid.Substring(right);
if (isPath)
{
tst = Path.Combine(Path.Combine(pre, tst), post);
}
s = TextRenderer.MeasureText(dc, tst, ctrl.Font);
if (s.Width <= ctrl.Width)
{
len += seg;
fit = tst;
}
}
if (len == 0)
{
if (!isPath)
return EllipsisChars;
if (pre.Length == 0 && mid.Length == 0)
return post;
fit = Path.Combine(Path.Combine(pre, EllipsisChars), post);
s = TextRenderer.MeasureText(dc, fit, ctrl.Font);
if (s.Width > ctrl.Width)
fit = Path.Combine(EllipsisChars, post);
}
return fit;
}
}
History
- June 20, 2009 - Original article