Warming up - a Forms control for receiving text output (part 2)

For our control, there are two basic pieces of functionality we need, plus some glue. Those two pieces are: managing the actual text buffer, and drawing to the screen. The glue includes such things as watching for property changes that affect how the control will draw and managing the information used for scrolling.

Scrolling? Yes! It's actually not necessary to implement scrolling. We could just create a control that resizes itself dynamically based on its content, and then the (programmer) user of the control would place it in a scrollable container (like Panel) with AutoScroll set to true. Then, the container would automatically show scroll bars as needed to allow the (end) user to see all of the child control. But, sometimes it's useful for the control itself to handle the scrolling behavior; for example, to allow the control to automatically scroll to keep a particular position, without having to make assumptions about the parent control.

So as part of this SimpleConsole class, we'll add scrolling support by inheriting ScrollableControl (which does most of the work) and including the necessary glue to make that part work the way we want.

Let's start with the text buffer. One of the issues we want to address as compared to other possible solutions is efficiency and ease-of-use. The nature of a text console like this is that lines of text get added to the bottom and fall off the top once the maximum capacity has been reached. So, we need a data structure that efficiently allows us to add things at one end and remove them from the other, and we need some public members to provide access to that data structure.

You've probably already noticed that the description sounds at lot like a queue. And so it does! So, are we going to use .NET's Queue<T> class? That would be nice and easy, right? Unfortunately, the one thing that class is missing is random access. The only way to get at elements from anywhere other than the dequeue end is to enumerate the whole thing. Now, with .NET 3.0 and the LINQ extensions Skip and Take, the code to extract some subset of the queue would be very easy. But it would still have to scan the whole queue.

In reality, this is unlikely to be a genuine performance issue. The naïve approach would work fine in the vast majority of cases. But remember, one of the reasons we're doing all this work is to try to address some of the efficiency issues that the TextBox (not being inherently line-oriented) might have with very large buffer sizes. And besides, it's a fun exercise, so why not?

So, what's in this queue? Well, at a minimum, we have to have each line of text. We'll store those as strings of course. We could even leave it at that. But I'd like our control to only scroll in either axis when necessary, and I don't really want to put an arbitrary limit on line length (though there still is a practical limit based on the .NET graphical coordinate system). Which means we need to keep track of the actual width of each line. Rather than recomputing that every time, we'll compute it once for each line when its added and store that in the queue with the text for the line.

That makes our data structure look like this...

First, a very simple struct to store in the queue:

struct DisplayLine
{
    public readonly string str;
    public readonly int dx;

    public DisplayLine(string str, int dx)
    {
        this.str = str;
        this.dx = dx;
    }
}

Then, the queue itself (done as raw code in our control class...OOP-i-fying this as its own class is left as an exercise for the reader Smile):

private DisplayLine[] _rgdlBuffer;
private int _cdlCur = 0;
private int _idlTail = 0;

We've got the basics here. An array to store the data itself, a counter telling us how much data we have, and an index indicating where the data starts.

For our SimpleConsole, we'll keep operations simple. We'll be able to add text to the queue, change its length, and clear it. Exposing the length of the buffer as a property on our control class, we have:

public int BufferLines
{
    get { return _rgdlBuffer.Length; }
    set
    {
        if (value != _rgdlBuffer.Length)
        {
            _rgdlBuffer = new DisplayLine[value];
            Clear();
        }
    }
}

Note that for simplicity, I've decided that any change to the length of the buffer will clear all of the existing contents. It would not be too difficult to change the size without losing the existing contents, but in the interest of keeping the property simple in this sample, I've left that as an exercise for those who might prefer that behavior.

And what about that Clear() method? All it does is reset our buffer state variables. We could have included a call to Array.Clear() to free up any data that had been in the queue before, but it's not strictly needed for the functionality we want and so again for simplicity I've just left that out.

public void Clear()
{
    _idlTail = 0;
    _cdlCur = 0;
    _fNewLine = true;

    _ComputeVirtualSize(false);
}

And what's that last method call? Remember the glue I mentioned? That's all that is. When the buffer's cleared, we've got a bit of housekeeping to do so that the other parts of the control class work right. We'll see that code shortly. But for now, there's one last interesting part about the buffer management. That's the code that actually does all the heavy lifting when a new line of text is added:

private void _Append(string strText, bool fNewLine)
{
    string[] rgstr = strText.Split(new string[] { "\r\n", "\r", "\n" },
        StringSplitOptions.None);

    // If we're not starting a new line, remove the most
    // recent line from our buffer, and prepend it to
    // the text we're adding.
    //
    // NOTE: _cstrCur will always be > 0 because _fNewLine
    // always starts out true for an empty buffer.
    if (!_fNewLine)
    {
        int idlPrepend = (_idlTail + --_cdlCur) % _rgdlBuffer.Length;

        rgstr[0] = _rgdlBuffer[idlPrepend].str + rgstr[0];
    }

    // Add each line, measuring the line width as we go
    //
    // TODO: we could be smarter about adding lines, in case
    // the passed in text has a number of lines that exceeds
    // the size of our buffer, by only adding the last N
    // lines of the passed in text where N is the size of
    // our buffer.
    using (Graphics gfx = this.CreateGraphics())
    {
        for (int istrAdd = 0; istrAdd < rgstr.Length; istrAdd++)
        {
            int idlT = (_idlTail + _cdlCur++) % _rgdlBuffer.Length;
            string strAdd = rgstr[istrAdd];

            _rgdlBuffer[idlT] = new DisplayLine(strAdd, (int)gfx.MeasureString(strAdd, this.Font).Width);
        }
    }

    // Adjust the tail to account for any excess lines that
    // were added
    _idlTail = (_idlTail + Math.Max(0, _cdlCur - _rgdlBuffer.Length)) % _rgdlBuffer.Length;
    _cdlCur = Math.Min(_cdlCur, _rgdlBuffer.Length);

    _fNewLine = fNewLine;
    _ComputeVirtualSize(false);
}

The comments in the code, I hope, sufficiently describe what each part of that method does. The basic idea is that it's a traditional circular buffer, plus some code to deal with breaking an input string into individual lines and measuring each line. Of course, there's that glue at the end again. Smile

You may note that this method is private, and contains a parameter indicating whether the added text will be followed by a new line or not. The control class exposes this functionality as two separate methods, to make the interface simpler:

public void Append(string strText)
{
    _Append(strText, false);
}

public void AppendLine(string strLine)
{
    _Append(strLine, true);
}

And that's it for buffer management. Next time, actually drawing the data on the screen.

[Edit: this blogging stuff is harder that it looks. Smile So, one of the best ways to review code is to try to explain it to someone else. In reviewing the code for my next post, I realized I had some problems with the code that I wanted to fix, but I'd already posted some of it here. I have shamelessly revised history and replaced any of the changed code that appeared here with the new versions. I'm guessing that this is so early in the history of this blog that no one's even seen the previous version, but either way I hope I didn't inconvenience anyone too much.]

Published Fri, Aug 15 2008 21:47 by Peter
Filed under: , , ,

Leave a Comment

(required) 
(required) 
(optional)
(required) 
If you can't read this number refresh your screen
Enter the numbers above: