8
\$\begingroup\$

I have been following along Gary Bernhardt's excellent video series on "Building a Text Editor From Scratch". It's in Ruby but I wanted to do it in C# just to see how much it differs.

Implementation wise it's faithful to what he does. I do have questions whether the code is up to mark in terms of new C# conventions, haven't violated any biggies or gone overboard with LINQ.

The method SplitLine was a head scratcher (really showcased the terseness of Ruby) where his line splitting and new carriage code looks like and I couldn't come up with anything like it in C#.

lines[row..row] = [line[0...col],line[col..-1]]

My C# Code

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace TextEditor
{
    class Program
    {
        static void Main(string[] args)
        {
            new Editor().Run();
        }
    }

    class Editor
    {
        Buffer _buffer;
        Cursor _cursor;
        Stack<object> _history;

        public Editor()
        {
            var lines = File.ReadAllLines("foo.txt")
                            .Where(x => x != Environment.NewLine);

            _buffer = new Buffer(lines);
            _cursor = new Cursor();
            _history = new Stack<object>();
        }

        public void Run()
        {
            while (true)
            {
                Render();
                HandleInput();
            }
        }

        private void HandleInput()
        {
            var character = Console.ReadKey();


            if ((ConsoleModifiers.Control & character.Modifiers) != 0 && 
                  character.Key == ConsoleKey.Q)
            {
                Environment.Exit(0);
            }

            else if ((ConsoleModifiers.Control & character.Modifiers) != 0 &&
                  character.Key == ConsoleKey.P)
            {
                _cursor = _cursor.Up(_buffer);
            }

            else if ((ConsoleModifiers.Control & character.Modifiers) != 0 &&
                  character.Key == ConsoleKey.N)
            {
                _cursor = _cursor.Down(_buffer);
            }

            else if ((ConsoleModifiers.Control & character.Modifiers) != 0 &&
                  character.Key == ConsoleKey.B)
            {
                _cursor = _cursor.Left(_buffer);
            }

            else if ((ConsoleModifiers.Control & character.Modifiers) != 0 &&
                  character.Key == ConsoleKey.Z)
            {
                _cursor = _cursor.Right(_buffer);
            }

            else if ((ConsoleModifiers.Control & character.Modifiers) != 0 &&
                 character.Key == ConsoleKey.U)
            {
                RestoreSnapshot();
            }

            else if (character.Key == ConsoleKey.Backspace)
            {
                if (_cursor.Col > 0)
                {
                    SaveSnapshot();
                    _buffer = _buffer.Delete(_cursor.Row, _cursor.Col - 1);
                    _cursor = _cursor.Left(_buffer);
                }
            }

            else if(character.Key == ConsoleKey.Enter)
            {
                SaveSnapshot();
                _buffer = _buffer.SplitLine(_cursor.Row, _cursor.Col);
                _cursor = _cursor.Down(_buffer).MoveToCol(0);
            }

            else
            {
                SaveSnapshot();
                _buffer = _buffer.Insert(character.KeyChar.ToString(), _cursor.Row, _cursor.Col);
                _cursor = _cursor.Right(_buffer);
            }


        }

        private void Render()
        {
            ANSI.ClearScreen();
            ANSI.MoveCursor(0, 0);
            _buffer.Render();
            ANSI.MoveCursor(_cursor.Row, _cursor.Col);
        }       

        private void SaveSnapshot()
        {
            _history.Push(_cursor);
            _history.Push(_buffer);
        }

        private void RestoreSnapshot()
        {
            if( _history.Count > 0 )
            {
                _buffer = (Buffer)_history.Pop();
                _cursor = (Cursor)_history.Pop();
            }
        }
    }

    class Buffer
    {
        string[] _lines;

        public Buffer(IEnumerable<string> lines)
        {
            _lines = lines.ToArray();
        }

        public void Render()
        {
            foreach (var line in _lines)
            {
                Console.WriteLine(line);
            }
        }

        public int LineCount()
        {
            return _lines.Count();
        }

        public int LineLength(int row)
        {
            return _lines[row].Length;
        }

        internal Buffer Insert(string character, int row, int col)
        {
            var linesDeepCopy = _lines.Select(x => x).ToArray();
            linesDeepCopy[row] = linesDeepCopy[row].Insert(col, character);
            return new Buffer(linesDeepCopy);
        }

        internal Buffer Delete(int row, int col)
        {
            var linesDeepCopy = _lines.Select(x => x).ToArray();
            linesDeepCopy[row] = linesDeepCopy[row].Remove(col, 1);
            return new Buffer(linesDeepCopy);
        }

        internal Buffer SplitLine(int row, int col)
        {
            var linesDeepCopy = _lines.Select(x => x).ToList();

            var line = linesDeepCopy[row];

            var newLines = new [] { line.Substring(0, col), line.Substring(col, line.Length - line.Substring(0, col).Length) };

            linesDeepCopy[row] = newLines[0];
            linesDeepCopy[row + 1] = newLines[1];



            return new Buffer(linesDeepCopy);
        }
    }

    class Cursor
    {
        public int Row { get; set; }
        public int Col { get; set; }


        public Cursor(int row=0, int col=0)
        {
            Row = row;
            Col = col;
        }

        internal Cursor Up(Buffer buffer)
        {
            return new Cursor(Row - 1, Col).Clamp(buffer);
        }

        internal Cursor Down(Buffer buffer)
        {
            return new Cursor(Row + 1, Col).Clamp(buffer);
        }


        internal Cursor Left(Buffer buffer)
        {
            return new Cursor(Row, Col - 1).Clamp(buffer);
        }

        internal Cursor Right(Buffer buffer)
        {
            return new Cursor(Row, Col + 1).Clamp(buffer);
        }

        private Cursor Clamp(Buffer buffer)
        {
            Row = Math.Min(buffer.LineCount() - 1 , Math.Max(Row, 0));
            Col = Math.Min(buffer.LineLength(Row), Math.Max(Col, 0));
            return new Cursor(Row, Col);
        }

        internal Cursor MoveToCol(int col)
        {
            return new Cursor(Row, 0);
        }
    }

    class ANSI
    {
        public static void ClearScreen()
        {
            Console.Clear();
        }

        public static void MoveCursor(int row, int col)
        {
            Console.CursorTop = row;
            Console.CursorLeft = col;
        }
    }
}
\$\endgroup\$
3
\$\begingroup\$

