4
\$\begingroup\$

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!

\$\endgroup\$
2
\$\begingroup\$

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.

| improve this answer | |
\$\endgroup\$
  • \$\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 to Console.ReadKey method to avoid it from writing pressed key to the console immediately. \$\endgroup\$ – AWhiteFox Dec 29 '19 at 11:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.