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;
}
}
}
1 Answer 1
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;