Antirez, of Redis fame, wrote a small terminal text editor in C, the source is available on GitHub. As someone who doesn’t know much C, I found it easy to read, and I learned a lot.
In this blog post, I’ll go through the steps of creating a basic text editor where we can type ASCII characters, move the cursor in all four directions, and close the editor. It will be written in Ruby, as a more or less a direct translation from kilo.
Kilo does way more, such as saving files, syntax highlighting, and everything you’d want from a text editor. In future posts I’ll try to keep adding features to the ruby version.
Terminal modes: cooked vs raw
When you open a terminal application, such as Terminal.app or iTerm2 on macOS, by default you are in what is known as “cooked mode”. Put simply, in “cooked mode” there are a good amount of processing steps that are performed between what the user does and what the underlying application receives.
We’re building a text editor, so we want full control of the user input, as well as the terminal window, which is what we get in raw mode.
Let’s look at a small example to illustrate the difference:
In a new ruby file, here named cooked.rb
, let’s add the following content:
loop do
content = gets
print "You typed: #{content}"
end
If we run this in a terminal with ruby cooked.rb
, we can start typing, what we type gets displayed, and when we press enter, the gets
method returns, and the You typed: ...
lines is printed. If you press Ctrl-C, the program stops.
Now let’s change it to the following, which uses the built-in ruby io/console
gem, let’s put the following content in a file named raw.rb
:
require "io/console"
IO.console.raw do
loop do
content = STDIN.readpartial(1)
exit(0) if content == 'Q'
end
end
And run it with ruby raw.rb
.
Note that we added the line to exit the program if the input is the capital letter Q
because signals are not caught in raw mode, Ctrl-C will not close the program.
Aside from stopping when you type Q
, you’ll notice that nothing gets printed, that’s because in raw mode, you’re in full control, you have to explicitly print characters to the screen, which we can do with the write
method on IO
objects, here we’ll print to STDOUT
:
require "io/console"
IO.console.raw do
loop do
content = STDIN.readpartial(1)
STDOUT.write(content)
exit(0) if content == 'Q'
end
end
Now characters are shown in the UI, but you may have noticed that some features are missing, or straight up not working. For instance, hitting backspace does not erase characters, pressing enter moves the cursor back to the beginning of the line. Overall it is severely limited.
In order to make the editor more useful, we first need to understand ANSI escape codes.
VT 100 codes
Before we dive in, let’s change our raw.rb
file, to be able to print additional information in order to get more information about the information the program receives from various user inputs.
We will use STDERR
, which is a constant that already exists. It makes it easy to write additional information in a location of our choosing, we will run the program a little differently to be able to actually see what we print there.
require "io/console"
def log_to_stderr(msg)
return if STDERR.tty?
STDERR.puts(msg)
end
IO.console.raw do
loop do
content = STDIN.readpartial(1)
log_to_stderr("read: '#{content}'")
log_to_stderr("read: '#{content.ord}'")
exit(0) if content == 'Q'
STDOUT.write(content)
end
end
We use an early return
if STDERR.tty?
is true
, as to not pollute the main terminal in case STDERR
is not redirected elsewhere. In other words, if we run the program with ruby raw.rb
, STDERR
will default to be the same as STDOUT
, and we’ll log additional characters to the main screen, you can try to comment out that the early return
and see if for yourself, it’s messy!
But now, if we redirect STDERR
to a different file, it’ll get printed there, we can do that with ruby raw.rb 2>stderr.log
, now if you open another terminal window and run tail -f stderr.log
, you’ll see new rows added to that file, looking like read: '...'
.
For each byte we read, we print two lines to STDERR
, the actual byte we read, but also the result of the #ord
method (docs). This method will be extremely convenient because the integer it returns will allow us to identify the different key strokes, even non printable characters such as backspace.
If you type ASCII characters, you’ll see these characters printed there, everything seems normal.
But if you use the backspace key or the enter key, “weird” thing will happen.
If we look at the content of the error
file, we can see that if we type the letter a
, ord
returns 97
, b
returns 98
, everything matches the ASCII table.
When typing Enter
or backspace
, printing the character itself looks weird, but ord
gives us more insight into what we read, enter
is the value 13
and backspace
is 127
.
Using the arrow keys, we can see that a single keystroke sends multiple bytes. The left arrow sends 27
, 91
& 67
. Looking at the ASCII table, we can see that this maps to respectively, the characters ESC
, [
& C
.
This specific sequence is called an ANSI escape code. This article is a great explanation of how these escape codes work. The important part for us is the first character, 27
, can be encoded in Ruby with "\x1b"
We’ll use the following escape codes to build our editor:
\x1b[?25l
hides the cursor\x1b[?25h
shows the cursor\x1b[H
moves the cursor to the beginning of the line\x1b[0K
clears the rest of the line\x1b[39m
sets the default foreground color"\x1b[Y;XH"
places the cursor at coordinates X & Y where X is the 0-index value of the column, and Y is the 0-index value of the row.
Drawing our editor
Let’s now use these escape codes to draw the editor. First we need to know the size of the current window. The io-console gem provides a helper for this, IO.console.winsize
, it returns a tuple, first the height, then width.
require "io/console"
PRINTABLE_ASCII_RANGE = 32..126
HIDE_CURSOR = "\x1b[?25l"
SHOW_CURSOR = "\x1b[?25h"
HOME = "\x1b[H"
CLEAR = "\x1b[0K"
DEFAULT_FOREGROUND_COLOR = "\x1b[39m"
TEXT_CONTENT = [String.new]
def log_to_stderr(msg)
return if STDERR.tty? # true when not redirecting to a file, a little janky but works for what I want
STDERR.puts(msg)
end
def coordinates(x, y)
"\x1b[#{ y };#{ x }H"
end
def refresh(height, x, y)
append_buffer = String.new
append_buffer << HIDE_CURSOR
append_buffer << HOME
height.times do |row_index|
if row_index >= TEXT_CONTENT.count
append_buffer << "~#{ CLEAR }\r\n"
next
end
row = TEXT_CONTENT[row_index] || String.new
append_buffer << row
append_buffer << DEFAULT_FOREGROUND_COLOR
append_buffer << CLEAR
append_buffer << "\r\n"
end
append_buffer.strip!
append_buffer << HOME
append_buffer << coordinates(x, y)
append_buffer << SHOW_CURSOR
log_to_stderr("'#{ append_buffer }'".inspect)
log_to_stderr("Cursor postition: x: #{ x }, y: #{ y }: #{ y };#{ x }H")
STDOUT.write(append_buffer)
end
IO.console.raw do
height, _width = IO.console.winsize
x = 1
y = 1
loop do
refresh(height, x, y)
content = STDIN.readpartial(1)
log_to_stderr("read: '#{content}'")
log_to_stderr("read: '#{content.ord}'")
if content == 'Q'
exit(0)
elsif PRINTABLE_ASCII_RANGE.cover?(content.ord)
current_row = TEXT_CONTENT[y - 1]
current_row.insert(x - 1, content) # Insert at -1 on an empty string is fine
x += 1
end
end
end
We can now type regular characters
TODO: Explain more
Moving around
The last thing we will add to our editor is the ability to use the four directional arrows, the backspace key and the enter key.
First, we’ll wrap the code in a new Editor
, class. So far we’ve used constants and a bunch of methods because we started with a small example, but there’s now a good amount of state we have to manage such as the current content of the editor and the current coordinates.
require "io/console"
class Editor
PRINTABLE_ASCII_RANGE = 32..126
HIDE_CURSOR = "\x1b[?25l"
SHOW_CURSOR = "\x1b[?25h"
HOME = "\x1b[H"
CLEAR = "\x1b[0K"
DEFAULT_FOREGROUND_COLOR = "\x1b[39m"
def initialize
@text_content = [String.new]
@x = 1
@y = 1
@height, @width = IO.console.winsize
end
def start
IO.console.raw do
loop do
refresh
content = STDIN.readpartial(1)
log_to_stderr("read: '#{content}'")
log_to_stderr("read: '#{content.ord}'")
if content == 'Q'
exit(0)
elsif PRINTABLE_ASCII_RANGE.cover?(content.ord)
current_row = @text_content[@y - 1]
current_row.insert(@x - 1, content) # Insert at -1 on an empty string is fine
@x += 1
end
end
end
end
private
def log_to_stderr(msg)
return if STDERR.tty? # true when not redirecting to a file, a little janky but works for what I want
STDERR.puts(msg)
end
def coordinates
"\x1b[#{ @y };#{ @x }H"
end
def refresh
append_buffer = String.new
append_buffer << HIDE_CURSOR
append_buffer << HOME
@height.times do |row_index|
if row_index >= @text_content.count
append_buffer << "~#{ CLEAR }\r\n"
next
end
row = @text_content[row_index] || String.new
append_buffer << row
append_buffer << DEFAULT_FOREGROUND_COLOR
append_buffer << CLEAR
append_buffer << "\r\n"
end
append_buffer.strip!
append_buffer << HOME
append_buffer << coordinates
append_buffer << SHOW_CURSOR
log_to_stderr("'#{ append_buffer }'".inspect)
log_to_stderr("Cursor postition: x: #{ @x }, y: #{ @y }: #{ @y };#{ @x }H")
STDOUT.write(append_buffer)
end
end
Editor.new.start
Let’s start with backspace
We will start by introducing a method dedicated to handling characters read from STDIN:
def start
IO.console.raw do
loop do
refresh
content = STDIN.readpartial(1)
log_to_stderr("read: '#{content}'")
log_to_stderr("read: '#{content.ord}'")
process_keypress(content)
end
end
end
def process_keypress(content)
if content == 'Q'
exit(0)
elsif PRINTABLE_ASCII_RANGE.cover?(content.ord)
current_row = @text_content[@y - 1]
current_row.insert(@x - 1, content) # Insert at -1 on an empty string is fine
@x += 1
end
end
Let’s now detect whether or not the last key pressed was the backspace key. According to the ASCII table, the ordinal value for backspace is 127:
def process_keypress(content)
if content == 'Q'
exit(0)
elsif content.ord == BACKSPACE
log_to_stderr("Backspace pressed")
elsif PRINTABLE_ASCII_RANGE.cover?(content.ord)
current_row = @text_content[@y - 1]
current_row.insert(@x - 1, content) # Insert at -1 on an empty string is fine
@x += 1
end
end
The behavior we need to implement depends on the content preceding the current cursor position:
- If there’s nothing to the left of the cursor, do nothing
- If there’s a character, remove it
- If there’s nothing on the current line, move the cursor to the end of the previous line. Since we haven’t yet implemented multi line handling, we’ll add this behavior when adding handling for the enter key
BACKSPACE = 127
# ...
def process_keypress(content)
current_row = @text_content[@y - 1]
if content == 'Q'
exit(0)
elsif content.ord == BACKSPACE
return if @x == 1 && @y == 1
if @x == 1
# TODO
else
deletion_index = @x - 2
current_row.slice!(deletion_index)
@x -= 1
end
elsif PRINTABLE_ASCII_RANGE.cover?(content.ord)
current_row.insert(@x - 1, content) # Insert at -1 on an empty string is fine
@x += 1
end
end
Let’s now add handling for the four arrow keys.
As we discovered earlier, a single press on an arrow key sends three bytes to the program. For instance the left arrow sends the bytes 27
, 91
& 68
. 27
is a non-printable character, 27
is the byte for the character [
and 68
the byte for the character D
OPENING_SQUARE_BRACKET = "["
UP = "A"
DOWN = "B"
RIGHT = "C"
LEFT = "D"
# ...
def process_keypress(content)
current_row = @text_content[@y - 1]
if content == 'Q'
exit(0)
elsif content.ord == BACKSPACE
return if @x == 1 && @y == 1
if @x == 1
# TODO
else
deletion_index = @x - 2
current_row.slice!(deletion_index)
@x -= 1
end
elsif content.ord == ESC
second_char = STDIN.read_nonblock(1, exception: false)
return if second_char == :wait_readable
third_char = STDIN.read_nonblock(1, exception: false)
return if third_char == :wait_readable
if second_char.ord == OPENING_SQUARE_BRACKET.ord
case third_char
when UP
return if current_row.nil?
@y -= 1 if @y > 1
current_row = @text_content[@y - 1]
return if current_row.nil?
# Keep the cursor at the same column position if the new current line is longer
return if @x <= current_row.length + 1
@x = current_row.length + 1
when DOWN
return if current_row.nil?
# Keep cursor at the beginning of the line when on the last line
@x = 1 if @y == @text_content.length
# Move cursor to the next line as long as we're not at the end of the file
@y += 1 if @y <= @text_content.length
current_row = @text_content[@y - 1]
return if current_row.nil?
# Keep the cursor at the same column position if the new current line is longer
return if @x <= current_row.length + 1
@x = current_row.length + 1
when RIGHT
return if current_row.nil?
if @x > current_row.length
# Move to the beginning of the next line as long as we're not at the end of the file
if @y <= @text_content.length + 1
@x = 1
@y += 1
end
else
@x += 1
end
when LEFT
return if @x == 1 && @y == 1
if @x == 1
@y -= 1
current_row = @text_content[@y - 1]
@x = current_row.length + 1
else
@x -= 1
end
end
end
elsif PRINTABLE_ASCII_RANGE.cover?(content.ord)
if current_row.nil?
current_row = String.new
@text_content[@y - 1] = current_row
end
current_row.insert(@x - 1, content) # Insert at -1 on an empty string is fine
@x += 1
end
end
Finally, let’s add support for the enter key. According to the ASCII table, the ordinal value for Enter is 13.
ENTER = 13
# ...
def process_keypress(content)
current_row = @text_content[@y - 1]
if content == 'Q'
exit(0)
elsif content.ord == BACKSPACE
return if @x == 1 && @y == 1
if @x == 1
if current_row.nil? || current_row.empty?
@text_content.delete_at(@y - 1)
@y -= 1
current_row = @text_content[@y - 1]
@x = current_row.length + 1
else
previous_row = @text_content[@y - 2]
@x = previous_row.length + 1
@text_content[@y - 2] = previous_row + current_row
@text_content.delete_at(@y - 1)
@y -= 1
end
else
deletion_index = @x - 2
current_row.slice!(deletion_index)
@x -= 1
end
elsif content.ord == ESC
# ...
elsif content.ord == ENTER
carry = if current_row && current_row.length > (@x - 1)
current_row.slice!((@x - 1)..-1)
else
String.new
end
new_line_index = if @y - 1 == @text_content.length # We're on a new line at the end
@y - 1
else
@y
end
@text_content.insert(new_line_index, carry)
@x = 1
@y += 1
elsif PRINTABLE_ASCII_RANGE.cover?(content.ord)
if current_row.nil?
current_row = String.new
@text_content[@y - 1] = current_row
end
current_row.insert(@x - 1, content) # Insert at -1 on an empty string is fine
@x += 1
end
end
Finally, up until now we used Q
as a way to exit the editor, because it was easy, but it is objectively a weird choice. Let’s now allow users to type a capitalized Q and instead use the sequence Ctrl+Q to exit!
CTRL_Q = 17
# ...
def process_keypress(content)
current_row = @text_content[@y - 1]
if content.ord == CTRL_Q
clear_screen
exit(0)
elsif # ...
end
end
# ...
def clear_screen
clear = ([HOME] + @height.times.map do
"#{ CLEAR }\r\n"
end + [HOME]).join
log_to_stderr(clear.inspect)
STDOUT.write(clear)
end
Conclusion
What we built is very simple, and only a subset of what kilo does, in future posts we’ll add
Putting it all together:
require "io/console"
class Editor
PRINTABLE_ASCII_RANGE = 32..126
HIDE_CURSOR = "\x1b[?25l"
SHOW_CURSOR = "\x1b[?25h"
HOME = "\x1b[H"
CLEAR = "\x1b[0K"
DEFAULT_FOREGROUND_COLOR = "\x1b[39m"
BACKSPACE = 127
ENTER = 13
ESC = 27
OPENING_SQUARE_BRACKET = "["
UP = "A"
DOWN = "B"
RIGHT = "C"
LEFT = "D"
CTRL_Q = 17
def initialize
@text_content = [String.new]
@x = 1
@y = 1
@height, @width = IO.console.winsize
end
def start
IO.console.raw do
loop do
refresh
content = STDIN.readpartial(1)
log_to_stderr("read: '#{content}'")
log_to_stderr("read: '#{content.ord}'")
process_keypress(content)
end
end
end
private
def log_to_stderr(msg)
return if STDERR.tty? # true when not redirecting to a file, a little janky but works for what I want
STDERR.puts(msg)
end
def coordinates
"\x1b[#{ @y };#{ @x }H"
end
def refresh
append_buffer = String.new
append_buffer << HIDE_CURSOR
append_buffer << HOME
@height.times do |row_index|
if row_index >= @text_content.count
append_buffer << "~#{ CLEAR }\r\n"
next
end
row = @text_content[row_index] || String.new
append_buffer << row
append_buffer << DEFAULT_FOREGROUND_COLOR
append_buffer << CLEAR
append_buffer << "\r\n"
end
append_buffer.strip!
append_buffer << HOME
append_buffer << coordinates
append_buffer << SHOW_CURSOR
log_to_stderr("'#{ append_buffer }'".inspect)
log_to_stderr("Cursor postition: x: #{ @x }, y: #{ @y }: #{ @y };#{ @x }H")
STDOUT.write(append_buffer)
end
def process_keypress(content)
current_row = @text_content[@y - 1]
if content.ord == CTRL_Q
clear_screen
exit(0)
elsif content.ord == BACKSPACE
return if @x == 1 && @y == 1
if @x == 1
if current_row.nil? || current_row.empty?
@text_content.delete_at(@y - 1)
@y -= 1
current_row = @text_content[@y - 1]
@x = current_row.length + 1
else
previous_row = @text_content[@y - 2]
@x = previous_row.length + 1
@text_content[@y - 2] = previous_row + current_row
@text_content.delete_at(@y - 1)
@y -= 1
end
else
deletion_index = @x - 2
current_row.slice!(deletion_index)
@x -= 1
end
elsif content.ord == ESC
second_char = STDIN.read_nonblock(1, exception: false)
return if second_char == :wait_readable
third_char = STDIN.read_nonblock(1, exception: false)
return if third_char == :wait_readable
if second_char.ord == OPENING_SQUARE_BRACKET.ord
case third_char
when UP
return if current_row.nil?
@y -= 1 if @y > 1
current_row = @text_content[@y - 1]
return if current_row.nil?
# Keep the cursor at the same column position if the new current line is longer
return if @x <= current_row.length + 1
@x = current_row.length + 1
when DOWN
return if current_row.nil?
# Keep cursor at the beginning of the line when on the last line
@x = 1 if @y == @text_content.length
# Move cursor to the next line as long as we're not at the end of the file
@y += 1 if @y <= @text_content.length
current_row = @text_content[@y - 1]
return if current_row.nil?
# Keep the cursor at the same column position if the new current line is longer
return if @x <= current_row.length + 1
@x = current_row.length + 1
when RIGHT
return if current_row.nil?
if @x > current_row.length
# Move to the beginning of the next line as long as we're not at the end of the file
if @y <= @text_content.length + 1
@x = 1
@y += 1
end
else
@x += 1
end
when LEFT
return if @x == 1 && @y == 1
if @x == 1
@y -= 1
current_row = @text_content[@y - 1]
@x = current_row.length + 1
else
@x -= 1
end
end
end
elsif content.ord == ENTER
carry = if current_row && current_row.length > (@x - 1)
current_row.slice!((@x - 1)..-1)
else
String.new
end
new_line_index = if @y - 1 == @text_content.length # We're on a new line at the end
@y - 1
else
@y
end
@text_content.insert(new_line_index, carry)
@x = 1
@y += 1
elsif PRINTABLE_ASCII_RANGE.cover?(content.ord)
if current_row.nil?
current_row = String.new
@text_content[@y - 1] = current_row
end
current_row.insert(@x - 1, content) # Insert at -1 on an empty string is fine
@x += 1
end
end
def clear_screen
clear = ([HOME] + @height.times.map do
"#{ CLEAR }\r\n"
end + [HOME]).join
log_to_stderr(clear.inspect)
STDOUT.write(clear)
end
end
Editor.new.start