I recently discovered how slow Console.MoveBufferArea actually is. I used it for writing NLog output to a console and discovered that at times of high output it became dead slow. I could at times see it flicker and update line for line while the whole machine temporarily came to a crawl.
As I searched the web I found that this problem is common, but I could not find any suitable solution to it. So I made one myself. I found some info on how to access the Win32 console API in this SO question, as well as the ColorConsoleAppender source code for log4net.
If you are only looking to implement a faster Console app then read my results. This code may or may not be for you.
Output box
While implementing use of WriteConsoleOutput I added some enhancements over the standard Console.WriteLine() functionality. Basically you define boxes. Each box will scroll individually of the others. So you can have two boxes side-by-side scrolling individual content, or you can have a small box or you can have a single status line separate from the scrolling screen. It writes to the window buffer so that the console scroll bars will work as expected (if size of windows is less than that of the buffer).
Input box
I also needed an input-box. And turns out that the normal Console.ReadLine() easily looses its content when you repaint the screen. Even if I bypassed this by reading the backbuffer content and retaining this over updates it is a standard multiline edit, and if you are on the last line of the window strange stuff happens (scrolls the whole content).
So I implemented my own little ReadLine() function that supports left-arrow, right-arrow, backspace, delete, typing letters and pressing enter. (Should probably have added home/end and stuff too.)
Taking it further
It would be easy to take the source code further to support foreground and background coloring of individual letters.
Result
I took care to implement this whole thing so it doesn’t use too much CPU. Now running a test reveals some surprising results:
Description | Time for 10 000 calls | |
Normal Console.WriteLine | 0,2599168 seconds | |
Console.MoveBuffer + WriteLine ++ | 23,4431487 seconds | |
InputConsoleBox with AutoDraw=true | 6,9907394 seconds | |
InputConsoleBox with AutoDraw=false and one call to Draw() at the end | 0,017288 seconds |
The bad parts
After a quick profiling session it turns out that calling WriteConsoleOutput is dead slow taking 99% CPU-time on the “AutoDraw=true” test. And from what I can find through searching the web there just isn’t any way around this.
If you implement your own redraw function (calling box.Draw()) and get a 10x speed increase out of console writing and a whopping 1356x speed increase over MoveBuffer. If you imeplement your own redraw then note that Windows does the redraw asynchronously (even after being so slow), so you may want to implement double buffering to avoid flickering (hint: you can create two boxes and just copy each others buffers upon swap).
The good parts
There are some clear advantages on using this implementation:
- I can control what to scroll.
- I can have a status-line unaffected by the scrolling.
- I can have a ReadLine() at the last line of the screen that is unaffected by the scrolling.
- Flickering is much less than with MoveBuffer.
- Implementing my own timer does give me a 1356x speed increase over MoveBuffer.
Sourcecode
Example usage
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
_logBox = new InputConsoleBox(0, 0, (short)Console.BufferWidth, (short)(Console.BufferHeight - 2), InputConsoleBox.Colors.LightWhite, InputConsoleBox.Colors.Black); _statusBox = new InputConsoleBox(0, (short)(Console.BufferHeight - 3), (short)Console.BufferWidth, 1, InputConsoleBox.Colors.LightYellow, InputConsoleBox.Colors.DarkBlue); _inputBox = new InputConsoleBox(0, (short)(Console.BufferHeight - 2), (short)Console.BufferWidth, 1, InputConsoleBox.Colors.LightYellow, InputConsoleBox.Colors.Black); _statusBox.WriteLine("Hey there!"); _inputBox.InputPrompt = "Command: "; _logBox.WriteLine("Write lots of lines on a separate thread or whatever..."); // If you are okay with some slight flickering this is an easy way to set up a refresh timer _logBox.AutoDraw = false; _redrawTask = Task.Factory.StartNew(async () => { while (true) { await Task.Delay(100); if (_logBox.IsDirty) _logBox.Draw(); } }); |
The implementation
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 |
using System; using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; using System.Threading; using Microsoft.Win32.SafeHandles; public class InputConsoleBox { #region Output #region Win32 interop private const UInt32 STD_OUTPUT_HANDLE = unchecked((UInt32)(-11)); private const UInt32 STD_ERROR_HANDLE = unchecked((UInt32)(-12)); [DllImport("Kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] private static extern IntPtr GetStdHandle(UInt32 type); [DllImport("Kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] private static extern SafeFileHandle CreateFile( string fileName, [MarshalAs(UnmanagedType.U4)] uint fileAccess, [MarshalAs(UnmanagedType.U4)] uint fileShare, IntPtr securityAttributes, [MarshalAs(UnmanagedType.U4)] FileMode creationDisposition, [MarshalAs(UnmanagedType.U4)] int flags, IntPtr template); [DllImport("kernel32.dll", SetLastError = true)] private static extern bool WriteConsoleOutput( SafeFileHandle hConsoleOutput, CharInfo[] lpBuffer, Coord dwBufferSize, Coord dwBufferCoord, ref SmallRect lpWriteRegion); [StructLayout(LayoutKind.Sequential)] private struct Coord { public short X; public short Y; public Coord(short X, short Y) { this.X = X; this.Y = Y; } }; [StructLayout(LayoutKind.Explicit)] private struct CharUnion { [FieldOffset(0)] public char UnicodeChar; [FieldOffset(0)] public byte AsciiChar; } [StructLayout(LayoutKind.Explicit)] private struct CharInfo { [FieldOffset(0)] public CharUnion Char; [FieldOffset(2)] public ushort Attributes; public CharInfo(char @char, ushort attributes) { this.Char = new CharUnion(); Char.UnicodeChar = @char; Attributes = attributes; } } [StructLayout(LayoutKind.Sequential)] private struct SmallRect { public short Left; public short Top; public short Right; public short Bottom; } #endregion #region Colors Enum private const int HighIntensity = 0x0008; private const ushort COMMON_LVB_LEADING_BYTE = 0x0100; private const ushort COMMON_LVB_TRAILING_BYTE = 0x0200; private const ushort COMMON_LVB_GRID_HORIZONTAL = 0x0400; private const ushort COMMON_LVB_GRID_LVERTICAL = 0x0800; private const ushort COMMON_LVB_GRID_RVERTICAL = 0x1000; private const ushort COMMON_LVB_REVERSE_VIDEO = 0x4000; private const ushort COMMON_LVB_UNDERSCORE = 0x8000; private const ushort COMMON_LVB_SBCSDBCS = 0x0300; [Flags] public enum Colors : int { Black = 0x0000, DarkBlue = 0x0001, DarkGreen = 0x0002, DarkRed = 0x0004, Gray = DarkBlue | DarkGreen | DarkRed, DarkYellow = DarkRed | DarkGreen, DarkPurple = DarkRed | DarkBlue, DarkCyan = DarkGreen | DarkBlue, LightBlue = DarkBlue | HighIntensity, LightGreen = DarkGreen | HighIntensity, LightRed = DarkRed | HighIntensity, LightWhite = Gray | HighIntensity, LightYellow = DarkYellow | HighIntensity, LightPurple = DarkPurple | HighIntensity, LightCyan = DarkCyan | HighIntensity } #endregion // Colors Enum private readonly CharInfo[] _buffer; private readonly List<CharInfo> _tmpBuffer; private readonly short _left; private readonly short _top; private readonly short _width; private readonly short _height; private ushort _defaultColor; private int _cursorLeft; private int _cursorTop; private static SafeFileHandle _safeFileHandle; /// <summary> /// Automatically draw to console. /// Unset this if you want to manually control when (and what order) boxes are writen to consoles - or you want to batch some stuff. /// You must manually call <c>Draw()</c> to write to console. /// </summary> public bool AutoDraw = true; public bool IsDirty { get; private set; } public InputConsoleBox(short left, short top, short width, short height, Colors defaultForegroundColor = Colors.Gray, Colors defaultBackgroundColor = Colors.Black) { if (left < 0 || top < 0 || left + width > Console.BufferWidth || top + height > Console.BufferHeight) throw new Exception(string.Format("Attempting to create a box {0},{1}->{2},{3} that is out of buffer bounds 0,0->{4},{5}", left, top, left + width, top + height, Console.BufferWidth, Console.BufferHeight)); _left = left; _top = top; _width = width; _height = height; _buffer = new CharInfo[_width * _height]; _defaultColor = CombineColors(defaultForegroundColor, defaultBackgroundColor); _tmpBuffer = new List<CharInfo>(_width * _height); // Assumption that we won't be writing much more than a screenful (backbufferfull) in every write operation //SafeFileHandle h = CreateFile("CONOUT$", 0x40000000, 2, IntPtr.Zero, FileMode.Open, 0, IntPtr.Zero); if (_safeFileHandle == null) { var stdOutputHandle = GetStdHandle(STD_OUTPUT_HANDLE); _safeFileHandle = new SafeFileHandle(stdOutputHandle, false); } Clear(); Draw(); } public void Clear() { for (int y = 0; y < _height; y++) { for (int x = 0; x < _width; x++) { var i = (y * _width) + x; _buffer[i].Char.UnicodeChar = ' '; _buffer[i].Attributes = _defaultColor; } } IsDirty = true; // Update screen if (AutoDraw) Draw(); } public void Draw() { IsDirty = false; var rect = new SmallRect() { Left = _left, Top = _top, Right = (short)(_left + _width), Bottom = (short)(_top + _height) }; bool b = WriteConsoleOutput(_safeFileHandle, _buffer, new Coord(_width, _height), new Coord(0, 0), ref rect); } private static ushort CombineColors(Colors foreColor, Colors backColor) { return (ushort)((int)foreColor + (((int)backColor) << 4)); } public void SetCursorPosition(int left, int top) { if (left >= _width || top >= _height) throw new Exception(string.Format("Position out of bounds attempting to set cursor at box pos {0},{1} when box size is only {2},{3}.", left, top, _width, _height)); _cursorLeft = left; _cursorTop = top; } public void SetCursorBlink(int left, int top, bool state) { Console.SetCursorPosition(left, top); Console.CursorVisible = state; //// Does not work //var i = (top * _width) + left; //if (state) // _buffer[i].Attributes = (ushort)((int)_buffer[i].Attributes & ~(int)COMMON_LVB_UNDERSCORE); //else // _buffer[i].Attributes = (ushort)((int)_buffer[i].Attributes | (int)COMMON_LVB_UNDERSCORE); //if (AutoDraw) // Draw(); } public void WriteLine(string line, Colors fgColor, Colors bgColor) { var c = _defaultColor; _defaultColor = CombineColors(fgColor, bgColor); WriteLine(line); _defaultColor = c; } public void WriteLine(string line) { Write(line + "\n"); } public void Write(string text) { Write(text.ToCharArray()); } public void Write(char[] text) { IsDirty = true; _tmpBuffer.Clear(); bool newLine = false; // Old-school! Could definitively have been done more easily with regex. :) var col = 0; var row = -1; for (int i = 0; i < text.Length; i++) { // Detect newline if (text[i] == '\n') newLine = true; if (text[i] == '\r') { newLine = true; // Skip following \n if (i + 1 < text.Length && text[i] == '\n') i++; } // Keep track of column and row col++; if (col == _width) { col = 0; row++; if (newLine) // Last character was newline? Skip filling the whole next line with empty { newLine = false; continue; } } // If we are newlining we need to fill the remaining with blanks if (newLine) { newLine = false; for (int i2 = col; i2 <= _width; i2++) { _tmpBuffer.Add(new CharInfo(' ', _defaultColor)); } col = 0; row++; continue; } if (i >= text.Length) break; // Add character _tmpBuffer.Add(new CharInfo(text[i], _defaultColor)); } var cursorI = (_cursorTop * _width) + _cursorLeft; // Get our end position var end = cursorI + _tmpBuffer.Count; // If we are overflowing (scrolling) then we need to complete our last line with spaces (align buffer with line ending) if (end > _buffer.Length && col != 0) { for (int i = col; i <= _width; i++) { _tmpBuffer.Add(new CharInfo(' ', _defaultColor)); } col = 0; row++; } // Chop start of buffer to fit into destination buffer if (_tmpBuffer.Count > _buffer.Length) _tmpBuffer.RemoveRange(0, _tmpBuffer.Count - _buffer.Length); // Convert to array so we can batch copy var tmpArray = _tmpBuffer.ToArray(); // Are we going to write outside of buffer? end = cursorI + _tmpBuffer.Count; var scrollUp = 0; if (end > _buffer.Length) { scrollUp = end - _buffer.Length; } // Scroll up if (scrollUp > 0) { Array.Copy(_buffer, scrollUp, _buffer, 0, _buffer.Length - scrollUp); cursorI -= scrollUp; } var lastPos = Math.Min(_buffer.Length, cursorI + tmpArray.Length); var firstPos = lastPos - tmpArray.Length; // Copy new data in Array.Copy(tmpArray, 0, _buffer, firstPos, tmpArray.Length); // Set new cursor position _cursorLeft = col; _cursorTop = Math.Min(_height, _cursorTop + row + 1); // Write to main buffer if (AutoDraw) Draw(); } #endregion #region Input private string _currentInputBuffer = ""; private string _inputPrompt; private int _inputCursorPos = 0; private int _inputFrameStart = 0; // Not used because COMMON_LVB_UNDERSCORE doesn't work //private bool _inputCursorState = false; //private int _inputCursorStateChange = 0; private int _cursorBlinkLeft = 0; private int _cursorBlinkTop = 0; public string InputPrompt { get { return _inputPrompt; } set { _inputPrompt = value; ResetInput(); } } private void ResetInput() { SetCursorPosition(0, 0); _inputCursorPos = Math.Min(_currentInputBuffer.Length, _inputCursorPos); var inputPrompt = InputPrompt + "[" + _currentInputBuffer.Length + "] "; // What is the max length we can write? var maxLen = _width - inputPrompt.Length; if (maxLen < 0) return; if (_inputCursorPos > _inputFrameStart + maxLen) _inputFrameStart = _inputCursorPos - maxLen; if (_inputCursorPos < _inputFrameStart) _inputFrameStart = _inputCursorPos; _cursorBlinkLeft = inputPrompt.Length + _inputCursorPos - _inputFrameStart; //if (_currentInputBuffer.Length - _inputFrameStart < maxLen) // _inputFrameStart--; // Write and pad the end var str = inputPrompt + _currentInputBuffer.Substring(_inputFrameStart, Math.Min(_currentInputBuffer.Length - _inputFrameStart, maxLen)); var spaceLen = _width - str.Length; Write(str + (spaceLen > 0 ? new String(' ', spaceLen) : "")); UpdateCursorBlink(true); } private void UpdateCursorBlink(bool force) { // Since COMMON_LVB_UNDERSCORE doesn't work we won't be controlling blink //// Blink the cursor //if (Environment.TickCount > _inputCursorStateChange) //{ // _inputCursorStateChange = Environment.TickCount + 250; // _inputCursorState = !_inputCursorState; // force = true; //} //if (force) // SetCursorBlink(_cursorBlinkLeft, _cursorBlinkTop, _inputCursorState); SetCursorBlink(_left + _cursorBlinkLeft, _top + _cursorBlinkTop, true); } public string ReadLine() { Console.CursorVisible = false; Clear(); ResetInput(); while (true) { Thread.Sleep(50); while (Console.KeyAvailable) { var key = Console.ReadKey(true); switch (key.Key) { case ConsoleKey.Enter: { var ret = _currentInputBuffer; _inputCursorPos = 0; _currentInputBuffer = ""; return ret; break; } case ConsoleKey.LeftArrow: { _inputCursorPos = Math.Max(0, _inputCursorPos - 1); break; } case ConsoleKey.RightArrow: { _inputCursorPos = Math.Min(_currentInputBuffer.Length, _inputCursorPos + 1); break; } case ConsoleKey.Backspace: { if (_inputCursorPos > 0) { _inputCursorPos--; _currentInputBuffer = _currentInputBuffer.Remove(_inputCursorPos, 1); } break; } case ConsoleKey.Delete: { if (_inputCursorPos < _currentInputBuffer.Length - 1) _currentInputBuffer = _currentInputBuffer.Remove(_inputCursorPos, 1); break; } default: { var pos = _inputCursorPos; //if (_inputCursorPos == _currentInputBuffer.Length) _inputCursorPos++; _currentInputBuffer = _currentInputBuffer.Insert(pos, key.KeyChar.ToString()); break; } } ResetInput(); } // COMMON_LVB_UNDERSCORE doesn't work so we use Consoles default cursor //UpdateCursorBlink(false); } } #endregion } |
would be nice if you included a license for using the code :p
(Apart from the coding convention used,) This is a pretty damn spiffy little bit of code and something I would really love to use this for my next whack at doing a rogue like game 🙂
i mean who doesn’t love that CGA text mode graphic charm… and with half block character you can even render out low res bitmaps :p (one half + fore and back colors to represent every 2 pixels vertical pixels)
Consider it BSD licensed. I should probably put up a default “unless otherwise stated”-license on my blog. 🙂
awesome thanks 🙂
I was trying out this better text console, and was having an issue with your implementation and example code: as soon as _logBox.WriteLine(“…”) is called the status box will disappear, as well as the logBox will be at the very top of the console window, while the inputBox is at the very bottom causing a lot of scrolling if one wants to see the log and/or input. My guess is that I’m doing something wrong but I have no idea what…