version 0.2

This commit is contained in:
prod2 2022-01-20 22:45:05 +01:00
commit d9ed102638
28 changed files with 1601 additions and 0 deletions

40
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -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.

View File

@ -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.

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
examples/editor
examples/interactive_basic
examples/interactive_history
examples/getkey
# Ignore all
tests/*
# Unignore all with extensions
!tests/*.*

21
LICENSE Normal file
View File

@ -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.

25
NOTICE.md Normal file
View File

@ -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.

61
README.md Normal file
View File

@ -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 <filename>
# or windows:
.\examples\editor.exe <filename>
```
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

33
examples/editor.nim Normal file
View File

@ -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)

28
examples/getkey.nim Normal file
View File

@ -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

View File

@ -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") & ">"

View File

@ -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") & ">"

22
jale.nimble Normal file
View File

@ -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

85
platforms.md Normal file
View File

@ -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

1
src/jale.nim Normal file
View File

@ -0,0 +1 @@
const jaleVersion = "0.2.0"

84
src/jale/defaults.nim Normal file
View File

@ -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()

118
src/jale/editor.nim Normal file
View File

@ -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()

View File

@ -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

172
src/jale/keycodes.nim Normal file
View File

@ -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

View File

@ -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

144
src/jale/renderer.nim Normal file
View File

@ -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()

54
src/jale/types/event.nim Normal file
View File

@ -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))

101
src/jale/types/history.nim Normal file
View File

@ -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

109
src/jale/types/line.nim Normal file
View File

@ -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

View File

@ -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)

26
src/jale/uniterm.nim Normal file
View File

@ -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: "<conio.h>", importc: "_putch".}
proc getchr(): cint {.header: "<conio.h>", 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

34
src/jale/utf8.nim Normal file
View File

@ -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

1
tests/config.nims Normal file
View File

@ -0,0 +1 @@
switch("path", "$projectDir/../src")

11
tests/test1.nim Normal file
View File

@ -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

106
tests/testevent.nim Normal file
View File

@ -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