Acorn.jl/src/editor.jl

566 lines
14 KiB
Julia

# """ Clear the screen, print an error message, and kill the program """
# function die(msg)
# write(STDOUT, "\x1b[2J")
# write(STDOUT, "\x1b[H")
# error(msg)
# end
##########
# CURSOR #
##########
mutable struct Cursor
x::Int
y::Int
rx::Int
end
##########
# EDITOR #
##########
"Editor instance"
mutable struct Editor
"row view offset"
rowoff::Int
"column view offset"
coloff::Int
"terminal width"
width::Int
"terminal height"
height::Int
"currently edited file"
filename::String
"current status message"
statusmsg::String
"time reaining to display status"
status_time::Float64
"true if the buffer has been edited"
dirty::Bool
"if true, the key input loop will exit"
quit::Bool
"the cursor position"
csr::Cursor
"the test buffer"
rows::Rows
"terminal hosting this editor"
term::Base.Terminals.TTYTerminal
"used by commands to store variables"
params::Dict{Symbol, Dict{Symbol, Any}}
end
"""create a new editor with default params"""
function Editor()
rowoff = 0
coloff = 0
width = 0
height = 0
filename = ""
statusmsg = ""
status_time = 0
dirty = false
quit = false
csr = Cursor(1,1,1)
rows = Rows()
term = Base.Terminals.TTYTerminal(get(ENV, "TERM", @static is_windows() ? "" : "dumb"), STDIN, STDOUT, STDERR)
params = Dict{Symbol, Dict{Symbol, Any}}()
Editor(rowoff, coloff, width, height, filename,
statusmsg, status_time, dirty, quit,
csr, rows, term, params)
end
###################
# FILE OPERATIONS #
###################
"""custom expand path function to ensure consistency"""
function editorExpandPath(filename::String)
if filename == ""
return ""
else
return abspath(expanduser(filename))
end
end
""" Open a file and read the lines into the ed.rows array """
function editorOpen(ed::Editor, filename::String)
try
filename = editorExpandPath(filename)
# If no file exists, create it
!isfile(filename) && open(filename, "w") do f end
open(filename, "r") do file
clearRows(ed.rows)
for line in eachline(file)
appendRow(ed.rows, line)
end
ed.filename = filename
ed.csr.x = 1
ed.csr.y = 1
ed.dirty = false
end
catch Exception e
setStatusMessage(ed, "Cannot open file $filename")
end
end
function editorOpen(ed::Editor)
if ed.dirty
confirm = editorPrompt(ed, "There are unsaved changes. Open another file? [y/n]: ")
if confirm != "y"
setStatusMessage(ed, "Open aborted")
return
end
end
filename = editorPrompt(ed, "Open file: ")
filename = editorExpandPath(filename)
if filename != ""
editorOpen(ed, filename)
else
setStatusMessage(ed, "Open aborted")
end
end
""" Save the buffer to the file using the current file name """
function editorSave(ed::Editor)
editorSave(ed, "")
end
""" Save the contents of the file buffer to disc """
function editorSave(ed::Editor, path::String)
prev_filename = ed.filename
try
if path == ""
if ed.filename == ""
ed.filename = editorPrompt(ed, "Save as: ")
if ed.filename == ""
setStatusMessage(ed, "Save aborted")
return
end
end
else
ed.filename = editorExpandPath(path)
end
open(ed.filename, "w") do f
write(f, rowsToString(ed.rows))
end
setStatusMessage(ed, "File saved: $(ed.filename)")
ed.dirty = false
catch Exception
# There was an error saving, restore original filename
setStatusMessage(ed, "Unable to save: $(ed.filename)")
ed.filename = prev_filename
end
end
##########
# VISUAL #
##########
""" Given an arrow input, move the cursor """
function moveCursor(ed::Editor, key::UInt32)
if key == ARROW_LEFT
if ed.csr.x > 1
ed.csr.x -= 1
elseif ed.csr.y > 1
# At start of line, move to end of prev line
ed.csr.y -= 1
ed.csr.x = 1+length(ed.rows[ed.csr.y].chars)
end
elseif key == ARROW_RIGHT
onrow = ed.csr.y <= length(ed.rows)
if onrow && ed.csr.x <= length(ed.rows[ed.csr.y].chars)
ed.csr.x += 1
elseif ed.csr.y < length(ed.rows) && ed.csr.x == 1 + length(ed.rows[ed.csr.y].chars)
# At end of line, move to next line
ed.csr.y += 1
ed.csr.x = 1
end
elseif key == ARROW_UP
ed.csr.y > 1 && (ed.csr.y -= 1)
elseif key == ARROW_DOWN
ed.csr.y < length(ed.rows) && (ed.csr.y += 1)
end
# Snap to end of line if we are further out
rowlen = ed.csr.y < length(ed.rows)+1 ? length(ed.rows[ed.csr.y].chars)+1 : 1
ed.csr.x > rowlen && (ed.csr.x = rowlen)
end
moveCursor(ed, key::Key) = moveCursor(ed, UInt32(key))
""" Scroll the screen based on the cursor position """
function scroll(ed::Editor)
ed.csr.rx = 1
if ed.csr.y <= length(ed.rows)
ed.csr.rx = renderX(ed.rows[ed.csr.y], ed.csr.x)
end
# Vertical scrolling
if ed.csr.y < ed.rowoff+1
ed.rowoff = ed.csr.y-1
end
if ed.csr.y >= ed.rowoff+1 + ed.height
ed.rowoff = ed.csr.y - ed.height
end
# Horizontal scrolling
if ed.csr.rx < ed.coloff+1
ed.coloff = ed.csr.rx-1
end
if ed.csr.rx >= ed.coloff+1 + ed.width
ed.coloff = ed.csr.rx - ed.width
end
end
function drawRows(ed::Editor, buf::IOBuffer)
for y = 1:ed.height
filerow = y + ed.rowoff
y != 1 && write(buf, "\r\n")
write(buf, "\x1b[K") # Clear line
if filerow > length(ed.rows)
if y == div(ed.height, 3) && ed.width > 40 && length(ed.rows) == 0
msg = "Acorn Editor"
padding = div(ed.width - length(msg), 2)
if padding > 0
write(buf, "~")
padding -= 1
end
while (padding -= 1) > 0
write(buf, " ")
end
write(buf, msg)
else
write(buf, "~");
end
else
len = length(ed.rows[filerow].render) - ed.coloff
len = clamp(len, 0, ed.width)
write(buf, ed.rows[filerow].render[1+ed.coloff : ed.coloff + len])
end
end
# Write a newline to prepare for status bar
write(buf, "\r\n");
end
function drawStatusBar(ed::Editor, buf::IOBuffer)
write(buf, "\x1b[7m") # invert colors
col = 1
# left padding
write(buf, ' ')
col += 1
# filename
filename = configGet(:status_fullpath) ? editorExpandPath(ed.filename) : splitdir(ed.filename)[2]
filestatus = string(filename, ed.dirty ? " *" : "")
for i = 1:min(div(ed.width,2), length(filestatus))
write(buf, filestatus[i])
col += 1
end
linenum = string(ed.csr.y)
while col < ed.width - length(linenum)
write(buf, ' ')
col += 1
end
write(buf, linenum, ' ')
write(buf, "\x1b[m") # uninvert colors
# make line for message bar
write(buf, "\r\n")
end
function drawStatusMessage(ed::Editor, buf::IOBuffer)
write(buf, "\x1b[K")
if time() - ed.status_time < 5.0
write(buf, ed.statusmsg[1:min(ed.width, length(ed.statusmsg))])
end
end
function setStatusMessage(ed::Editor, msg::String)
ed.statusmsg = msg
ed.status_time = time()
end
function refreshScreen(ed::Editor)
# Update terminal size
ed.height = Base.Terminals.height(ed.term) - 2 # status + msg bar = 2
@static is_windows() ? (ed.height -= 1) : ed.height
ed.width = Base.Terminals.width(ed.term)
scroll(ed)
buf = IOBuffer()
write(buf, "\x1b[?25l") # ?25l: Hide cursor
write(buf, "\x1b[H") # H: Move cursor to top left
drawRows(ed, buf)
drawStatusBar(ed, buf)
drawStatusMessage(ed, buf)
@printf(buf, "\x1b[%d;%dH", ed.csr.y-ed.rowoff,
ed.csr.rx-ed.coloff)
write(buf, "\x1b[?25h") # ?25h: Show cursor
write(STDOUT, String(take!(buf)))
end
function editorPrompt(ed::Editor, prompt::String;
callback=nothing,
buf::String="",
showcursor::Bool=true) ::String
while true
statusmsg = string(prompt, buf)
setStatusMessage(ed, string(prompt, buf))
refreshScreen(ed)
if showcursor
# Position the cursor at the end of the line
@printf(STDOUT, "\x1b[%d;%dH", 999, length(statusmsg)+1)
end
c = Char(readKey())
if c == '\x1b'
setStatusMessage(ed, "")
callback != nothing && callback(ed, buf, c)
return ""
elseif c == '\r'
if length(buf) != 0
setStatusMessage(ed, "")
callback != nothing && callback(ed, buf, c)
return buf
end
elseif UInt32(c) == BACKSPACE && length(buf) > 0
buf = buf[1:end-1]
elseif !iscntrl(c) && UInt32(c) < 128
buf = string(buf, c)
end
callback != nothing && callback(ed, buf, c)
end
end
##############
## Keyboard ##
##############
function processKeypress(ed::Editor)
c = readKey();
if c == UInt32('\x1b') # Esc
setStatusMessage(ed, "Press ctrl-q to quit")
elseif c == ctrl_key('p')
runCommand(ed)
elseif (c == ARROW_LEFT
|| c == ARROW_UP
|| c == ARROW_RIGHT
|| c == ARROW_DOWN)
moveCursor(ed, c)
elseif c == PAGE_UP || c == PAGE_DOWN
lines = ed.height
while (lines-=1) > 0
moveCursor(ed, c == PAGE_UP ? ARROW_UP : ARROW_DOWN)
end
elseif c == HOME_KEY
ed.csr.x = 0
elseif c == END_KEY
ed.csr.y < length(ed.rows) && (ed.csr.x = length(ed.rows[ed.csr.y].chars))
elseif c == UInt32('\r')
editorInsertNewline(ed)
elseif c == BACKSPACE
editorDelChar(ed)
elseif c == DEL_KEY
moveCursor(ed, ARROW_RIGHT)
editorDelChar(ed)
elseif c == ctrl_key('l')
# Refresh screen
return
elseif iscntrl(Char(c)) && isKeyBound(Char(c))
runCommand(ed, getKeyBinding(Char(c)))
elseif c == UInt32('\t')
editorInsertTab(ed)
elseif !iscntrl(Char(c)) && c < 1000
# Chars above 1000 are a ::Key, see src/terminal.jl
editorInsertChar(ed, c)
end
end
#####################
# Editor Operations #
#####################
function editorInsertChar(ed::Editor, c::UInt32)
# The cursor is able to move beyond the last row
ed.csr.y == length(ed.rows)+1 && appendRow(ed.rows, "")
insertChar!(ed.rows[ed.csr.y], ed.csr.x, Char(c))
ed.csr.x += 1
ed.dirty = true
end
function editorInsertTab(ed::Editor)
# The cursor is able to move beyond the last row
ed.csr.y == length(ed.rows)+1 && appendRow(ed.rows, "")
# Insert character(s) into the row data
mv_fwd = insertTab!(ed.rows[ed.csr.y], ed.csr.x)
ed.csr.x += mv_fwd
ed.dirty = true
end
function editorDelChar(ed::Editor)
ed.csr.y == length(ed.rows)+1 && return
ed.csr.x == 1 && ed.csr.y == 1 && return
if ed.csr.x > 1
deleteChar!(ed.rows[ed.csr.y], ed.csr.x -1)
ed.csr.x -= 1
ed.dirty = true
else
# Move cursor to end of prev line
ed.csr.x = 1+length(ed.rows[ed.csr.y-1].chars)
appendRowString(ed.rows[ed.csr.y-1], ed.rows[ed.csr.y].chars)
editorDelRow(ed, ed.csr.y)
ed.csr.y -= 1
end
end
function editorInsertRow(ed::Editor, i::Int, str::String)
row = Row(str)
update!(row)
insert!(ed.rows, i, row)
end
function editorInsertNewline(ed::Editor)
if ed.csr.x == 1
editorInsertRow(ed, ed.csr.y, "")
else
row = ed.rows[ed.csr.y]
before = row.chars[1:ed.csr.x-1]
after = row.chars[ed.csr.x:end]
editorInsertRow(ed, ed.csr.y + 1, after)
row.chars = before
update!(row)
end
ed.csr.y += 1
ed.csr.x = 1
end
function editorDelRow(ed::Editor, i::Int)
i < 1 || i > length(ed.rows) && return
deleteat!(ed.rows, i)
ed.dirty = true
end
function editorQuit(ed::Editor; force::Bool=false)
if !force && ed.dirty
setStatusMessage(ed,
"File has unsaved changes. Save changes or use <ctrl-p>'quit !' to quit anyway.")
else
write(STDOUT, "\x1b[2J")
write(STDOUT, "\x1b[H")
ed.quit = true
!isinteractive() && exit(0)
end
end
############
# COMMANDS #
############
function runCommand(ed::Editor)
cmd = editorPrompt(ed, "> ")
runCommand(ed, strip(cmd))
end
function runCommand(ed::Editor, command_str::String)
cmd_arr = split(command_str, ' ', limit=2)
# Get the command
cmd = strip(cmd_arr[1])
# Blank, do nothing
cmd == "" && return
# Command must be a valid identifier
if !Base.isidentifier(cmd)
setStatusMessage(ed, "'$cmd' is not a valid command name")
return
end
cmd_sym = Symbol(cmd)
# Get arguments if there are any
args = ""
if length(cmd_arr) > 1
args = cmd_arr[2]
end
# If the command exists, run it
if cmd_sym in keys(COMMANDS)
# join(args): convert Substring to String
runCommand(COMMANDS[cmd_sym], ed, join(args))
else
setStatusMessage(ed, "'$cmd_sym' is not a valid command")
end
end
function runCommand(c::Command, ed::Editor, args::String)
c.cmd(ed, args)
end