Files
edt/edt.lua
2025-11-06 10:39:10 +01:00

997 lines
24 KiB
Lua

local fs = require("filesystem")
local keyboard = require("keyboard")
local shell = require("shell")
local term = require("term") -- TODO use tty and cursor position instead of global area and gpu
local text = require("text")
local unicode = require("unicode")
-- Monokai color scheme
local bgColor = 0x272822
local lineColor = 0x39392F
local textColor = 0xF8F8F2
local keywordColor = 0xF92672
local commentColor = 0x75715E
local stringColor = 0xE6DB74
local valueColor = 0xAE81FF
local builtinColor = 0x66D9EF
local lineNrColor = 0x90908A
local keywords = {
['break'] = true,
['do'] = true,
['else'] = true,
['for'] = true,
['if'] = true,
['elseif'] = true,
['return'] = true,
['then'] = true,
['repeat'] = true,
['while'] = true,
['until'] = true,
['end'] = true,
['function'] = true,
['local'] = true,
['in'] = true,
['and'] = true,
['or'] = true,
['not'] = true,
['+'] = true,
['-'] = true,
['%'] = true,
['#'] = true,
['*'] = true,
['/'] = true,
['^'] = true,
['='] = true,
['=='] = true,
['~='] = true,
['<'] = true,
['<='] = true,
['>'] = true,
['>='] = true,
['..'] = true
}
local builtins = {
['assert'] = true,
['collectgarbage'] = true,
['dofile'] = true,
['error'] = true,
['getfenv'] = true,
['getmetatable'] = true,
['ipairs'] = true,
['loadfile'] = true,
['loadstring'] = true,
['module'] = true,
['next'] = true,
['pairs'] = true,
['pcall'] = true,
['print'] = true,
['rawequal'] = true,
['rawget'] = true,
['rawset'] = true,
['require'] = true,
['select'] = true,
['setfenv'] = true,
['setmetatable'] = true,
['tonumber'] = true,
['tostring'] = true,
['type'] = true,
['unpack'] = true,
['xpcall'] = true
}
local values = {
['false'] = true,
['nil'] = true,
['true'] = true,
['_G'] = true,
['_VERSION'] = true
}
local patterns = {
{"^%-%-%[%[.-%]%]", commentColor},
{"^%-%-.*", commentColor},
{"^\"\"", stringColor},
{"^\".-[^\\]\"", stringColor},
{"^\'\'", stringColor},
{"^\'.-[^\\]\'", stringColor},
{"^%[%[.-%]%]", stringColor},
{"^[%w_%+%-%%%#%*%/%^%=%~%<%>%.]+", function(text)
if values[text] or tonumber(text) then
local match = text:find('^0x%x%x%x%x%x%x$')
if match then
local luminosity = 0.2126 * tonumber('0x' .. text:sub(3, 4)) + 0.7152 * tonumber('0x' .. text:sub(5, 6)) + 0.0722 * tonumber('0x' .. text:sub(7, 8))
if luminosity > 20 then
return 0x000000, tonumber(text)
else
return 0xffffff, tonumber(text)
end
else
return valueColor
end
elseif keywords[text] then
return keywordColor
elseif builtins[text] then
return builtinColor
end
return textColor
end}
}
local cache = {}
local currentMargin = 7
if not term.isAvailable() then
return
end
local gpu = term.gpu()
local originalFg = gpu.setForeground(textColor)
local originalBg = gpu.setBackground(bgColor)
local function resetColors()
gpu.setForeground(originalFg)
gpu.setBackground(originalBg)
end
local args, options = shell.parse(...)
if #args == 0 then
resetColors()
io.write("Usage: edit <filename>")
return
end
local filename = shell.resolve(args[1])
local file_parentpath = fs.path(filename)
if fs.exists(file_parentpath) and not fs.isDirectory(file_parentpath) then
resetColors()
io.stderr:write(string.format("Not a directory: %s\n", file_parentpath))
return 1
end
local readonly = options.r or fs.get(filename) == nil or fs.get(filename).isReadOnly()
if fs.isDirectory(filename) then
resetColors()
io.stderr:write("file is a directory\n")
return 1
elseif not fs.exists(filename) and readonly then
resetColors()
io.stderr:write("file system is read only\n")
return 1
end
local function loadConfig()
local env = {}
local config = loadfile("/etc/edit.cfg", nil, env)
if config then
pcall(config)
end
env.keybinds = env.keybinds or {
left = {{"left"}},
right = {{"right"}},
up = {{"up"}},
down = {{"down"}},
home = {{"home"}},
eol = {{"end"}},
pageUp = {{"pageUp"}},
pageDown = {{"pageDown"}},
backspace = {{"back"}},
delete = {{"delete"}},
deleteLine = {{"control", "delete"}, {"shift", "delete"}},
newline = {{"enter"}},
save = {{"control", "s"}},
close = {{"control", "w"}},
find = {{"control", "f"}},
findnext = {{"control", "g"}, {"control", "n"}, {"f3"}},
jump = {{'control', 'j'}}
}
if not config then
local root = fs.get("/")
if root and not root.isReadOnly() then
fs.makeDirectory("/etc")
local f = io.open("/etc/edit.cfg", "w")
if f then
local serialization = require("serialization")
for k, v in pairs(env) do
f:write(k.."="..tostring(serialization.serialize(v, math.huge)).."\n")
end
f:close()
end
end
end
return env
end
term.clear()
term.setCursorBlink(true)
local running = true
local buffer = {}
local scrollX, scrollY = 0, 0
local config = loadConfig()
local getKeyBindHandler
local function helpStatusText()
local function prettifyKeybind(label, command)
local keybind = type(config.keybinds) == "table" and config.keybinds[command]
if type(keybind) ~= "table" or type(keybind[1]) ~= "table" then return "" end
local alt, control, shift, key
for _, value in ipairs(keybind[1]) do
if value == "alt" then alt = true
elseif value == "control" then control = true
elseif value == "shift" then shift = true
else key = value end
end
if not key then return "" end
return label .. ": [" ..
(control and "Ctrl+" or "") ..
(alt and "Alt+" or "") ..
(shift and "Shift+" or "") ..
unicode.upper(key) ..
"] "
end
return prettifyKeybind("Save", "save") ..
prettifyKeybind("Close", "close") ..
prettifyKeybind("Find", "find") ..
prettifyKeybind('Jump to line', 'jump')
end
local currentStatus = ''
local function setStatus(value)
local x, y, w, h = term.getGlobalArea()
value = unicode.wlen(value) > w - 10 and unicode.wtrunc(value, w - 9) or value
value = text.padRight(value, w - 10)
gpu.set(x, y + h - 1, value)
currentStatus = value
end
local function getArea()
local x, y, w, h = term.getGlobalArea()
return x + currentMargin, y, w - currentMargin, h - 1
end
local function removePrefix(line, length)
if length >= unicode.wlen(line) then
return ""
else
local prefix = unicode.wtrunc(line, length + 1)
local suffix = unicode.sub(line, unicode.len(prefix) + 1)
length = length - unicode.wlen(prefix)
if length > 0 then
suffix = (" "):rep(unicode.charWidth(suffix) - length) .. unicode.sub(suffix, 2)
end
return suffix
end
end
local function lengthToChars(line, length)
if length > unicode.wlen(line) then
return unicode.len(line) + 1
else
local prefix = unicode.wtrunc(line, length)
return unicode.len(prefix) + 1
end
end
local function isWideAtPosition(line, x)
local index = lengthToChars(line, x)
if index > unicode.len(line) then
return false, false
end
local prefix = unicode.sub(line, 1, index)
local char = unicode.sub(line, index, index)
return unicode.isWide(char), unicode.wlen(prefix) == x
end
local function drawLine(x, y, w, h, lineNr)
local yLocal = lineNr - scrollY
local drawY = y - 1 + lineNr - scrollY
if yLocal > 0 and yLocal <= h then
local colors = {}
local line = buffer[lineNr] or ""
if cache[lineNr] and cache[lineNr][1] == line then
colors = cache[lineNr][2]
else
local function appendTextInColor(text, color, bgcolor)
local data = colors[#colors]
if data ~= nil then
if data[2] == color and data[3] == bgcolor then
data[1] = data[1] .. text
else
colors[#colors + 1] = {text, color, bgcolor}
end
else
colors[#colors + 1] = {text, color, bgcolor}
end
end
local len = 0
for char = 1, line:len() do
if char > len then
local patternFound = false
for pat = 1, #patterns do
local data = patterns[pat]
local foundb, founde = line:find(data[1], char)
if foundb ~= nil then
local text = line:sub(foundb, founde)
local color = data[2]
local bgcolor = data[3]
if type(color) == 'function' then
color, bgcolor = color(text)
end
appendTextInColor(text, color, bgcolor)
len = len + (founde - foundb + 1)
patternFound = true
break
end
end
if not patternFound then
appendTextInColor(line:sub(char, char), textColor)
len = len + 1
end
end
end
cache[lineNr] = {line, colors}
end
local i = 0
local cx, cy = term.getCursor()
local lineBg = bgColor
if cy + scrollY == lineNr then
lineBg = lineColor
end
gpu.setBackground(lineBg)
gpu.fill(1, y - 1 + lineNr - scrollY, 7, 1, ' ')
gpu.fill(x, drawY, w + currentMargin, 1, ' ')
if lineNr <= #buffer then
for l = 1, #colors do
local data = colors[l]
local text = data[1]
local color = data[2]
local bg = data[3]
local drawAt = i - scrollX + x
if drawAt + text:len() > 0 then
local currentColor = gpu.setForeground(color)
local currentBg = gpu.setBackground(bg or lineBg)
gpu.set(drawAt, drawY, text)
gpu.setForeground(currentColor)
gpu.setBackground(currentBg)
end
i = i + text:len()
end
local currentColor = gpu.setForeground(lineNrColor)
local number = tostring(math.floor(lineNr))
gpu.fill(1, y - 1 + lineNr - scrollY, 7, 1, ' ')
gpu.set(2 + (5 - number:len()), y - 1 + lineNr - scrollY, number)
gpu.setForeground(currentColor)
gpu.setBackground(bgColor)
end
end
end
local function getCursor()
local cx, cy = term.getCursor()
return cx + scrollX - currentMargin, cy + scrollY
end
local function line()
local cbx, cby = getCursor()
return buffer[cby]
end
local function getNormalizedCursor()
local cbx, cby = getCursor()
local wide, right = isWideAtPosition(buffer[cby], cbx)
if wide and right then
cbx = cbx - 1
end
return cbx, cby
end
local function setCursor(nbx, nby)
local x, y, w, h = getArea()
nby = math.max(1, math.min(#buffer, nby))
local ncy = nby - scrollY
if ncy > h then
term.setCursorBlink(false)
local sy = nby - h
local dy = math.abs(scrollY - sy)
scrollY = sy
if h > dy then
gpu.copy(x - currentMargin, y + dy, w + currentMargin, h - dy, 0, -dy)
end
for lineNr = nby - (math.min(dy, h) - 1), nby do
drawLine(x, y, w, h, lineNr)
end
elseif ncy < 1 then
term.setCursorBlink(false)
local sy = nby - 1
local dy = math.abs(scrollY - sy)
scrollY = sy
if h > dy then
gpu.copy(x - currentMargin, y, w + currentMargin, h - dy, 0, dy)
end
for lineNr = nby, nby + (math.min(dy, h) - 1) do
drawLine(x, y, w, h, lineNr)
end
end
term.setCursor(term.getCursor(), nby - scrollY)
nbx = math.max(1, math.min(unicode.wlen(line()) + 1, nbx))
local wide, right = isWideAtPosition(line(), nbx)
local ncx = nbx - scrollX
if ncx > w or (ncx + 1 > w and wide and not right) then
term.setCursorBlink(false)
scrollX = nbx - w + ((wide and not right) and 1 or 0)
for lineNr = 1 + scrollY, math.min(h + scrollY, #buffer) do
drawLine(x, y, w, h, lineNr)
end
elseif ncx < 1 or (ncx - 1 < 1 and wide and right) then
term.setCursorBlink(false)
scrollX = nbx - 1 - ((wide and right) and 1 or 0)
for lineNr = 1 + scrollY, math.min(h + scrollY, #buffer) do
drawLine(x, y, w, h, lineNr)
end
end
term.setCursor(nbx - scrollX + currentMargin, nby - scrollY)
nbx, nby = getCursor()
gpu.set(x + w - 10, y + h, text.padLeft(string.format("%d,%d", nby, nbx), 10))
end
local function highlight(bx, by, length, enabled)
local x, y, w, h = getArea()
local cx, cy = bx - scrollX, by - scrollY
cx = math.max(1, math.min(w, cx))
cy = math.max(1, math.min(h, cy))
length = math.max(1, math.min(w - cx, length))
local fg, fgp = gpu.getForeground()
local bg, bgp = gpu.getBackground()
if enabled then
gpu.setForeground(bg, bgp)
gpu.setBackground(fg, fgp)
end
local indexFrom = lengthToChars(buffer[by], bx)
local value = unicode.sub(buffer[by], indexFrom)
if unicode.wlen(value) > length then
value = unicode.wtrunc(value, length + 1)
end
gpu.set(x - 1 + cx, y - 1 + cy, value)
if enabled then
gpu.setForeground(fg, fgp)
gpu.setBackground(bg, bgp)
end
end
local function home()
local cbx, cby = getCursor()
setCursor(1, cby)
end
local function ende()
local cbx, cby = getCursor()
setCursor(unicode.wlen(line()) + 1, cby)
end
local function left()
local cbx, cby = getNormalizedCursor()
if cbx > 1 then
local wideTarget, rightTarget = isWideAtPosition(line(), cbx - 1)
if wideTarget and rightTarget then
setCursor(cbx - 2, cby)
else
setCursor(cbx - 1, cby)
end
return true
elseif cby > 1 then
setCursor(cbx, cby - 1)
ende()
return true
end
end
local function right(n)
n = n or 1
local cbx, cby = getNormalizedCursor()
local be = unicode.wlen(line()) + 1
local wide, right = isWideAtPosition(line(), cbx + n)
if wide and right then
n = n + 1
end
if cbx + n <= be then
setCursor(cbx + n, cby)
elseif cby < #buffer then
setCursor(1, cby + 1)
end
end
local function up(n)
n = n or 1
local cbx, cby = getCursor()
if cby > 1 then
setCursor(cbx, cby - n)
end
end
local function down(n)
n = n or 1
local cbx, cby = getCursor()
if cby < #buffer then
setCursor(cbx, cby + n)
end
end
local function delete(fullRow)
local cx, cy = term.getCursor()
local cbx, cby = getCursor()
local x, y, w, h = getArea()
local function deleteRow(row)
local content = table.remove(buffer, row)
local rcy = cy + (row - cby)
if rcy <= h then
gpu.copy(x, y + rcy, w, h - rcy, 0, -1)
drawLine(x, y, w, h, row + (h - rcy))
end
return content
end
if fullRow then
term.setCursorBlink(false)
if #buffer > 1 then
deleteRow(cby)
else
buffer[cby] = ""
gpu.fill(x, y - 1 + cy, w, 1, " ")
end
setCursor(1, cby)
elseif cbx <= unicode.wlen(line()) then
term.setCursorBlink(false)
local index = lengthToChars(line(), cbx)
buffer[cby] = unicode.sub(line(), 1, index - 1) ..
unicode.sub(line(), index + 1)
drawLine(x, y, w, h, cby)
elseif cby < #buffer then
term.setCursorBlink(false)
local append = deleteRow(cby + 1)
buffer[cby] = buffer[cby] .. append
drawLine(x, y, w, h, cby)
else
return
end
setStatus(helpStatusText())
end
-- enter() jetzt mit Schalter für Auto-Indent
local function enter(autoIndent)
if autoIndent == nil then autoIndent = true end
term.setCursorBlink(false)
local cx, cy = term.getCursor()
local cbx, cby = getCursor()
local x, y, w, h = getArea()
local index = lengthToChars(line(), cbx)
table.insert(buffer, cby + 1, unicode.sub(buffer[cby], index))
buffer[cby] = unicode.sub(buffer[cby], 1, index - 1)
drawLine(x, y, w, h, cby)
if cy < h then
if cy < h - 1 then
gpu.copy(x, y + cy, w, h - (cy + 1), 0, 1)
end
drawLine(x, y, w, h, cby + 1)
end
if autoIndent then
local whitespace = buffer[cby]:match('^[%s]+') or ""
buffer[cby + 1] = whitespace .. buffer[cby + 1]
setCursor(1 + whitespace:len(), cby + 1)
else
setCursor(1, cby + 1)
end
setStatus(helpStatusText())
end
local findText = ""
local function find()
local x, y, w, h = getArea()
local cx, cy = term.getCursor()
local cbx, cby = getCursor()
local ibx, iby = cbx, cby
while running do
if unicode.len(findText) > 0 then
local sx, sy
for syo = 1, #buffer do
sy = (iby + syo - 1 + #buffer - 1) % #buffer + 1
sx = string.find(buffer[sy], findText, syo == 1 and ibx or 1, true)
if sx and (sx >= ibx or syo > 1) then
break
end
end
if not sx then
sy = iby
sx = string.find(buffer[sy], findText, nil, true)
end
if sx then
sx = unicode.wlen(string.sub(buffer[sy], 1, sx - 1)) + 1
cbx, cby = sx, sy
setCursor(cbx, cby)
highlight(cbx, cby, unicode.wlen(findText), true)
end
end
term.setCursor(7 + unicode.wlen(findText), h + 1)
setStatus("Find: " .. findText)
local _, address, char, code = term.pull("key_down")
if address == term.keyboard() then
local handler, name = getKeyBindHandler(code)
highlight(cbx, cby, unicode.wlen(findText), false)
if name == "newline" then
break
elseif name == "close" then
handler()
elseif name == "backspace" then
findText = unicode.sub(findText, 1, -2)
elseif name == "find" or name == "findnext" then
ibx = cbx + 1
iby = cby
elseif not keyboard.isControl(char) then
findText = findText .. unicode.char(char)
end
end
end
setCursor(cbx, cby)
setStatus(helpStatusText())
end
local function fix()
local x, y, w, h = getArea()
gpu.fill(x, y, w, h, ' ')
for i = 1, #buffer do
if i > scrollY and i <= (scrollY + h) then
drawLine(x, y, w, h, i)
end
end
end
local function jump()
local x, y, w, h = getArea()
local cx, cy = term.getCursor()
local currentStatus = currentStatus
setStatus('Jump to line #')
local current = ''
while true do
local _, address, char, code = term.pull('key_down')
char = math.floor(char)
if address == term.keyboard() then
if char == 13 then
break
elseif char >= string.byte('0') and char <= string.byte('9') then
if current:len() < 5 then
current = current .. string.char(char)
end
elseif char == 127 then
if current:len() > 0 then
current = current:sub(1, -1)
end
end
term.setCursor(15, h + 1)
gpu.set(15, h + 1, ' ')
term.write(current)
end
end
term.setCursor(cx, cy)
current = tonumber(current)
if current then
if current <= #buffer then
setCursor(1, current)
setStatus('Jumped to line ' .. tostring(current))
else
setStatus('Line ' .. tostring(current) .. ' does not exist')
end
else
setStatus(currentStatus)
end
end
local keyBindHandlers = {
left = left,
right = right,
up = up,
down = down,
home = home,
eol = ende,
pageUp = function()
local x, y, w, h = getArea()
up(h - 1)
end,
pageDown = function()
local x, y, w, h = getArea()
down(h - 1)
end,
backspace = function()
if not readonly and left() then
delete()
end
end,
delete = function()
if not readonly then
delete()
end
end,
deleteLine = function()
if not readonly then
delete(true)
end
end,
newline = function()
if not readonly then
enter()
end
end,
save = function()
if readonly then return end
local new = not fs.exists(filename)
local backup
if not new then
backup = filename .. "~"
for i = 1, math.huge do
if not fs.exists(backup) then
break
end
backup = filename .. "~" .. i
end
fs.copy(filename, backup)
end
if not fs.exists(file_parentpath) then
fs.makeDirectory(file_parentpath)
end
local f, reason = io.open(filename, "w")
if f then
local chars, firstLine = 0, true
for _, line in ipairs(buffer) do
if not firstLine then
line = "\n" .. line
end
firstLine = false
f:write(line)
chars = chars + unicode.len(line)
end
f:close()
local format
if new then
format = [["%s" [New] %dL,%dC written]]
else
format = [["%s" %dL,%dC written]]
end
setStatus(string.format(format, fs.name(filename), #buffer, chars))
else
setStatus(reason)
end
if not new then
fs.remove(backup)
end
end,
close = function()
running = false
end,
find = function()
findText = ""
find()
end,
findnext = find,
jump = jump
}
getKeyBindHandler = function(code)
if type(config.keybinds) ~= "table" then return end
local result, resultName, resultWeight = nil, nil, 0
for command, keybinds in pairs(config.keybinds) do
if type(keybinds) == "table" and keyBindHandlers[command] then
for _, keybind in ipairs(keybinds) do
if type(keybind) == "table" then
local alt, control, shift, key
for _, value in ipairs(keybind) do
if value == "alt" then alt = true
elseif value == "control" then control = true
elseif value == "shift" then shift = true
else key = value end
end
local keyboardAddress = term.keyboard()
if (not alt or keyboard.isAltDown(keyboardAddress)) and
(not control or keyboard.isControlDown(keyboardAddress)) and
(not shift or keyboard.isShiftDown(keyboardAddress)) and
code == keyboard.keys[key] and
#keybind > resultWeight
then
resultWeight = #keybind
resultName = command
result = keyBindHandlers[command]
end
end
end
end
end
return result, resultName
end
local function insert(value)
if not value or unicode.len(value) < 1 then
return
end
term.setCursorBlink(false)
local cx, cy = term.getCursor()
local cbx, cby = getCursor()
local x, y, w, h = getArea()
local index = lengthToChars(line(), cbx)
buffer[cby] = unicode.sub(line(), 1, index - 1) ..
value ..
unicode.sub(line(), index)
drawLine(x, y, w, h, cby)
right(unicode.wlen(value))
setStatus(helpStatusText())
end
local function onKeyDown(char, code)
local handler = getKeyBindHandler(code)
if handler then
handler()
elseif readonly and code == keyboard.keys.q then
running = false
elseif not readonly then
if not keyboard.isControl(char) then
insert(unicode.char(char))
elseif unicode.char(char) == "\t" then
insert(" ")
end
end
end
-- WICHTIG: hier das Einfügen fixen
local function onClipboard(value)
value = value:gsub("\r\n", "\n")
local cbx, cby = getCursor()
local start = 1
local l = value:find("\n", 1, true)
if l then
repeat
local line = string.sub(value, start, l - 1)
line = text.detab(line, 2)
insert(line)
-- beim Einfügen KEIN Auto-Indent
enter(false)
start = l + 1
l = value:find("\n", start, true)
until not l
end
insert(string.sub(value, start))
end
local function onClick(x, y)
setCursor(x + scrollX, y + scrollY)
end
local function onScroll(direction)
local cbx, cby = getCursor()
setCursor(cbx, cby - direction * 12)
end
do
local f = io.open(filename)
if f then
local x, y, w, h = getArea()
local chars = 0
for line in f:lines() do
table.insert(buffer, line)
chars = chars + unicode.len(line)
if #buffer <= h then
drawLine(x, y, w, h, #buffer)
end
end
f:close()
if #buffer == 0 then
table.insert(buffer, "")
end
local format
if readonly then
format = [["%s" [readonly] %dL,%dC]]
else
format = [["%s" %dL,%dC]]
end
setStatus(string.format(format, fs.name(filename), #buffer, chars))
else
table.insert(buffer, "")
setStatus(string.format([["%s" [New File] ]], fs.name(filename)))
end
setCursor(1, 1)
end
while running do
local startX = scrollX
local startY = scrollY
local _, startC = getCursor()
local event, address, arg1, arg2, arg3 = term.pull()
if address == term.keyboard() or address == term.screen() then
local blink = true
if event == "key_down" then
onKeyDown(arg1, arg2)
elseif event == "clipboard" and not readonly then
onClipboard(arg1)
elseif event == "touch" or event == "drag" then
local x, y, w, h = getArea()
arg1 = arg1 - x + 1
arg2 = arg2 - y + 1
if arg1 >= 1 and arg2 >= 1 and arg1 <= w and arg2 <= h then
onClick(arg1, arg2)
end
elseif event == "scroll" then
onScroll(arg3)
else
blink = false
end
if blink then
term.setCursorBlink(true)
end
local _, endC = getCursor()
local x, y, w, h = getArea()
if startC ~= endC then
drawLine(x, y, w, h, startC)
end
drawLine(x, y, w, h, endC)
end
end
resetColors()
term.clear()
term.setCursorBlink(true)