commit d9ed102638cd15a6478528d55d80969be9233a28 Author: prod2 <95874442+prod2@users.noreply.github.com> Date: Thu Jan 20 22:45:05 2022 +0100 version 0.2 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b6f2aa3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,40 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** + +A clear and concise description of what the bug is. + +**To Reproduce** + +Library bug? + +Please attach code to reproduce. + +Bug is an example? + +1. In example '...' +2. Do '...' +3. Expect '...' to happen but +4. '...' happens + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** +- OS: [e.g. iOS] +- Jale version (github commit hash) +- Terminal emulator: [e.g. xterm or cmd.exe] +- Possible interfering conditions [e.g. it's over ssh or inside tmux] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..11fc491 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..812fc77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +examples/editor +examples/interactive_basic +examples/interactive_history +examples/getkey + +# Ignore all +tests/* +# Unignore all with extensions +!tests/*.* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fbcfc80 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Productive2 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..10ed51f --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,25 @@ +# Sources of inspiration + +https://github.com/h3rald/nimline + +MIT License + +Copyright (c) 2017 Fabio Cevasco + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6cbee6 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Just Another Line Editor + +or jale.nim + +# Note + +This is a new (and very immature) alternative to other +line editors such as linenoise (see rdstdin in the nim +standard library) or nimline (https://github.com/h3rald/nimline). Currently you probably want to use either of +those because this is a wip. + +# Installation + +``` +git clone https://github.com/japl-lang/jale +cd jale +nimble install +``` + +# Checking the examples out + +Building the examples + +``` +nimble examples +``` + +Checking the sample editor out. Quit with ctrl+c, save with ctrl+s. + +``` +examples/editor +# or windows: +.\examples\editor.exe +``` + +Checking the interactive prompt out. Move between lines using ctrl+up/down. Create new lines with ctrl+down on the last line. ctrl+page up/down also works. + +``` +examples/interactive_history +# or windows: +.\examples\interactive_history.exe +``` + +# Features + +- multiline support +- easily add new keybindings (using templates) +- very customizable (even inserting characters is a keybinding that's optional) +- plugin system based +- history +- horizontal scrolling + +# Missing features + +Note: they won't be missing forever hopefully. + +- No utf-8 +- No tab autocompletion support +- No syntax highlighting support +- Windows output still really unstable/untested in depth + diff --git a/examples/editor.nim b/examples/editor.nim new file mode 100644 index 0000000..38e52ae --- /dev/null +++ b/examples/editor.nim @@ -0,0 +1,33 @@ +import jale/editor +import jale/types/multiline +import jale/defaults +import jale/types/event +import jale/keycodes +import tables + +import terminal +import strutils +import os + +eraseScreen() +setCursorPos(stdout, 0,0) + +let e = newMultilineEditor() + +if paramCount() > 0: + let arg = paramStr(1) + if fileExists(arg): + e.content = readFile(arg).fromString() + +var save = false +e.evtTable.subscribe(jeKeypress): + if args[0].intVal == keysByName["ctrl+s"]: + e.finish() + save = true + + +e.horizontalScrollMode = hsbAllScroll +let result = e.read() +if save and paramCount() > 0: + writeFile(paramStr(1), result) + diff --git a/examples/getkey.nim b/examples/getkey.nim new file mode 100644 index 0000000..87d744f --- /dev/null +++ b/examples/getkey.nim @@ -0,0 +1,28 @@ +# handy tool that prints out char codes when pressed + +import jale/uniterm +import jale/keycodes +import strutils +import terminal +import os +import tables + +echo "Press 'ctrl+c' to quit" +echo "Press 'ctrl+a' to print a horizontal bar" + +var escape = false +if paramCount() > 0 and paramStr(1) == "esc": + escape = true + +while true: + + let key = if escape: getKey() else: int(uniGetchr()) + if key == 3: + break + if key == 1: + echo "=".repeat(terminalWidth()) + else: + if keysById.hasKey(key): + echo keysById[key] + else: + echo key diff --git a/examples/interactive_basic.nim b/examples/interactive_basic.nim new file mode 100644 index 0000000..ebeb8de --- /dev/null +++ b/examples/interactive_basic.nim @@ -0,0 +1,17 @@ +import jale/defaults +import jale/editor +import jale/renderer +import jale/types/event + +import strutils + +var keep = true +let e = newSimpleLineEditor() + +e.evtTable.subscribe(jeQuit): + keep = false + +e.prompt = "> " +while keep: + let input = e.read() + echo "output:<" & input.replace("\n", "\\n") & ">" diff --git a/examples/interactive_history.nim b/examples/interactive_history.nim new file mode 100644 index 0000000..4bead04 --- /dev/null +++ b/examples/interactive_history.nim @@ -0,0 +1,19 @@ +import jale/editor +import jale/types/event +import jale/defaults + +import strutils + +var keep = true + +let e = newHistoryEditor() + +e.evtTable.subscribe(jeQuit): + keep = false + +e.prompt = "> " + +while keep: + let input = e.read() + echo "output:<" & input.replace("\n", "\\n") & ">" + diff --git a/jale.nimble b/jale.nimble new file mode 100644 index 0000000..a63f79d --- /dev/null +++ b/jale.nimble @@ -0,0 +1,22 @@ +# Package + +version = "0.2" +author = "Productive2" +description = "Just Another Line Editor" +license = "MIT" +srcDir = "src" + +# Dependencies + +requires "nim >= 1.0.0" + +import os +task examples, "Builds examples": + for kind, path in walkDir("examples/"): + if path.splitFile().ext == ".nim": + let (oup, exitcode) = gorgeEx "nim c " & path + if exitcode != 0: + echo "Failed building example " & path + echo oup + else: + echo "Successfully built example " & path diff --git a/platforms.md b/platforms.md new file mode 100644 index 0000000..9caa1cd --- /dev/null +++ b/platforms.md @@ -0,0 +1,85 @@ + +# Platforms + +Reading vt100 docs/relying on nim's terminal is not enough, it's best to +test everything out there. Different terminal emulators, a few different OS'es +or distros and also windows specific emulators. + +Unless otherwise specified, everything falls back to Artix linux (basically the same versions of software as arch), x86_64, nim 1.4.2. + +| Terminal | Last tested and worked | +| :-------- | :------------ | +| xfce terminal | passed on 26.2.2021 | +| konsole | passed on 26.2.2021 | +| alacritty | passed on 26.2.2021 | +| xterm | passed on 26.2.2021 | +| termite | passed on 26.2.2021 | +| urxvt | passed on 26.2.2021 | +| win 10 + cmd.exe | 26.2.2021: see notes | +| win 10 + powershell | 26.2.2021: see notes | +| xfce terminal + ssh | passed on 26.2.2021 | +| xfce terminal + tmux | 26.2.2021: see notes | +| tty | 26.2.2021: see notes | +| freebsd xterm | passed on 19.2.2021 | +| freebsd xfce term | passed on 19.2.2021 | +| debian xterm | passed on 19.2.2021 | +| debian qterminal | passed on 19.2.2021 | +| debian kitty | passed on 19.2.2021 | +| nim 1.0.0 | passed on 26.2.2021 | + +Info about testing dates: + +| Testing date | commit | +| :----------- | :-------- | +| 19.2.2021 | 12c7c28714508e7a1c16bcd7b3fa1372c4a19ae2 | +| 26.2.2021 | d4d2f52ec13a3c5cfea2cdce2d09777317de3545 | + +## Notes on 26.2.2021 + +### Found issues + +- (minor, doesn't affect repls) examples/editor does not scroll to the +end right after opening when opening a file too large to fit in the screen. + +- (minor) examples/interactive_history when a history element is taller than the screen, +it can cause issues with the rendering of the next history element when scrolling +through history + +### tmux notes + +- ctrl+pageup and ctrl+page down do not create any input (not even for getch) +- otherwise pass + +### tty notes + +- ctrl+(arrow keys) does not create a distinct key + +### powershell notes + +- editor issues: +- on horizontal scroll conditions the line can overflow causing rendering bugs +- on vertical scroll + page up the first line could disappear (maybe only when first line is a horizontal scroll candidate) +- very slow experience, a lot of cursor jumping + +### cmd.exe notes + +- same issues as powershell + +# Testing procedure + +Platform +- [ ] Jale compiles? +examples/interactive_basic +- [ ] Entering single line input, backspace, delete +- [ ] entering new lines, deleting lines with backspace +- [ ] home/end/page up/page down +- [ ] Submitting output +examples/interactive_history +- [ ] Multiple multiline history events +examples/editor +- [ ] Clears the screen well +- [ ] Writing small files +- [ ] Reading small files +- [ ] horizontal scroll +- [ ] vertical scroll + diff --git a/src/jale.nim b/src/jale.nim new file mode 100644 index 0000000..3145029 --- /dev/null +++ b/src/jale.nim @@ -0,0 +1 @@ +const jaleVersion = "0.2.0" diff --git a/src/jale/defaults.nim b/src/jale/defaults.nim new file mode 100644 index 0000000..1fb0f8c --- /dev/null +++ b/src/jale/defaults.nim @@ -0,0 +1,84 @@ +# defaults.nim + +# creates a LineEditor and binds a terminal renderer and many default keys + +import editor +import keycodes +import types/multiline +import types/event +import tables +import renderer +import editor_history + +proc defaultBindings*(le: LineEditor, enterSubmits = true, ctrlForVerticalMove = true) = + + le.evtTable.subscribe(jeKeypress): + let key = args[0].intVal + + template bindKey(k: int, body: untyped) = + if key == k: + body + + template bindKey(k: string, body: untyped) = + if key == keysByName[k]: + body + + if key > 31 and key < 127: + let ch = char(key) + le.content.insert($ch) + + bindKey("ctrl+c"): + le.quit() + + bindKey("ctrl+d"): + if le.content.getContent() == "": + le.quit() + + bindKey("left"): + le.content.left() + bindKey("right"): + le.content.right() + if ctrlForVerticalMove: + bindKey("ctrlup"): + le.content.up() + bindKey("ctrldown"): + if le.content.Y() == le.content.high(): + le.content.insertline() + le.content.down() + else: + bindKey("up"): + le.content.up() + bindKey("down"): + le.content.down() + bindKey("pageup"): + le.content.vhome() + bindKey("pagedown"): + le.content.vend() + bindKey("home"): + le.content.home() + bindKey("end"): + le.content.`end`() + bindKey("backspace"): + le.content.backspace() + bindKey("delete"): + le.content.delete() + if enterSubmits: + bindKey("enter"): + le.finish() + else: + bindKey("enter"): + le.content.enter() + +proc newSimpleLineEditor*: LineEditor = + result = newLineEditor() + result.defaultBindings() + result.bindRenderer() + +proc newMultilineEditor*: LineEditor = + result = newLineEditor() + result.defaultBindings(false, false) + result.bindRenderer() + +proc newHistoryEditor*: LineEditor = + result = newSimpleLineEditor() + result.bindHistory() diff --git a/src/jale/editor.nim b/src/jale/editor.nim new file mode 100644 index 0000000..cbab571 --- /dev/null +++ b/src/jale/editor.nim @@ -0,0 +1,118 @@ +# editor.nim + +import types/event +import types/multiline +import terminal +import keycodes +import sequtils + +when defined(posix): + import posix + +type + JaleState = enum + jsInactive, jsActive, jsFinishing, jsQuitting + + + HorizontalScrollBehavior* = enum + hsbSingleScroll, hsbAllScroll, hsbWrap + + LineEditorObj* = object + # permanent across reads + evtTable*: EventTable + prompt*: string + horizontalScrollMode*: HorizontalScrollBehavior + + # change across every read() call + content*: Multiline + lastKeystroke*: int # TODO potentially deprecate + state: JaleState + hscroll*: int + vscroll*: int + vmax: int # maximum amount of vertical screen space + + # TODO remove the need for these through events + rendered*: int # how many lines were printed last full refresh + forceRedraw*: bool + + LineEditor* = ref LineEditorObj + +# weak list of references +var editors: seq[ptr LineEditorObj] + +# constructors and related code + +proc reset*(le: LineEditor) = + # called when read() is over + le.state = jsInactive + le.rendered = 0 # TODO deprecate + le.content = newMultiline() + le.lastKeystroke = -1 # TODO deprecate + le.forceRedraw = false # TODO deprecate + + le.hscroll = 0 + le.vscroll = 0 + +proc finalizer (inst: LineEditor) = + editors = editors.filterIt(cast[ptr LineEditorObj](inst) != it) + +proc newLineEditor*: LineEditor = + new(result, finalizer) + result.reset() + result.evtTable = newEventTable() + result.prompt = "" + result.horizontalScrollMode = hsbSingleScroll + result.vmax = 0 + editors.add(cast[ptr LineEditorObj](result)) + +# resize event firing +when defined(posix): + onSignal(28): + for ed in editors: + discard ed.evtTable.callRaw(jeResize, @[]) + +# getters/setters + +proc getVmax*(le: LineEditor): int = + if le.vmax <= 0: + terminalHeight() + le.vmax + else: + le.vmax + +proc setVmax*(le: LineEditor, val: int) = + le.vmax = val + + +# methods + +proc unfinish*(le: LineEditor) = + le.state = jsActive + +proc finish*(le: LineEditor) = + le.state = jsFinishing + discard le.evtTable.callRaw(jeFinish, @[]) + +proc quit*(le: LineEditor) = + le.state = jsQuitting + discard le.evtTable.callRaw(jeQuit, @[]) + +# TODO: deprecate +proc redraw*(le: LineEditor) = + le.forceRedraw = true + +proc read*(le: LineEditor): string = + le.state = jsActive + discard le.evtTable.callRaw(jePreRead, @[]) + while le.state == jsActive: + + discard le.evtTable.callRaw(jePreKeypress, @[]) + let key = getKey() + le.lastKeystroke = key # TODO deprecate + discard le.evtTable.callRaw(jeKeypress, @[JaleObject(kind: jkInt, intVal: key)]) + discard le.evtTable.callRaw(jePostKeypress, @[]) + + discard le.evtTable.callRaw(jePostRead, @[]) + + if le.state == jsFinishing: + result = le.content.getContent() + le.reset() diff --git a/src/jale/editor_history.nim b/src/jale/editor_history.nim new file mode 100644 index 0000000..f6500e1 --- /dev/null +++ b/src/jale/editor_history.nim @@ -0,0 +1,59 @@ +import types/history +import editor +import types/event +import keycodes +import tables + +import options + +proc bindHistory*(le: LineEditor, useControl: bool = false) = + # adds hooks to events + # after reading finished, it adds to history + # before reading, it adds the temporary input to the history + let hist = newHistory() + + le.evtTable.subscribe(jeFinish): + hist.clean() + hist.newEntry(le.content) + + le.evtTable.subscribe(jePreRead): + hist.newEntry(le.content, temp = true) + discard hist.toEnd() + + # Adds history keybindings to editor (up, down, pg up/down) + # Works with the history provided + # if useShift is true, then the up/down keys and page up/down + # will remain free, and shift+up/down and ctrl+pg up/down + # will be used + + # for sanity, NEVER bind both history and multiline to control + + let upkey = keysByName[if useControl: "controlup" else: "up"] + let downkey = keysByName[if useControl: "controldown" else: "down"] + let homekey = keysByName[if useControl: "ctrlpageup" else: "pageup"] + let endkey = keysByName[if useControl: "ctrlpagedown" else: "pagedown"] + + le.evtTable.subscribe(jeKeypress): + let key = args[0].intVal + if key == upkey: + let res = hist.delta(-1) + if res.isSome(): + le.content = res.get() + le.redraw() + elif key == downkey: + let res = hist.delta(1) + if res.isSome(): + le.content = res.get() + le.redraw() + elif key == homekey: + let res = hist.toStart() + if res.isSome(): + le.content = res.get() + le.redraw() + elif key == endkey: + let res = hist.toStart() + if res.isSome(): + le.content = res.get() + le.redraw() + else: + discard diff --git a/src/jale/keycodes.nim b/src/jale/keycodes.nim new file mode 100644 index 0000000..9ea8fcd --- /dev/null +++ b/src/jale/keycodes.nim @@ -0,0 +1,172 @@ +# keycodes.nim + +import tables +import strutils +import uniterm + +type + JaleKeycode* = enum + jkStart = 255 # jale keycodes exported start one above jkStart + # arrow keys + 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, + + 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[uint64, JaleKeycode] + +proc defEscSeq(keys: seq[int], id: JaleKeycode) = + var result: uint64 = 0'u64 + for key in keys: + result *= 256 + result += key.uint64 + if escapeSeqs.hasKey(result): + raise newException(Defect, "Duplicate escape sequence definition") + escapeSeqs[result] = id + +block: + when defined(windows): + defEscSeq(@[224], jkContinue) + + # arrow keys + defEscSeq(@[224, 72], jkUp) + defEscSeq(@[224, 80], jkDown) + defEscSeq(@[224, 77], jkRight) + defEscSeq(@[224, 75], jkLeft) + # ctrl+arrow keys + defEscSeq(@[224, 141], jkCtrlUp) + defEscSeq(@[224, 145], jkCtrlDown) + defEscSeq(@[224, 116], jkCtrlRight) + defEscSeq(@[224, 115], jkCtrlLeft) + # moves + defEscSeq(@[224, 71], jkHome) + defEscSeq(@[224, 79], jkEnd) + defEscSeq(@[224, 73], jkPageUp) + defEscSeq(@[224, 81], jkPageDown) + # ctrl+moves + defEscSeq(@[224, 134], jkCtrlPageUp) + defEscSeq(@[224, 118], jkCtrlPageDown) + defEscSeq(@[224, 119], jkCtrlHome) + defEscSeq(@[224, 117], jkCtrlEnd) + + # special keys + defEscSeq(@[8], jkBackspace) + defEscSeq(@[13], jkEnter) + defEscSeq(@[224, 82], jkInsert) + defEscSeq(@[224, 83], jkDelete) + else: + # arrow keys + 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) + + # ctrl+arrow keys + defEscSeq(@[27, 91, 49], jkContinue) + defEscSeq(@[27, 91, 49, 59], jkContinue) + defEscSeq(@[27, 91, 49, 59, 53], jkContinue) # ctrl + + defEscSeq(@[27, 91, 49, 59, 50], jkContinue) # shift + + defEscSeq(@[27, 91, 49, 59, 53, 65], jkCtrlUp) # ctrl + defEscSeq(@[27, 91, 49, 59, 53, 66], jkCtrlDown) # ctrl + defEscSeq(@[27, 91, 49, 59, 53, 67], jkCtrlRight) # ctrl + defEscSeq(@[27, 91, 49, 59, 53, 68], jkCtrlLeft) # ctrl + + # urxvt + defEscSeq(@[27, 79], jkContinue) + defEscSeq(@[27, 79, 97], jkCtrlUp) + defEscSeq(@[27, 79, 98], jkCtrlDown) + defEscSeq(@[27, 79, 99], jkCtrlRight) + defEscSeq(@[27, 79, 100], jkCtrlLeft) + + # other 4 move keys + defEscSeq(@[27, 91, 72], jkHome) + defEscSeq(@[27, 91, 70], jkEnd) + defEscSeq(@[27, 91, 54], jkContinue) + defEscSeq(@[27, 91, 53], jkContinue) + defEscSeq(@[27, 91, 53, 126], jkPageUp) + defEscSeq(@[27, 91, 54, 126], jkPageDown) + # alternative home/end for tty + defEscSeq(@[27, 91, 49, 126], jkHome) + defEscSeq(@[27, 91, 52], jkContinue) + defEscSeq(@[27, 91, 52, 126], jkEnd) + # urxvt + defEscSeq(@[27, 91, 55], jkContinue) + defEscSeq(@[27, 91, 56], jkContinue) + defEscSeq(@[27, 91, 55, 126], jkHome) + defEscSeq(@[27, 91, 56, 126], jkEnd) + + # ctrl + fancy keys like pgup, pgdown, home, end + defEscSeq(@[27, 91, 53, 59], jkContinue) + defEscSeq(@[27, 91, 53, 59, 53], jkContinue) + defEscSeq(@[27, 91, 53, 59, 53, 126], jkCtrlPageUp) + defEscSeq(@[27, 91, 54, 59], jkContinue) + defEscSeq(@[27, 91, 54, 59, 53], jkContinue) + defEscSeq(@[27, 91, 54, 59, 53, 126], jkCtrlPageDown) + + defEscSeq(@[27, 91, 49, 59, 53, 72], jkCtrlHome) + defEscSeq(@[27, 91, 49, 59, 53, 70], jkCtrlEnd) + + # urxvt + defEscSeq(@[27, 91, 53, 94], jkCtrlPageUp) + defEscSeq(@[27, 91, 54, 94], jkCtrlPageDown) + defEscSeq(@[27, 91, 55, 94], jkCtrlHome) + defEscSeq(@[27, 91, 56, 94], jkCtrlEnd) + + # other keys + 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) + defEscSeq(@[8], jkBackspace) + +proc getKey*: int = + var key: uint64 = 0 + while true: + key *= 256 + key += uniGetChr().uint64 + if escapeSeqs.hasKey(key): + if escapeSeqs[key] != jkContinue: + key = escapeSeqs[key].uint64 + break + else: + break + return key.int diff --git a/src/jale/plugins/viewprotector.nim b/src/jale/plugins/viewprotector.nim new file mode 100644 index 0000000..320d174 --- /dev/null +++ b/src/jale/plugins/viewprotector.nim @@ -0,0 +1,27 @@ +import ../editor +import ../templates + +import terminal + +type ViewBehavior* = enum + vbLines, vbLinesLimited, vbFullscreen + +proc setViewBehavior*(ed: LineEditor, behavior: ViewBehavior) = + case behavior: + of vbLines: + ed.bindEvent(jeResize): + ed.vmax = terminalHeight() - 1 + of vbLinesLimited: + discard + of vbFullscreen: + var resized = false + ed.bindEvent(jeResize): + ed.vmax = terminalHeight() - 1 + ed.redraw() + resized = true + ed.bindEvent(jePreFullRender): + if resized: + eraseScreen() + setCursorPos(0,0) + resized = false + diff --git a/src/jale/renderer.nim b/src/jale/renderer.nim new file mode 100644 index 0000000..70cb5c6 --- /dev/null +++ b/src/jale/renderer.nim @@ -0,0 +1,144 @@ +# renderer.nim +# +# listens to LineEditor events and updates the screen + +import editor +import types/event +import types/multiline +import strutils +import terminal + + +proc renderLine*(prompt: string, text: string, hscroll: int = 0) = + eraseLine() + setCursorXPos(0) + var lower = hscroll + var upper = hscroll + terminalWidth() - prompt.len() - 1 + if upper > text.high(): + upper = text.high() + if lower < -1: + raise newException(Defect, "negative hscroll submitted to renderLine") + if lower > text.high(): + write stdout, prompt + else: + let content = prompt & text[lower..upper] + write stdout, content + + +proc render(editor: LineEditor, line: int = -1) = + ## Assumes that the cursor is already on the right line then + ## proceeds to render the line-th line of the editor (if -1, will check + ## the y). + var y = line + if y == -1: + y = editor.content.Y + + # the prompt's length is assumed to be always padded + let prompt = if y == 0: editor.prompt else: " ".repeat(editor.prompt.len()) + let content = editor.content.getLine(y) + + if editor.horizontalScrollMode == hsbAllScroll or + (editor.horizontalScrollMode == hsbSingleScroll and y == editor.content.Y): + renderLine(prompt, content, editor.hscroll) + else: + renderLine(prompt, content, 0) + +proc fullRender(editor: LineEditor) = + # from the top cursor pos, it draws the entire multiline prompt, then + # moves cursor to current y + + #editor.events.call(jePreFullRender) + + let lastY = min(editor.content.high(), editor.vscroll + editor.getVmax() - 1) + for i in countup(editor.vscroll, lastY): + editor.render(i) + if i - editor.vscroll < editor.rendered: + cursorDown(1) + else: + write stdout, "\n" + inc editor.rendered + + let rendered = lastY - editor.vscroll + 1 + var extraup = 0 + while rendered < editor.rendered: + eraseLine() + cursorDown(1) + dec editor.rendered + inc extraup + + # return to the selected y pos + cursorUp(lastY + 1 - editor.content.Y + extraup) + +proc moveCursorToEnd(editor: LineEditor) = + # only called when read finished + if editor.content.high() > editor.content.Y: + cursorDown(editor.content.high() - editor.content.Y) + write stdout, "\n" + + + +proc bindRenderer*(le: LineEditor) = + + var + preY: int + preVScroll: int + + le.evtTable.subscribe(jePreRead): + le.fullRender() + + le.evtTable.subscribe(jePreKeypress): + setCursorXPos(le.content.X - le.hscroll + le.prompt.len()) + preY = le.content.Y + preVScroll = le.vscroll + + le.evtTable.subscribe(jeKeypress): + discard + + le.evtTable.subscribe(jePostKeypress): + # scrolling + # last X that can be rendered on the screen + let lastX = terminalWidth() - le.prompt.len() + le.hscroll - 1 + # first X to be rendered + let firstX = le.hscroll + # X index put in bounds + let boundX = min(max(firstX, le.content.X), lastX) + # if outside of bounds + if le.content.X != boundX: + # scroll to move it inside bounds + le.hscroll += le.content.X - boundX + # if all lines scroll horizontally, full redraw is neccessary + if le.horizontalScrollMode == hsbAllScroll: + le.redraw() + + # first Y to be rendered + let firstY = le.vscroll + # last Y to be (potentially) rendered + let lastY = le.vscroll + le.getVmax() - 1 + # Y index put into bounds + let boundY = min(max(firstY, le.content.Y), lastY) + # Y outside of bounds: + if le.content.Y != boundY: + # scroll vertically to move it inside bounds + le.vscroll += le.content.Y - boundY + # vertical scrolling always means full redraw + le.redraw() + + # actual redraw handling + if le.forceRedraw or preY != le.content.Y or preVScroll != le.vscroll: + # move to the top + if preY - preVScroll > 0: + cursorUp(preY - preVScroll) + # redraw everything + le.fullRender() + if le.forceRedraw: + le.forceRedraw = false + else: + # redraw a single line + le.render() + + le.evtTable.subscribe(jePostRead): + le.moveCursorToEnd() + + le.evtTable.subscribe(jeResize): + le.redraw() + diff --git a/src/jale/types/event.nim b/src/jale/types/event.nim new file mode 100644 index 0000000..a487e9e --- /dev/null +++ b/src/jale/types/event.nim @@ -0,0 +1,54 @@ +# event.nim + +# Using async is not needed, since the only events that happen are key presses +# and terminal resizes, while all other operations should complete reasonably fast. +# If you need to include jale in a larger async program, it is advised to read those +# yourself and feed them to jale (sort of reimplementing the editor main loop part). + +import tables + +type + JaleEvent* = enum + jeQuit, jeFinish, + jePreKeypress, jeKeypress, jePostKeypress, + jePreRead, jePostRead, + jeResize + + JaleKind* = enum + jkInt, jkChar, jkString, jkVoid + + JaleObject* = object + case kind*: JaleKind: + of jkInt: + intVal*: int + of jkChar: + charVal*: char + of jkString: + stringVal*: string + of jkVoid: + discard + + EventTable* = TableRef[JaleEvent, seq[proc (args: seq[JaleObject]): JaleObject]] + +proc newEventTable*: EventTable = + new(result) + +proc subscribeRaw*(evtTable: EventTable, event: JaleEvent, action: proc(args: seq[JaleObject]): JaleObject) = + + if not evtTable.hasKey(event): + evtTable[event] = @[] + evtTable[event].add(action) + +template subscribe*(evtTable: EventTable, event: JaleEvent, body: untyped) = + proc action (args {.inject.}: seq[JaleObject]): JaleObject {.gensym.} = + result = JaleObject(kind: jkVoid) + body + + evtTable.subscribeRaw(event, action) + + +proc callRaw*(evtTable: EventTable, event: JaleEvent, args: seq[JaleObject]): seq[JaleObject] = + result = @[] + if evtTable.hasKey(event): + for callback in evtTable[event]: + result.add(callback(args)) diff --git a/src/jale/types/history.nim b/src/jale/types/history.nim new file mode 100644 index 0000000..3a8abbd --- /dev/null +++ b/src/jale/types/history.nim @@ -0,0 +1,101 @@ +# history.nim + +import ../types/multiline + +import os +import options + +type HistoryElement* = ref object + original*: Multiline + current*: Multiline + temp: bool + +type History* = ref object + elements: seq[HistoryElement] + index: int + lowestTouchedIndex: int + +proc newHistory*: History = + new(result) + result.index = 0 + result.lowestTouchedIndex = 0 + result.elements = @[] + +template newIndex(h: History): Option[Multiline] = + if h.lowestTouchedIndex > h.index: + h.lowestTouchedIndex = h.index + + some(h.elements[h.index].current) + +proc delta*(h: History, amt: int): Option[Multiline] = + # move up/down in history and return reference to current + # also update lowest touched index + if h.elements.len() == 0: + return none[Multiline]() + + if h.index + amt <= 0: + h.index = 0 + elif h.index + amt >= h.elements.high(): + h.index = h.elements.high() + else: + h.index += amt + h.newIndex() + + +proc toEnd*(h: History): Option[Multiline] = + if h.elements.len() == 0: + return none[Multiline]() + h.index = h.elements.high() + h.newIndex() + +proc toStart*(h: History): Option[Multiline] = + if h.elements.len() == 0: + return none[Multiline]() + h.index = 0 + h.newIndex() + +proc clean*(h: History) = + # restore originals to current + # from lowest touched index to the top + + if h.lowestTouchedIndex <= h.elements.high(): + for i in countup(h.lowestTouchedIndex, h.elements.high()): + if h.elements[i].temp: + h.elements.delete(i) + else: + h.elements[i].current = h.elements[i].original.copy() + + h.lowestTouchedIndex = h.elements.len() + +proc newEntry*(h: History, ml: Multiline, temp: bool = false) = + if not temp: + h.elements.add(HistoryElement(original: ml, current: ml.copy(), temp: temp)) + else: + h.elements.add(HistoryElement(original: ml, current: ml, temp: temp)) + +proc save*(h: History, path: string) = + # discards currents and temps, only saves originals + if dirExists(path): + raise newException(CatchableError, "Attempt to save history to " & path & ", but a directory at that path exists") + + let file = open(path, fmWrite) + + for el in h.elements: + if el.temp: + continue + file.writeLine(el.original.serialize()) + + file.close() + +proc loadHistory*(path: string): History = + if not fileExists(path): + raise newException(CatchableError, "Attempt to read history from a non-existant file") + let file = open(path, fmRead) + + let h = newHistory() + var line: string + while readLine(file, line): + h.newEntry(line.deserialize()) + + file.close() + return h diff --git a/src/jale/types/line.nim b/src/jale/types/line.nim new file mode 100644 index 0000000..052a951 --- /dev/null +++ b/src/jale/types/line.nim @@ -0,0 +1,109 @@ +# line.nim +# +# supports multiple encodings +# all public methods shall work the same regardless of encoding +# +# mx/x are volatile, they are not expected to store information long term + +import strformat +import ../utf8 + +type + Line* = object + content: string + encoding: Encoding + length: int + x: int # real index + mx: int # mouse index (rune index) + +# getter + +proc content*(line: Line): string = + line.content + +proc X*(line: Line): int = + # line.X == line.len when at complete end + # line.X == line.high when at over the last char + # line.X == 0 when over the first char + line.mx + +# constructor + +proc newLine*: Line = + Line(content: "", encoding: ecUtf8, length: 0, x: 0, mx: 0) + +proc copy*(l: Line): Line = + Line(content: l.content, encoding: l.encoding, length: l.length, x: l.x, mx: l.mx) + +# methods + +proc insert*(line: var Line, str: string) = + # position: x + case line.encoding: + of ecUtf8: + line.length += str.runeLen + of ecSingle: + line.length += str.len + 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()] + + # TODO: x += str.len() + +proc delete(line: var 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 + + +proc backspace*(line: var Line) = + # TODO: position: x, move x + # from multiline: + if ml.x > 0: + ml.lines[ml.y].backspace() + elif ml.x == 0 and ml.y > 0: + let cut = ml.lines[ml.y].content + ml.lines.delete(ml.y) + dec ml.y + ml.x = ml.lineLen() + ml.lines[ml.y].insert(cut, ml.x) + # end "from multiline" + if position == 0: + return + +proc delete*(line: var Line) = + # TODO: position: x + +proc navigateToMx*(line: var Line, target: mx) = + +proc left*(line: var Line) = + +proc right*(line: var Line) = + +proc home*(line: var Line) = + +proc `end`*(line: var Line) = + + +#proc range*(line: var Line, start: int, finish: int): string = +# if start > finish or start < 0 or finish > line.content.high(): +# raise newException(CatchableError, &"Invalid arguments for Line.range: start {start}, finish {finish} for line of length {line.content.len()}") +# result = line.content[start..finish] + +proc len*(line: Line): int = + line.length + +proc high*(line: Line): int = + line.length - 1 + +proc clearLine*(line: var Line) = + line.content = "" + line.length = 0 diff --git a/src/jale/types/multiline.nim b/src/jale/types/multiline.nim new file mode 100644 index 0000000..9e34adf --- /dev/null +++ b/src/jale/types/multiline.nim @@ -0,0 +1,174 @@ +# multiline.nim + +import line + +import strutils + +type + Multiline* = ref object + lines: seq[Line] + y: int + +# getters/setters + +proc X*(ml: Multiline): int = + ml.lines[ml.y].X + +proc MX*(ml: Multiline): int = + ml.lines[ml.y].MX + +proc Y*(ml: Multiline): int = + ml.y + +# constructor + +proc newMultiline*(initEmpty: bool = true): Multiline = + new(result) + result.lines = @[] + if initEmpty: + result.lines.add(newLine()) + result.y = 0 + +proc copy*(ml: Multiline): Multiline = + new(result) + for l in ml.lines: + result.lines.add(l.copy()) + result.y = ml.y + +# methods + +proc lineLen*(ml: Multiline): int = + ml.lines[ml.y].len() + +proc lineHigh*(ml: Multiline): int = + ml.lines[ml.y].high() + +proc len*(ml: Multiline): int = + ml.lines.len() + +proc high*(ml: Multiline): int = + ml.lines.high() + +# internal setter +proc sety(ml: Multiline, target: int) = + ml.lines[target].navigateToMx(ml.lines[ml.y].MX) + ml.y = target + +# warning check before calling them if y lands in illegal territory +# these are unsafe! +proc decy(ml: Multiline) = + ml.sety(ml.y-1) + +proc incy(ml: Multiline) = + ml.sety(ml.y+1) + +# publically callable movement methods +proc left*(ml: Multiline) = + ml.lines[ml.y].left() + +proc right*(ml: Multiline) = + ml.lines[ml.y].right() + +proc up*(ml: Multiline) = + if ml.y > 0: + ml.decy + +proc down*(ml: Multiline) = + if ml.y < ml.lines.high(): + ml.incy + +proc home*(ml: Multiline) = + ml.lines[ml.y].home() + +proc `end`*(ml: Multiline) = + ml.lines[ml.y].`end`() + +proc vhome*(ml: Multiline) = + ml.sety(0) + +proc vend*(ml: Multiline) = + ml.sety(ml.high()) + +proc insert*(ml: Multiline, str: string) = + ml.lines[ml.y].insert(str) + +proc delete*(ml: Multiline) = + if ml.lines[ml.y].X < ml.lineLen(): + ml.lines[ml.y].delete() + else: + discard # TODO merge two lines + +proc backspace*(ml: Multiline) = + if ml.lines[ml.y].X > 0: + ml.lines[ml.y].backspace() + else: + discard # TODO merge two lines + +proc insertline*(ml: Multiline) = + # the default behaviour of command mode o + if ml.y == ml.lines.high(): + ml.lines.add(newLine()) + else: + ml.lines.insert(newLine(), ml.y + 1) + inc ml.y # this automatically moves x and mx to 0, since the newLine creates one with these coords + +proc enter*(ml: Multiline) = # TODO REDO THIS + # the default behaviour of enter in normie editors + if ml.lines[ml.y].X > ml.lineHigh(): + ml.insertline() # when end of line, it's just an insertline + else: + let cut = ml.lines[ml.y].range(ml.x, ml.lineHigh()) + ml.lines[ml.y].delete(ml.x, ml.lineHigh()) + ml.insertline() + ml.lines[ml.y].insert(cut, 0) + +proc clearline*(ml: Multiline) = + ml.lines[ml.y].clearLine() + +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 #TODO: preserve x + +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: + "" + +# without the extra args these work together to convert a multiline to a single +# line string. With extra args customizable +proc serialize*(ml: Multiline, sep: string = r"\n", replaceBS: bool = true): string = + # replaceBS = replace backslash + for line in ml.lines: + if replaceBS: + result &= line.content.replace(r"\", r"\\") & sep + else: + result &= line.content & sep + result[0..result.high() - sep.len()] + +proc deserialize*(str: string, sep: string = r"\n", replaceBS: bool = true): Multiline = + result = newMultiline(initEmpty = false) + for line in str.split(sep): + if replaceBS: + result.lines.add(newLine(line.replace(r"\\", r"\"))) + else: + result.lines.add(newLine(line)) + + result.y = result.high() + result.x = result.lineLen() + +proc fromString*(str: string): Multiline = + # simple load of string to multiline + deserialize(str, sep = "\n", replaceBS = false) + +proc getContent*(ml: Multiline): string = + # simple convert of multiline to string + ml.serialize(sep = "\n", replaceBS = false) + + diff --git a/src/jale/uniterm.nim b/src/jale/uniterm.nim new file mode 100644 index 0000000..4e85566 --- /dev/null +++ b/src/jale/uniterm.nim @@ -0,0 +1,26 @@ +## Universal (cross platform) terminal abstractions + +# source: https://github.com/h3rald/nimline/blob/master/nimline.nim +# lines 42-56 (modified) + +import terminal + +when defined(windows): + proc putchr(c: cint): cint {.discardable, header: "", importc: "_putch".} + proc getchr(): cint {.header: "", importc: "_getch".} + + proc uniPutChr*(c: char) = + ## Prints an ASCII character to stdout. + putchr(c.cint) + proc uniGetChr*: int = + ## Retrieves an ASCII character from stdin. + getchr().int + +else: + proc uniPutChr*(c: char) = + ## Prints an ASCII character to stdout. + stdout.write(c) + + proc uniGetChr*: int = + ## Retrieves an ASCII character from stdin. + return getch().int diff --git a/src/jale/utf8.nim b/src/jale/utf8.nim new file mode 100644 index 0000000..503915e --- /dev/null +++ b/src/jale/utf8.nim @@ -0,0 +1,34 @@ +type + Encoding* = enum + ecSingle, ecUtf8 + ByteType* = enum + btSingle, btContinuation, + btDouble = 2, btTriple = 3, btQuadruple = 4, +# btInvalid + +proc byteType*(c: char): ByteType = + let n = c.uint8 + if n < 0x80: + return btSingle + elif n < 0xc0: + return btContinuation + elif n >= 0xc2 and n < 0xe0: + # c0 and c1 are invalid utf8 bytes + return btDouble + elif n >= 0xe0 and n < 0xf0: + return btTriple + elif n >= 0xf0 and n < 0xf5: + return btQuadruple + else: + raise newException(Exception, "Invalid utf8 sequence") + #return btInvalid + + +proc runeLen*(s: string): int = + for c in s: + case c.byteType: + of btSingle, btDouble, btTriple, btQuadruple: + result.inc + of btContinuation: + discard + diff --git a/tests/config.nims b/tests/config.nims new file mode 100644 index 0000000..3bb69f8 --- /dev/null +++ b/tests/config.nims @@ -0,0 +1 @@ +switch("path", "$projectDir/../src") \ No newline at end of file diff --git a/tests/test1.nim b/tests/test1.nim new file mode 100644 index 0000000..cc350cc --- /dev/null +++ b/tests/test1.nim @@ -0,0 +1,11 @@ +# This is just an example to get you started. You may wish to put all of your +# tests into a single file, or separate them into multiple `test1`, `test2` +# etc. files (better names are recommended, just make sure the name starts with +# the letter 't'). +# +# To run these tests, simply execute `nimble test`. + +import unittest + +test "can test": + check true diff --git a/tests/testevent.nim b/tests/testevent.nim new file mode 100644 index 0000000..c903b39 --- /dev/null +++ b/tests/testevent.nim @@ -0,0 +1,106 @@ +import unittest + +import jale/event +import tables + +test "can add procs to tests": + let e = newEvent[int]() + proc sayHi = + echo "hi" + + proc sayHello = + echo "hello" + + e.add(1, sayHi) + e.add(2, sayHello) + + check e[1].len() == 1 + check e[2].len() == 1 + +test "can do closures with events": + let e = newEvent[int]() + var val = 4 + proc setVal = + val = 2 + + e.add(5, setVal) + + check val == 4 + e.call(1) + check val == 4 + e.call(5) + check val == 2 + +test "can remove from events": + let e = newEvent[int]() + var val = 10 + proc decVal = + dec val + + e.add(1, decVal) + e.add(2, decVal) + e.add(3, decVal) + + e.call(5) + check val == 10 + e.call(4) + check val == 10 + e.call(3) + check val == 9 + e.call(2) + check val == 8 + e.call(1) + check val == 7 + + check e.remove(1, decVal) == true + e.call(1) + check val == 7 + e.call(2) + check val == 6 + +test "can remove multiple at once": + let e = newEvent[char]() + var val = 8 + proc incVal = + inc val + + e.add('a', incVal) + e.add('a', incVal) + + e.call('a') + check val == 10 + check e.remove('a', incVal) == true + e.call('a') + check val == 10 + +test "can remove non existent keys without crashing": + let e = newEvent[uint8]() + proc nothing = + discard + check e.remove(5'u8, nothing) == false + +test "removal is selective for procs": + let e = newEvent[string]() + proc first = + echo "1" + proc second = + echo "2" + e.add("key", first) + e.add("key", second) + check e.remove("key", first) == true + check e["key"].len() == 1 + +test "purging": + let e = newEvent[int]() + proc nothing = + discard + + e.add(1, nothing) + e.add(1, nothing) + e.add(2, nothing) + e.add(1, nothing) + + check e[1].len() == 3 + e.purge(1) + check e[1].len() == 0 + check e[2].len() == 1