From d2001bb4581f10ecd3b621f82d05433badc0aba4 Mon Sep 17 00:00:00 2001 From: prod2 Date: Thu, 29 Dec 2022 17:54:51 +0100 Subject: [PATCH] unicode support, half done multiline --- editor.nim | 138 +++++++++++++++---------------------- example.nim | 2 +- renderer.nim | 27 ++++++++ terminalUtils/buffer.nim | 23 +++++-- terminalUtils/keycodes.nim | 137 ++++++++++++++++++++++++------------ textBuffer.nim | 101 +++++++++++++++++++++++++++ 6 files changed, 293 insertions(+), 135 deletions(-) create mode 100644 renderer.nim create mode 100644 textBuffer.nim diff --git a/editor.nim b/editor.nim index c9e98d2..c32a625 100644 --- a/editor.nim +++ b/editor.nim @@ -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 \ No newline at end of file diff --git a/example.nim b/example.nim index 3e0178d..797fdff 100644 --- a/example.nim +++ b/example.nim @@ -1,6 +1,6 @@ import editor -let e = newEditor(">>> ", false) +let e = newEditor(">>> ") while true: let (res, text) = e.read() diff --git a/renderer.nim b/renderer.nim new file mode 100644 index 0000000..db495c4 --- /dev/null +++ b/renderer.nim @@ -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() + diff --git a/terminalUtils/buffer.nim b/terminalUtils/buffer.nim index 3889f83..8a1c6e7 100644 --- a/terminalUtils/buffer.nim +++ b/terminalUtils/buffer.nim @@ -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) diff --git a/terminalUtils/keycodes.nim b/terminalUtils/keycodes.nim index 765e128..0b3d8b8 100644 --- a/terminalUtils/keycodes.nim +++ b/terminalUtils/keycodes.nim @@ -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) diff --git a/textBuffer.nim b/textBuffer.nim new file mode 100644 index 0000000..8194fcf --- /dev/null +++ b/textBuffer.nim @@ -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() \ No newline at end of file