Warming up - a Forms control for receiving text output (part 3)
I hope this post isn't too much of a disappointment. After promising to demonstrate how to implement a custom Forms control we're only just now, in the third of the series, getting to drawing the control contents on the screen. And as you'll see, there's so little to it you may wind up wondering "what's all the fuss?"
Since we just talked about the text buffer management, an appropriate way to lead to the drawing part would be the glue that connects the two together. So, let's start there.
Whenever the buffer itself changes, we need to recompute the size of the virtual display area of the control. There are a few pieces of information that influence this: the font used to display the text, the longest measured width of all the lines of text, and the number of lines of text.
One of those pieces of information has two ways to become invalid. Specifically, the longest measured width of all the lines is dependent both on the font being used, and the actual lines of text. So, we have a helper method to deal with changes to either of those data:
private void _ComputeVirtualSize(bool fMeasureText)
// If the font changed, we need to recalculate the widths
// of every single line
_dxLineMax = fMeasureText ? _DxLineMaxMeasured() : _DxLineMax();
Logically, this method is called any time something happens to invalidate the measurements based on the buffer data itself. These changes affect the "virtual size" of the control; that is, the whole size of the displayed data, as opposed to the bit you can see at any given time as determined by the scroll bars when the virtual size exceeds the on-screen size. Once the method is done with calculations, it calls Control.Invalidate to ensure that whatever changes prompted this recalculation are shown by forcing the control to redraw itself.
Of course, the real work is done in the helper methods. Here's one of them:
private int _DxLineMaxMeasured()
int dxMax = 0;
using (Graphics gfx = this.CreateGraphics())
for (int iline = 0; iline < _cdlCur; iline++)
int idl = (_idlTail + iline) % _rgdlBuffer.Length;
string str = _rgdlBuffer[idl].str;
int dx = (int)gfx.MeasureString(str, this.Font).Width;
_rgdlBuffer[idl] = new DisplayLine(str, dx);
if (dx > dxMax)
dxMax = dx;
The _DxLineMax method is similar, except that it doesn't actually remeasure each line of text. It just looks at the current computed length for each line.
The _UpdateScrollBars method handles the logic for connecting the text measurements to the user-interface:
private void _UpdateScrollBars()
bool fScrollToEnd = -AutoScrollPosition.Y + ClientSize.Height >= AutoScrollMinSize.Height;
AutoScrollMinSize = new Size(_dxLineMax, this.Font.Height * _cdlCur);
AutoScrollPosition = new Point(-AutoScrollPosition.X, AutoScrollMinSize.Height - ClientSize.Height);
This method first checks to see if the last line of text is visible, then updates the virtual size for the control (the AutoScrollMinSize property). Finally, if the last line of text was visible, it resets the scroll bars to the end of their range again.
That last bit may be the trickiest part of this whole control. The AutoScrollPosition is an odd property indeed. When assigned, you must pass positive values to it. But when it's retrieved, it returns negative values.
This behavior sort of makes sense. That is, the AutoScrollPosition's return value is tailor-made to be passed straight to a translation transformation to be used when drawing, so the negative values seem reasonable. Also, it would be odd to think of moving the scroll bars forward through the document by decrementing their position, so assignment of the position uses positive, increasing numbers. But putting both of those ideas together in the same property seems odd to me. I sort of wish Microsoft had just made two different properties, each with their own specific, consistent behavior.
But they didn't, and this is how it is. So it's important to be aware of this little idiosyncrasy.
The font itself is managed by the base Control class. But we can watch for changes by overriding the OnFontChanged method, and so we do:
protected override void OnFontChanged(EventArgs e)
Any time the font changes, that invalidates any measurement we made of the text, so we have to pass "true" to the _ComputeBufferData method so that it knows to remeasure every line of text rather than just searching through the lines for the current maximum.
Now that we've gotten all the glue out, what was it again that we're gluing? That's right: the text buffer management, which we've already seen, and the control drawing, which we haven't.
In a custom control, there's exactly one place where you actually draw to the screen. That's the Control.OnPaint method, which you override in your own Control sub-class. In Windows, drawing to the screen is done on an "on-demand" basis. That is, the control must always be prepared to draw its current state, and Windows will ask it to draw that state any time something has caused the current on-screen representation of the control to be incorrect.
Note that one way this can happen is if we say so explicitly, by calling the Control.Invalidate method, as described earlier. Other ways this can happen is if the window containing the control is moved partially on- or off-screen, or is rearranged relative to other windows such that the areas that are visible change.
(By the way, this design is not unique to Windows. Many GUI systems employ the same paradigm, including the Mac OS and the standard GUI frameworks in Java).
(Also by the way, a window — that is, a Form sub-class when using .NET Forms — is just a special case of a Control, so all the same redraw rules apply).
So, what does our OnPaint method look like? Here it is:
protected override void OnPaint(PaintEventArgs e)
Graphics gfx = e.Graphics;
int ilineDraw = (int)(-AutoScrollPosition.Y / this.Font.Height);
int yDrawCur = ilineDraw * this.Font.Height,
yDrawMax = -AutoScrollPosition.Y + this.Height;
using (Brush brush = new SolidBrush(this.ForeColor))
while (yDrawCur < yDrawMax && ilineDraw < _cdlCur)
int idlCur = (_idlTail + ilineDraw++) % _rgdlBuffer.Length;
gfx.DrawString(_rgdlBuffer[idlCur].str, this.Font, brush, 0, yDrawCur);
yDrawCur += this.Font.Height;
Simple, isn't it? Maybe even surprisingly so. When the OnPaint method is called, .NET has already created a System.Drawing.Graphics instance and configured it for the current drawing situation (including setting up the clipping so that only the parts of your control that are visible are drawn). This Graphics instance is passed to the OnPaint method via the PaintEventArgs parameter.
Our control is actually reasonably simple, being just a single list of lines of text. So, in our custom OnPaint method we just need to draw each line in the right spot. In this particular case, I've also included a bit of logic to compute the first and last lines that are actually visible, so that we don't waste time drawing lines that can't be seen. In truth, graphics that are drawn outside the clipped area are handled much faster than graphics that actually wind up on-screen. But it's an easy optimization to make, so we might as well.
So what are the key elements of this OnPaint method?
It starts out by calculating the range of lines, as I mentioned. It actually does this by calculating the line index of the first line that would be visible in the control, and then by calculating the vertical pixel coordinate of the last line that would be visible. Since we're updating the current pixel coordinates as we draw the lines anyway, I find it simpler to just do the straight comparison rather than the full calculation.
Then, we adjust the transformation of the Graphics instance to account for the scroll bars. This shifts all of the drawing so that the part that winds up drawing into the visible area is correct according to the position of the scroll bars.
Finally, we actually draw. We create a new brush based on the ForeColor property of the control (note the "using" statement — it's very important to ensure that disposable objects like a Brush are disposed of when you're done with them!). Then we just loop while we have strings to draw and room to draw them in, drawing each one as we go and updating our drawing position according to the font height.
As is often the case, the code is a much more concise way of describing the process. But hopefully the narrative helps elaborate on some of the non-obvious aspects to or implicit requirements hidden in the code.
And so ends my little "warm-up" here, in which I try to describe an example of a custom Forms control, providing enough detail to help anyone else trying to learn the basics of writing a custom control. The key thing is to maintain the state of the control such that it's always ready to draw, and then always draw in the OnPaint method. The full code for the SimpleConsole class can be found here. If you look at it, you'll note that I included support for attaching this console to anything that uses a TraceListener, by exposing a custom TraceListener object that writes to the SimpleConsole instance. Isn't that fun?