From 2358434baeda2efa437d856d825d7ed5e7e7a51d Mon Sep 17 00:00:00 2001 From: Productive2 Date: Mon, 15 Feb 2021 20:18:31 +0100 Subject: [PATCH] initial commit --- .gitignore | 1 + defaults.nim | 52 +++++++++++++++++++++++ editor.nim | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++ event.nim | 17 ++++++++ keycodes.nim | 85 +++++++++++++++++++++++++++++++++++++ line.nim | 40 ++++++++++++++++++ main.nim | 32 ++++++++++++++ multiline.nim | 107 ++++++++++++++++++++++++++++++++++++++++++++++ renderer.nim | 21 +++++++++ templates.nim | 21 +++++++++ todo.md | 20 +++++++++ 11 files changed, 511 insertions(+) create mode 100644 .gitignore create mode 100644 defaults.nim create mode 100644 editor.nim create mode 100644 event.nim create mode 100644 keycodes.nim create mode 100644 line.nim create mode 100644 main.nim create mode 100644 multiline.nim create mode 100644 renderer.nim create mode 100644 templates.nim create mode 100644 todo.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba2906d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +main diff --git a/defaults.nim b/defaults.nim new file mode 100644 index 0000000..8e99958 --- /dev/null +++ b/defaults.nim @@ -0,0 +1,52 @@ +import editor +import keycodes +import multiline +import event +import tables +import templates + +# default populate +proc defInsert(editor: LineEditor) = + if editor.lastKeystroke > 31 and editor.lastKeystroke < 127: + let ch = char(editor.lastKeystroke) + editor.content.insert($ch) + +proc defControl(editor: LineEditor) = + block control: + template check(key: string, blk: untyped) = + if editor.lastKeystroke == keysByName[key]: + blk + break control + check("left"): + editor.content.left() + check("right"): + editor.content.right() + check("up"): + editor.content.up() + check("down"): + editor.content.down() + check("backspace"): + editor.content.backspace() + check("delete"): + editor.content.delete() + check("enter"): + if editor.content.Y() == editor.content.high() and editor.content.getLine(editor.content.high()) == "": + editor.finished = true + else: + editor.content.insertline() + check("ctrl+c"): + editor.finished = true + editor.events.call(jeQuit) + check("ctrl+d"): + if editor.content.getContent() == "": + editor.finished = true + editor.events.call(jeQuit) + +proc defLog(editor: LineEditor) = + echo editor.lastKeystroke + +proc populateDefaults*(editor: LineEditor) = + editor.bindEvent(jeKeypress): + editor.defInsert() + editor.defControl() + diff --git a/editor.nim b/editor.nim new file mode 100644 index 0000000..e1c970e --- /dev/null +++ b/editor.nim @@ -0,0 +1,115 @@ +import strformat +import strutils +import tables +import terminal + +import line +import multiline +import keycodes +import event +import renderer + +type + JaleEvent* = enum + jeKeypress, jeQuit + + LineEditor* = ref object + content*: Multiline + historyIndex*: int + history: seq[Multiline] + keystrokes*: Event[int] + events*: Event[JaleEvent] + prompt*: string + lastKeystroke*: int + finished*: bool + rendered: int # how many lines were printed last full refresh + +proc newLineEditor*: LineEditor = + new(result) + result.content = newMultiline() + result.history = @[] + result.history.add(result.content) + result.historyIndex = 0 + result.keystrokes.new() + result.events.new() + result.prompt = "" + result.rendered = 0 + +proc render(editor: LineEditor, line: int = -1, hscroll: bool = true) = + var y = line + if y == -1: + y = editor.content.Y + + renderLine( + ( + if y == 0: + editor.prompt + else: + " ".repeat(editor.prompt.len()) + ), + editor.content.getLine(y), + 0 + ) + + +proc fullRender(editor: LineEditor) = + # from the current (proper) cursor pos, it draws the entire multiline prompt, then + # moves cursor to current x,y + for i in countup(0, editor.content.high()): + editor.render(i, false) +# if i <= editor.rendered: + cursorDown(1) +# else: +# write stdout, "\n" +# inc editor.rendered + +proc restore(editor: LineEditor) = + # from the line that's represented as y=0 it moves the cursor to editor.y + # if it's at the bottom, it also scrolls enough + # it's achieved by the right number of newlines + # does not restore editor.x + + write stdout, "\n".repeat(editor.content.len()) + cursorUp(editor.content.len()) + editor.rendered = editor.content.len() + + if editor.content.Y == 0: + return + cursorDown(editor.content.Y) + +proc moveCursorToEnd(editor: LineEditor) = + # only called when read finished + cursorDown(editor.content.high() - editor.content.Y) + write stdout, "\n" + +proc moveCursorToStart(editor: LineEditor, delta: int = 0) = + if delta > 0: + cursorUp(delta) + +proc restoreCursor(editor: LineEditor) = + if editor.content.Y > 0: + cursorDown(editor.content.Y) + +proc read*(editor: LineEditor): string = +# write stdout, "\n" + editor.restore() + editor.fullRender() + editor.restoreCursor() + while not editor.finished: + editor.render() + setCursorXPos(editor.content.X + editor.prompt.len()) + let key = getKey() + let preY = editor.content.Y + editor.lastKeystroke = key + editor.keystrokes.call(key) + editor.events.call(jeKeypress) + if preY != editor.content.Y: + # redraw everything because y changed + editor.moveCursorToStart(preY) + editor.fullRender() + editor.restoreCursor() + + editor.finished = false + editor.moveCursorToEnd() + + return editor.content.getContent() diff --git a/event.nim b/event.nim new file mode 100644 index 0000000..927d7d5 --- /dev/null +++ b/event.nim @@ -0,0 +1,17 @@ +# event.nim + +import tables + +type + Event*[T] = TableRef[T, seq[proc ()]] + +proc call*[T](evt: Event[T], key: T) = + if evt.hasKey(key): + for callback in evt[key]: + callback() + +proc add*[T](event: Event[T], key: T, callback: proc) = + if not event.hasKey(key): + event[key] = @[] + event[key].add(callback) + diff --git a/keycodes.nim b/keycodes.nim new file mode 100644 index 0000000..af00c59 --- /dev/null +++ b/keycodes.nim @@ -0,0 +1,85 @@ +# keycodes.nim + +import tables +import strutils +import terminal + +type + JaleKeycode* = enum + jkStart = 255 # jale keycodes exported start one above jkStart + jkLeft = 256, jkRight, jkUp, jkDown, jkHome, jkEnd, jkDelete, jkBackspace, + jkInsert, jkEnter + + 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" + + 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) = + var result = 0 + for key in keys: + result *= 256 + result += key + escapeSeqs[result] = id + +block: + when defined(windows): + defEscSeq(@[224], jkContinue) + defEscSeq(@[224, 72], jkUp) + defEscSeq(@[224, 80], jkDown) + defEscSeq(@[224, 77], jkRight) + defEscSeq(@[224, 75], jkLeft) + defEscSeq(@[224, 71], jkHome) + defEscSeq(@[224, 79], jkEnd) + defEscSeq(@[224, 82], jkInsert) + defEscSeq(@[224, 83], jkDelete) + # TODO: finish defining escape sequences + else: + defEscSeq(@[27], jkContinue) + defEscSeq(@[27, 91], jkContinue) + defEscSeq(@[27, 91, 65], jkUp) + defEscSeq(@[27, 91, 66], jkDown) + defEscSeq(@[27, 91, 67], jkRight) + defEscSeq(@[27, 91, 68], jkLeft) + defEscSeq(@[27, 91, 72], jkHome) + defEscSeq(@[27, 91, 70], jkEnd) + defEscSeq(@[27, 91, 51], jkContinue) + defEscSeq(@[27, 91, 50], jkContinue) + defEscSeq(@[27, 91, 51, 126], jkDelete) + defEscSeq(@[27, 91, 50, 126], jkInsert) + defEscSeq(@[13], jkEnter) + defEscSeq(@[127], jkBackspace) + +proc getKey*: int = + var key: int = 0 + while true: + key *= 256 + key += int(getch()) + if escapeSeqs.hasKey(key): + if escapeSeqs[key] != jkContinue: + key = int(escapeSeqs[key]) + break + else: + break + return key diff --git a/line.nim b/line.nim new file mode 100644 index 0000000..aabec3f --- /dev/null +++ b/line.nim @@ -0,0 +1,40 @@ +# line.nim + +import strformat + +type + Line* = ref object + content: string + +# getters/setters + +proc content*(l: Line): string = + l.content + +proc `content=`*(l: Line, str: string) = + l.content = str + +# constructor + +proc newLine*: Line = + Line(content: "") + +# methods + +proc insert*(line: Line, str: string, pos: int) = + if pos > line.content.high(): + line.content &= str + elif pos == 0: + line.content = str & line.content + else: + line.content = line.content[0..pos-1] & str & line.content[pos..line.content.high()] + +proc delete*(line: Line, start: int, finish: int) = + if start > finish or start < 0 or finish > line.content.high(): + raise newException(CatchableError, &"Invalid arguments for Line.delete: start {start}, finish {finish} for line of length {line.content.len()}") + var result = "" + if start > 0: + result &= line.content[0..start-1] + if finish < line.content.high(): + result &= line.content[finish+1..line.content.high()] + line.content = result diff --git a/main.nim b/main.nim new file mode 100644 index 0000000..c9fd0ab --- /dev/null +++ b/main.nim @@ -0,0 +1,32 @@ +import defaults +import tables +import editor +import strutils +import templates +import multiline +import event + +var keep = true + +let e = newLineEditor() + +e.bindEvent(jeQuit): + keep = false + +e.bindKey('a'): + echo "a has been pressed" + +e.bindKey("ctrl+b"): + echo "ctrl+b has been pressed" + +e.prompt = "> " +e.populateDefaults() +while keep: + let input = e.read() + if input.contains("quit"): + break + else: + echo "===" + echo input + echo "===" + diff --git a/multiline.nim b/multiline.nim new file mode 100644 index 0000000..733b04f --- /dev/null +++ b/multiline.nim @@ -0,0 +1,107 @@ +# multiline.nim + +import line + +type + Multiline* = ref object + lines: seq[Line] + x,y: int + +# getters/setters + +proc X*(ml: Multiline): int = + ml.x + +proc Y*(ml: Multiline): int = + ml.y + +# constructor + +proc newMultiline*: Multiline = + new(result) + result.lines = @[] + result.lines.add(newLine()) + result.x = 0 + result.y = 0 + +# methods + +proc lineLen*(ml: Multiline): int = + ml.lines[ml.y].content.len() + +proc lineHigh*(ml: Multiline): int = + ml.lineLen() - 1 + +proc len*(ml: Multiline): int = + ml.lines.len() + +proc high*(ml: Multiline): int = + ml.lines.high() + +proc left*(ml: Multiline) = + if ml.x > 0: + dec ml.x + +proc right*(ml: Multiline) = + if ml.x < ml.lineLen(): + inc ml.x + +proc up*(ml: Multiline) = + if ml.y > 0: + dec ml.y + if ml.x > ml.lineLen(): + ml.x = ml.lineLen() + +proc down*(ml: Multiline) = + if ml.y < ml.lines.high(): + inc ml.y + if ml.x > ml.lineLen(): + ml.x = ml.lineLen() + +proc insert*(ml: Multiline, str: string) = + ml.lines[ml.y].insert(str, ml.x) + ml.x += str.len() + +proc delete*(ml: Multiline) = + if ml.x < ml.lineLen(): + ml.lines[ml.y].delete(ml.x, ml.x) + +proc backspace*(ml: Multiline) = + if ml.x > 0: + ml.lines[ml.y].delete(ml.x - 1, ml.x - 1) + dec ml.x + +proc insertline*(ml: Multiline) = + # TODO split line support + if ml.y == ml.lines.high(): + ml.lines.add(newLine()) + else: + ml.lines.insert(newLine(), ml.y + 1) + inc ml.y + ml.x = 0 + + +proc clearline*(ml: Multiline) = + ml.lines[ml.y].content = "" + +proc removeline*(ml: Multiline) = + ml.lines.delete(ml.y) + if ml.lines.len() == 0: + ml.lines.add(newLine()) + if ml.y > ml.lines.high(): + dec ml.y + +proc getLine*(ml: Multiline, line: int = -1): string = + if line == -1: + ml.lines[ml.y].content + else: + if line >= 0 and line <= ml.lines.high(): + ml.lines[line].content + else: + "" + +proc getContent*(ml: Multiline): string = + for line in ml.lines: + result &= line.content & "\n" + result[0..result.high()-1] # cut finishing newline + diff --git a/renderer.nim b/renderer.nim new file mode 100644 index 0000000..e8c56b1 --- /dev/null +++ b/renderer.nim @@ -0,0 +1,21 @@ +# renderer.nim +# +# a terminal renderer for readline-like libraries + +import terminal +import strutils + +proc renderLine*(prompt: string, content: string, hscroll: int = 0) = + var content = prompt & content + if content.len() < terminalWidth(): + content &= " ".repeat(terminalWidth() - content.len()) + if content.len() > terminalWidth(): + var lower = hscroll + var upper = hscroll + terminalWidth() - 1 + if upper > content.high(): + upper = content.high() + content = content[lower..upper] + write stdout, "\r" & content + + + diff --git a/templates.nim b/templates.nim new file mode 100644 index 0000000..6717ed4 --- /dev/null +++ b/templates.nim @@ -0,0 +1,21 @@ +import editor +import event +import keycodes + +template bindKey*(editor: LineEditor, key: int, body: untyped) = + proc action {.gensym.} = + body + editor.keystrokes.add(key, action) + +template bindKey*(editor: LineEditor, key: char, body: untyped) = + editor.bindKey(int(key)): + body + +template bindKey*(editor: LineEditor, key: string, body: untyped) = + editor.bindKey(keysByName[key]): + body + +template bindEvent*(editor: LineEditor, event: JaleEvent, body: untyped) = + proc action {.gensym.} = + body + editor.events.add(event, action) diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..13b7f42 --- /dev/null +++ b/todo.md @@ -0,0 +1,20 @@ +Soon: + +Horizontal scrolling for the current line in render +Add new keycodes (enter, ctrl+h, ctrl+j?) +Add a way to hook for the quit event (ctrl+c or ctrl+d auto triggers in default) +Move arrow keys to per-key events/create template that hooks by name +create template that hooks any key + +Multiline editing: + +- when moving up/down render should re-render the line to reset horizontal scrolling + +Other stuff: + +- move codebase into multiple modules to ensure modularity (move away defaults and anything that can be submodules (keycodes) e.g.) +- tab completion into the defaults +- new events such as pre-terminate, pre-return, pre-render (filter) + + +Done: