unicode support, half done multiline

This commit is contained in:
Art 2022-12-29 17:54:51 +01:00
parent 3799102798
commit d2001bb458
Signed by: prod2
GPG Key ID: F3BB5A97A70A8DDE
6 changed files with 293 additions and 135 deletions

View File

@ -1,32 +1,22 @@
import unicode
import strutils
import strformat
import posix
import unicode
import terminalUtils/buffer
import terminalUtils/terminalGetInfo
import terminalUtils/keycodes
import textBuffer
import renderer
type
EditorState* = ref object
# config
prompt: string # prompt to display
maxRowsGoal: int # if terminal resizes, how to compute max rows
# positive: it will set max rows to maxRowsGoal, or terminal height, whichever is lower
# negative: it will set it to terminal height - maxRowsGoal or terminal height, whichever is lower
# 0: terminal height
# state reached during execution
x: int # current cursor x position on screen
bx: int # current x position in buffer (not same as x due to UTF-8)
y: int # which row are we editing
size: int # size of the row being edited in characters
buffer: seq[string] # current text
screenBuffer: Buffer
history: seq[seq[string]] # past texts
textBuffer: TextBuffer # current text
termBuffer: Buffer
history: seq[TextBuffer] # past texts
historyIndex: int # where are we in history
cols: int # number of columns in the terminal
maxRows: int # max number of rows allowed
EditorResult* = enum
erEnter, erCtrlC, erCtrlD, erError
@ -38,11 +28,9 @@ var editors: seq[EditorState]
onSignal(28):
discard
proc newEditor*(prompt: string = "> ", multiline: bool = true): EditorState =
proc newEditor*(prompt: string = "> "): EditorState =
new(result)
result.prompt = prompt
result.maxRowsGoal = if multiline: 0 else: 1
result.buffer.add("")
editors.add(result)
proc destroyEditor*(oldEditor: EditorState) =
@ -52,80 +40,62 @@ proc destroyEditor*(oldEditor: EditorState) =
editors.del(i)
return
proc render(ed: EditorState) =
ed.screenBuffer.clearLine()
ed.screenBuffer.write(ed.prompt)
ed.screenBuffer.write(ed.buffer[ed.y])
ed.screenBuffer.redraw()
template cline(ed: EditorState): var string =
ed.buffer[ed.y]
proc read*(ed: EditorState): (EditorResult, string) =
var editorResult = erError
let (_, cursorY) = termGetCursorPos(stdout)
ed.screenBuffer = newBuffer(0, cursorY, termGetWidth(), termGetHeight() - cursorY, stdout)
ed.termBuffer = newBuffer(0, cursorY, termGetWidth(), 1, stdout)
ed.textBuffer = newTextBuffer()
ed.history.add(ed.textBuffer)
ed.historyIndex = ed.history.high()
ed.render()
while true:
let key = getKey()
case key:
of jkEnter.int:
editorResult = erEnter
break
of jkBackspace.int:
if ed.x == 0:
discard # merge two lines TODO
elif ed.x > ed.cline().high():
# TODO UTF8
ed.cline() = ed.cline[0..ed.cline().high()-1]
dec ed.x
dec ed.bx
else:
# TODO UTF8
ed.cline() = ed.cline[0..ed.x-1] & ed.cline[ed.x+1..ed.cline().high()]
dec ed.x
dec ed.bx
of jkDelete.int:
if ed.x > ed.cline.high():
discard # merge two lines TODO
elif ed.x == 0:
# TODO UTF8
ed.cline() = ed.cline[1..ed.cline.high()]
else:
# TODO UTF8
discard # TODO implement
of 3: # ctrl+c
editorResult = erCtrlC
break
of 4: # ctrl+d
if ed.buffer.len() <= 1 and ed.cline().len() == 0:
editorResult = erCtrlD
break
else:
if key > 31 and key < 127:
# ascii char
let ch = key.char()
if ed.x == 0:
ed.cline() = $ch & ed.cline
elif ed.x > ed.cline.high():
ed.cline() = ed.cline & $ch
render(ed.textBuffer, ed.termBuffer, ed.prompt)
let (getKeyResult, key) = getKey()
case getKeyResult:
of gkChar:
ed.textBuffer.insertRune(key.uint32().Rune())
of gkControl:
let control = key.JaleKeycode()
case control:
of jkEnter:
if ed.textBuffer.isCursorAtEnd():
editorResult = erEnter
break
else:
ed.textBuffer.enter()
of {jkBackspace, jkAltBackspace}:
ed.textBuffer.backspace()
of jkDelete:
ed.textBuffer.delete()
of jkCtrlC: # ctrl+c
editorResult = erCtrlC
break
of jkCtrlD: # ctrl+d
if ed.textBuffer.isEmpty():
editorResult = erCtrlD
break
of jkLeft:
ed.textBuffer.moveCursor(-1, 0)
of jkRight:
ed.textBuffer.moveCursor(1, 0)
of jkDown:
ed.textBuffer.moveCursor(0, 1)
of jkUp:
ed.textBuffer.moveCursor(0, -1)
of jkEnd:
ed.textBuffer.moveToEnd(true)
of jkHome:
ed.textBuffer.moveToEnd(false)
of jkCtrlDown:
ed.textBuffer.newLine()
else:
ed.cline() = ed.cline[0..ed.x-1] & $ch & ed.cline[ed.x..ed.cline().high()]
inc ed.x
inc ed.bx
ed.render()
discard # not implemented
# return val
result = (editorResult, ed.buffer.join("\n"))
result = (editorResult, ed.textBuffer.getContent())
# cleanup
stdout.write("\n")
ed.history.add(ed.buffer)
ed.buffer = @[""]
ed.y = 0
ed.x = 0
ed.screenBuffer = nil
ed.termBuffer = nil

