I've created an small lib to allow writing to console with multiple threads and reading from console at the same time avoiding output being written in the middle of input because console cursor is there. My library implements simple mechanism to make console write output above input.
TSConsole
is the main class for interacting with console.
TSCString
class used for creating colored strings.
TSConsole.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
namespace ThreadSafeConsole
{
public static class TSConsole
{
/* Private fields */
private static readonly object _lockObject = new object();
private static readonly StringBuilder _buffer = new StringBuilder();
private static bool _isReading = false;
private static int _bufferCursorPos = 0;
private static string _prompt = "";
/* Public Properties */
public static string Prompt
{
get
{
lock (_lockObject)
{
return _prompt;
}
}
set
{
lock (_lockObject)
{
if (_isReading)
throw new InvalidOperationException("Can't set prompt while reading.");
if (string.IsNullOrEmpty(value))
throw new ArgumentNullException(nameof(value));
_prompt = value;
}
}
}
public static bool IsReading
{
get
{
lock (_lockObject)
{
return _isReading;
}
}
}
/* Public Methods */
public static void WriteLine(string str)
{
if (string.IsNullOrEmpty(str))
throw new ArgumentNullException(nameof(str));
lock (_lockObject)
{
GoToBufferBeginning();
Console.Write(str);
RestoreBuffer();
}
}
public static void WriteLine(IEnumerable<TSCString> enumeration)
{
if (enumeration is null)
throw new ArgumentNullException(nameof(enumeration));
lock (_lockObject)
{
ConsoleColor saveForeground = Console.ForegroundColor;
ConsoleColor saveBackground = Console.BackgroundColor;
GoToBufferBeginning();
foreach (var elem in enumeration)
{
if (!string.IsNullOrEmpty(elem.Data))
{
Console.ForegroundColor = elem.Foreground ?? saveForeground;
Console.BackgroundColor = elem.Backgroung ?? saveBackground;
Console.Write(elem.Data);
}
}
Console.ForegroundColor = saveForeground;
Console.BackgroundColor = saveBackground;
RestoreBuffer();
}
}
public static void WriteLine(params TSCString[] enumeration)
{
WriteLine((IEnumerable<TSCString>)enumeration);
}
public static string ReadLine(int maxLength = 0)
{
lock (_lockObject)
{
if (_isReading)
throw new InvalidOperationException("Some thread is already reading.");
_isReading = true;
_bufferCursorPos = 0;
Console.Write(_prompt);
}
while (true)
{
ConsoleKeyInfo keyInfo = Console.ReadKey(true);
lock (_lockObject)
{
if (!char.IsControl(keyInfo.KeyChar))
{
if (maxLength == 0 || _buffer.Length < maxLength)
{
int i = _buffer.Length - _bufferCursorPos;
Console.Write(keyInfo.KeyChar + _buffer.ToString(_bufferCursorPos, i));
MoveCursor(i * -1);
_buffer.Insert(_bufferCursorPos, keyInfo.KeyChar.ToString(CultureInfo.InvariantCulture));
_bufferCursorPos++;
}
}
else
{
switch (keyInfo.Key)
{
// [Backspace] Remove symbol before cursor
case ConsoleKey.Backspace:
if (_bufferCursorPos != 0)
{
MoveCursor(-1);
int i = _buffer.Length - _bufferCursorPos;
Console.Write(_buffer.ToString(_bufferCursorPos, i) + ' ');
MoveCursor(i * -1 - 1);
_buffer.Remove(_bufferCursorPos - 1, 1);
_bufferCursorPos--;
}
continue;
// [Del] Remove symbol after cursor
case ConsoleKey.Delete:
if (_bufferCursorPos != _buffer.Length)
{
int i = _buffer.Length - _bufferCursorPos;
Console.Write(_buffer.ToString(_bufferCursorPos + 1, i - 1) + ' ');
MoveCursor(i * -1);
_buffer.Remove(_bufferCursorPos, 1);
}
continue;
// [Enter] Clear buffer and return string
case ConsoleKey.Enter:
if (_buffer.Length != 0)
{
GoToBufferBeginning();
int i = _prompt.Length + _buffer.Length;
Console.Write(new string(' ', i));
MoveCursor(i * -1);
string result = _buffer.ToString();
_isReading = false;
_buffer.Clear();
_bufferCursorPos = 0;
return result;
}
continue;
// [Left Arrow] Move cursor left
case ConsoleKey.LeftArrow:
if (_bufferCursorPos != 0)
{
MoveCursor(-1);
_bufferCursorPos--;
}
continue;
// [Right Arrow] Move cursor right
case ConsoleKey.RightArrow:
if (_bufferCursorPos != _buffer.Length)
{
MoveCursor(1);
_bufferCursorPos++;
}
continue;
}
}
}
}
}
/* Private Helpers */
private static void GoToBufferBeginning()
{
if (_isReading)
{
Console.SetCursorPosition(0, Console.CursorTop - (_prompt.Length + _bufferCursorPos) / Console.BufferWidth);
}
}
private static void RestoreBuffer()
{
if (Console.CursorLeft != 0)
Console.Write(new string(' ', Console.BufferWidth - Console.CursorLeft));
if (_isReading)
{
Console.Write(_prompt);
Console.Write(_buffer.ToString());
MoveCursor(_bufferCursorPos - _buffer.Length);
}
}
private static void MoveCursor(int move)
{
if (move == 0)
return;
move += Console.CursorLeft;
int left = move % Console.BufferWidth;
int top = Console.CursorTop + move / Console.BufferWidth;
if (left < 0)
{
left += Console.BufferWidth;
top -= 1;
}
Console.SetCursorPosition(left, top);
}
}
}
TSCString.cs
using System;
namespace ThreadSafeConsole
{
public class TSCString
{
/* Public Properties */
public string Data { get; set; }
public ConsoleColor? Foreground { get; set; }
public ConsoleColor? Backgroung { get; set; }
/* Constructors */
public TSCString() { }
public TSCString(string data) => Data = data;
public TSCString(string data, ConsoleColor fg) : this(data) => Foreground = fg;
public TSCString(string data, ConsoleColor fg, ConsoleColor bg) : this(data, fg) => Backgroung = bg;
}
}
Few things I'm not sure about:
- Should I make
TSCString
struct instead of class? - Is it okay to have to have constructors like in
TSCString
or should I better avoid one constructor calling another in this situation?
Looking forward to your feedback!
1 Answer 1
Anytime you give up a lock you have to assume another thread could jump in
public static string ReadLine(int maxLength = 0)
{
lock (_lockObject)
{
if (_isReading)
throw new InvalidOperationException("Some thread is already reading.");
_isReading = true;
_bufferCursorPos = 0;
Console.Write(_prompt);
}
while (true)
{
ConsoleKeyInfo keyInfo = Console.ReadKey(true);
lock (_lockObject)
{
in ReadLine the lock is given up after setting the isReading and in the loop it give it up as well each loop. Now WriteLine could come in and write since the lock object isn't locked. I don't know if that's your intent or not.
Also I think throwing an exception would make using this harder than it should be. The calling code will need to now to do a try/catch to around all calls. I would suggest following some other patterns in .net by doing a TryWrite or TryRead and instead of throwing return back false if it can't do the method. Or you could even use a TaskCompletionSource and return back a Task and implement a queue for reading and writing and complete the task when the operation is complete.
Also properties like IsReading and Prompt have little to no value when talking multi-threading as the caller you don't know if another thread has changed that value once you read the value.
-
\$\begingroup\$ I don't know if that's your intent or not. Yes, I've done it intentionally. I'm giving up a lock every time to allow threads write to the console while the reading thread is waiting for a key to be pressed. I'm also passing
true
toConsole.ReadKey
method to avoid it from writing pressed key to the console immediately. \$\endgroup\$AWhiteFox– AWhiteFox2019年12月29日 11:25:54 +00:00Commented Dec 29, 2019 at 11:25