7
\$\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;
 }
 }
}
t3chb0t
44.6k9 gold badges84 silver badges190 bronze badges
asked Apr 8, 2018 at 0:59
\$\endgroup\$

1 Answer 1

4
\$\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;
answered Apr 8, 2018 at 7:55
\$\endgroup\$
0

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.