View File

@ -1,6 +1,6 @@
import editor
let e = newEditor(">>> ", false)
let e = newEditor(">>> ")
while true:
let (res, text) = e.read()

27
renderer.nim Normal file
View File

@ -0,0 +1,27 @@
# takes a text buffer and a terminal buffer (terminalUtils/buffer)
# when render() is called, rewrites the terminal buffer completely as a function of
# of the text buffer
import textBuffer
import terminalUtils/buffer
import terminalUtils/terminalGetInfo
proc render*(textBuffer: TextBuffer, termBuffer: Buffer, prompt: string) =
# we are free to "redraw" everything everytime
# since termBuffer double buffers
# resizing the buffer if needed
let lineCount = textBuffer.getLineCount()
let termWidth = termGetWidth()
let termHeight = termGetHeight() # TODO
let (bufWidth, bufHeight) = termBuffer.getSize()
termBuffer.clearLine()
termBuffer.write(prompt)
termBuffer.write(textBuffer.getLine())
let (x, y) = textBuffer.getCursorPos()
termBuffer.setCursorPos(x + prompt.len(), y)
termBuffer.redraw()

View File

@ -86,10 +86,7 @@ proc clearLine*(buf: Buffer) =
buf.buffered[i] = Cell(text: Rune(0))
buf.bufferX = 0
proc write*(buf: Buffer, text: string) =
# convert text to characters
let runes = text.toRunes()
proc write*(buf: Buffer, runes: seq[Rune]) =
# if it would go out of the screen, crash
if buf.bufferX + runes.len() >= buf.width:
raise newException(ValueError, "Text too long, would overflow the buffer.")
@ -102,6 +99,12 @@ proc write*(buf: Buffer, text: string) =
buf.bufferX += runes.len()
proc write*(buf: Buffer, text: string) =
# convert text to characters
let runes = text.toRunes()
buf.write(runes)
proc redraw*(buf: Buffer, force: bool = false) =
var toPrint = ""
@ -142,8 +145,16 @@ func getSize*(buf: Buffer): (int, int) =
(buf.width, buf.height)
proc resize*(buf: Buffer, newX, newY: int) =
# TODO
raise newException(Defect, "Not implemented")
# shrinking X
if newX < buf.width:
# dropping cells outside the screen
var i = 0
buf.width = newX
buf.height = newY
func getPosition*(buf: Buffer): (int, int) =
(buf.positionX, buf.positionY)

View File