There are a couple of simple errors:

1) In Buffer.SplitLine(...) you replace the next line with the second part of the split instead of inserting it after the first part:

linesDeepCopy[row] = newLines[0];    
linesDeepCopy[row + 1] = newLines[1];

instead you should do something like this:

    linesDeepCopy[row] = newLines[0];
    linesDeepCopy.Insert(row + 1, newLines[1]);

2) In Cursor.MoveToCol(int col) you don't use the argument col:

  internal Cursor MoveToCol(int col)
  {
    return new Cursor(Row, 0);
  }

I suppose it to be:

  internal Cursor MoveToCol(int col)
  {
    return new Cursor(Row, col);
  }

3)

In Editor.HandleInput() you should check if a character is a text char before you insert:

        else if (IsTextChar(character))
        {
          SaveSnapshot();
          _buffer = _buffer.Insert(character.KeyChar.ToString(), _cursor.Row, _cursor.Col);
          _cursor = _cursor.Right(_buffer);
        }

....

      private bool IsTextChar(ConsoleKeyInfo character)
      {
        return !Char.IsControl(character.KeyChar);
      }

4)

This construct

(ConsoleModifiers.Control & character.Modifiers) != 0

is potentially wrong because the value of ConsoleModifiers.Control could actually be 0. Therefore you should do like this:

(ConsoleModifiers.Control & character.Modifiers) == ConsoleModifiers.Control

Other things

Consider if this input check is suitable:

    if ((ConsoleModifiers.Control & character.Modifiers) != 0 && character.Key == ConsoleKey.Q)
    {
      Environment.Exit(0);
    }

Because of the flag-behavior of ConsoleModifiers.Control it will be true if any combination of Modifiers are pressed that involve Control (+ Q). It would maybe be more useful to make it more distinct like:

if (character.Modifiers == ConsoleModifiers.Control && character.Key == ConsoleKey.Q)
        {
          Environment.Exit(0);
        }

In this way you'll save the other combinations with Control + Q to other tools.


Your handling of state is nice and clean and easily maintained in that you always create a new instance when ever Buffer or Cursor changes. But it requires immutable objects. Cursor is not immutable:

class Cursor
{
  public int Row { get; set; }
  public int Col { get; set; }

  ....
}

Anyone (?) could change these parameters on objects placed on the history stack.

At least you should restrict Cursor.Row and Cursor.Col to be private settable only.

class Cursor
{
  public int Row { get; private set; }
  public int Col { get; private set; }

  ....
}

Consider to make the Buffer.

  public int LineCount()
  {
    return _lines.Count();
  }

as a parameter instead:

public int LineCount => _lines.Length;
\$\endgroup\$

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.