@ -3,49 +3,34 @@
import tables
import strutils
import terminal
import unicode
type
JaleKeycode* = enum
jkStart = 255 # jale keycodes exported start one above jkStart
# arrow keys
jkCtrlA = 1, jkCtrlB, jkCtrlC, jkCtrlD, jkCtrlE,
jkCtrlF, jkCtrlG, # ctrl+h invalid
jkBackspace = 8,
jkTab = 9,
jkCtrlJ = 10, jkCtrlK, jkCtrlL,
jkEnter = 13,
jkCtrlN = 14, jkCtrlO, jkCtrlP, jkCtrlQ,
jkCtrlR, jkCtrlS, jkCtrlT, jkCtrlU, jkCtrlV,
jkCtrlW, jkCtrlX, jkCtrlY, jkCtrlZ,
jkEscape = 27,
jkCtrlBackslash = 28,
jkCtrlRightBracket = 29,
jkAltBackspace = 127,
jkLeft = 256, jkRight, jkUp, jkDown,
jkCtrlLeft, jkCtrlRight, jkCtrlUp, jkCtrlDown,
# other 4 move keys
jkHome, jkEnd, jkPageUp, jkPageDown,
jkCtrlHome, jkCtrlEnd, jkCtrlPageUp, jkCtrlPageDown,
# special keys
jkDelete, jkBackspace, jkInsert, jkEnter,
jkDelete, jkInsert,
jkFinish, # jale keycodes exported end one below jkFinish
# non-exported jale keycodes come here:
jkContinue # represents an unfinished escape sequence
var keysById*: Table[int, string]
var keysByName*: Table[string, int]
block:
# 1 - 26
for i in countup(1, 26): # iterate through lowercase letters
keysById[i] = "ctrl+" & $char(i + 96)
keysById[9] = "tab"
# keysById[8] will never get triggered because it's an escape seq
keysById[28] = r"ctrl+\"
keysById[29] = "ctrl+]"
keysByName[r"ctrl+\"] = 28
keysByName["ctrl+]"] = 29
for i in countup(1, 26):
keysByName[keysById[i]] = i
# jale keycodes:
for i in countup(int(jkStart) + 1, int(jkFinish) - 1):
var name: string = ($JaleKeycode(i))
name = name[2..name.high()].toLower()
keysByName[name] = i
keysById[i] = name
var escapeSeqs*: Table[int, JaleKeycode]
proc defEscSeq(keys: seq[int], id: JaleKeycode) =
@ -58,6 +43,8 @@ proc defEscSeq(keys: seq[int], id: JaleKeycode) =
escapeSeqs[result] = id
block:
defEscSeq(@[127], jkAltBackspace)
when defined(windows):
defEscSeq(@[224], jkContinue)
@ -83,8 +70,6 @@ block:
defEscSeq(@[224, 117], jkCtrlEnd)
# special keys
defEscSeq(@[8], jkBackspace)
defEscSeq(@[13], jkEnter)
defEscSeq(@[224, 82], jkInsert)
defEscSeq(@[224, 83], jkDelete)
else:
@ -154,24 +139,88 @@ block:
defEscSeq(@[27, 91, 50], jkContinue)
defEscSeq(@[27, 91, 51, 126], jkDelete)
defEscSeq(@[27, 91, 50, 126], jkInsert)
defEscSeq(@[13], jkEnter)
defEscSeq(@[127], jkBackspace)
defEscSeq(@[8], jkBackspace)
type IsKeyUTF8Results = enum
ikValid, ikUnfinished, ikOverlong, ikInvalid2,
ikInvalid3, ikInvalid4, ikInvalidUnknown
proc getChar: int =
getch().int
func isContinuation(singleByte: uint8): bool =
singleByte shr 6 == 0b10
proc getKey*: int =
# TODO unicode handling
func isValidUTF8(key: int): IsKeyUTF8Results =
const L = sizeof(int)
var bytes: array[L, uint8]
var key = key
var i = L - 1
# to not make any assumptions about how int is
# stored in memory
while key > 0:
bytes[i] = (key mod 256).uint8
key = key div 256
dec i
# https://github.com/nim-lang/Nim/blob/version-1-6/lib/pure/unicode.nim#L166
i = 0
while i < L:
if bytes[i] <= 127:
inc i
elif bytes[i] shr 5 == 0b110:
# 2 long
if bytes[i] < 0xc2: return ikOverlong # overlong
elif i + 1 >= L: return ikUnfinished
elif bytes[i+1].isContinuation: i += 2
else: return ikInvalid2
elif bytes[i] shr 4 == 0b1110:
# 3 long
if i + 2 >= L: return ikUnfinished
elif bytes[i+1].isContinuation() and bytes[i+2].isContinuation():
i += 3
else: return ikInvalid3
elif bytes[i] shr 3 == 0b11110:
# 4 long
if i+3 >= L: return ikUnfinished
elif bytes[i+1].isContinuation() and
bytes[i+2].isContinuation() and
bytes[i+3].isContinuation():
i += 4
else: return ikInvalid4
else:
return ikInvalidUnknown
return ikValid
type
GetKeyResult* = enum
gkControl, gkChar
proc getKey*: (GetKeyResult, int) =
# TODO move away from int approach completely
var key = 0
var bytes: string
while true:
key *= 256
key += getChar()
let newChar = getch()
key += newChar.int()
bytes &= newChar
if escapeSeqs.hasKey(key):
if escapeSeqs[key] != jkContinue:
key = escapeSeqs[key].int()
break
return (gkControl, key)
elif key < 30 and key > 0:
# JaleKeycode has values from 1 to 29
# which are NOT in escapeSeqs to save typing
return (gkControl, key)
else:
break
return key
let validity = isValidUTF8(key)
case validity:
of ikValid:
# TODO: find a better alternative to runeAt
return (gkChar, bytes.runeAt(0).int)
of ikUnfinished:
discard # continue looping
else:
# For now, in development builds, crash
# later it might be wise to just ignore
# bad bytes, might check what other line
# editors do
raise newException(ValueError, "Invalid UTF8 input, " & $validity)

101
textBuffer.nim Normal file
View File

@ -0,0 +1,101 @@
# a multiline utf8 text buffer
import strutils
import unicode
import sequtils
type
TextBuffer* = ref object
content: seq[seq[Rune]]
cursorX: int # rune index
cursorY: int
proc newTextBuffer*: TextBuffer =
new(result)
result.content = @[]
result.content.add(@[])
result.cursorX = 0
result.cursorY = 0
proc getLine*(buf: TextBuffer): seq[Rune] =
buf.content[buf.cursorY]
proc getContent*(buf: TextBuffer): string =
buf.content.join("\n")
proc cline(buf: TextBuffer): var seq[Rune] =
buf.content[buf.cursorY]
proc insertRune*(buf: TextBuffer, rune: Rune) =
buf.cline().insert(rune, buf.cursorX)
buf.cursorX += 1
proc isEmpty*(buf: TextBuffer): bool =
buf.content.len() == 1 and buf.content[0].len() == 0
proc delete*(buf: TextBuffer) =
# emulates a delete key
if buf.cursorX == 0 and buf.cline().len() > 0:
buf.cline() = buf.cline[1..buf.cline.high()]
elif buf.cursorX > buf.cline.high():
discard # merge two lines TODO
else:
buf.cline() = buf.cline[0..buf.cursorX-1] & buf.cline[buf.cursorX+1..buf.cline().high()]
proc backspace*(buf: TextBuffer) =
# emulates a backspace key
if buf.cursorX == 0:
discard # merge two lines TODO
elif buf.cursorX > buf.cline().high():
buf.cline() = buf.cline[0..buf.cline().high()-1]
dec buf.cursorX
else:
buf.cline() = buf.cline[0..buf.cursorX-2] & buf.cline[buf.cursorX..buf.cline().high()]
dec buf.cursorX
proc moveCursor*(buf: TextBuffer, dx, dy: int) =
if dx != 0:
buf.cursorX += dx
if buf.cursorX < 0:
buf.cursorX = 0 # TODO switching over lines
elif buf.cursorX > buf.cline().len():
buf.cursorX = buf.cline().len() # TODO switching over lines
if dy != 0:
buf.cursorY += dy
if buf.cursorY < 0:
buf.cursorY = 0
elif buf.cursorY > buf.content.high():
buf.cursorY = buf.content.high()
proc getCursorPos*(buf: TextBuffer): (int, int) =
(buf.cursorX, buf.cursorY)
proc isCursorAtEnd*(buf: TextBuffer): bool =
return buf.cursorY == buf.content.high() and buf.cursorX > buf.cline().high()
proc moveToEnd*(buf: TextBuffer, forward: bool) =
# home/end keys
if forward:
buf.cursorX = buf.cline().len()
else:
buf.cursorX = 0
proc enter*(buf: TextBuffer) =
# emulates an enter in text editors
let pre = buf.cline[0..buf.cursorX-1]
let post = buf.cline[buf.cursorX..^1]
buf.cline() = pre
inc buf.cursorY
buf.content.insert(post, buf.cursorY)
buf.cursorX = 0
proc newLine*(buf: TextBuffer) =
# inserts a new empty line below the current one
# and moves cursor to it
inc buf.cursorY
buf.content.insert(@[], buf.cursorY)
buf.cursorX = 0
proc getLineCount*(buf: TextBuffer): int =
buf.content.len()