about summary refs log tree commit diff stats
path: root/src/local/lineedit.nim
diff options
context:
space:
mode:
Diffstat (limited to 'src/local/lineedit.nim')
-rw-r--r--src/local/lineedit.nim328
1 files changed, 328 insertions, 0 deletions
diff --git a/src/local/lineedit.nim b/src/local/lineedit.nim
new file mode 100644
index 00000000..0f048586
--- /dev/null
+++ b/src/local/lineedit.nim
@@ -0,0 +1,328 @@
+import std/strutils
+import std/unicode
+
+import bindings/quickjs
+import js/javascript
+import types/cell
+import types/opt
+import types/winattrs
+import utils/strwidth
+import utils/twtstr
+
+import chagashi/charset
+import chagashi/validator
+import chagashi/decoder
+
+type
+  LineEditState* = enum
+    EDIT, FINISH, CANCEL
+
+  LineHistory* = ref object
+    lines: seq[string]
+
+  LineEdit* = ref object
+    news*: string
+    prompt: string
+    promptw: int
+    state*: LineEditState
+    escNext*: bool
+    cursorx: int # 0 ..< news.notwidth
+    cursori: int # 0 ..< news.len
+    shiftx: int # 0 ..< news.notwidth
+    shifti: int # 0 ..< news.len
+    padding: int # 0 or 1
+    maxwidth: int
+    disallowed: set[char]
+    hide: bool
+    hist: LineHistory
+    histindex: int
+    histtmp: string
+    invalid*: bool
+
+jsDestructor(LineEdit)
+
+func newLineHistory*(): LineHistory =
+  return LineHistory()
+
+# Note: capped at edit.maxwidth.
+func getDisplayWidth(edit: LineEdit): int =
+  var dispw = 0
+  var i = edit.shifti
+  var r: Rune
+  while i < edit.news.len and dispw < edit.maxwidth:
+    fastRuneAt(edit.news, i, r)
+    dispw += r.width()
+  return dispw
+
+proc shiftView(edit: LineEdit) =
+  # Shift view so it contains the cursor.
+  if edit.cursorx < edit.shiftx:
+    edit.shiftx = edit.cursorx
+    edit.shifti = edit.cursori
+  # Shift view so it is completely filled.
+  if edit.shiftx > 0:
+    let dispw = edit.getDisplayWidth()
+    if dispw < edit.maxwidth:
+      let targetx = edit.shiftx - edit.maxwidth + dispw
+      if targetx <= 0:
+        edit.shiftx = 0
+        edit.shifti = 0
+      else:
+        while edit.shiftx > targetx:
+          let (r, len) = edit.news.lastRune(edit.shifti - 1)
+          edit.shiftx -= r.width()
+          edit.shifti -= len
+  edit.padding = 0
+  # Shift view so it contains the cursor. (act 2)
+  if edit.shiftx < edit.cursorx - edit.maxwidth:
+    while edit.shiftx < edit.cursorx - edit.maxwidth and
+        edit.shifti < edit.news.len:
+      var r: Rune
+      fastRuneAt(edit.news, edit.shifti, r)
+      edit.shiftx += r.width()
+    if edit.shiftx > edit.cursorx - edit.maxwidth:
+      # skipped over a cell because of a double-width char
+      edit.padding = 1
+
+proc generateOutput*(edit: LineEdit): FixedGrid =
+  edit.shiftView()
+  # Make the output grid +1 cell wide, so it covers the whole input area.
+  result = newFixedGrid(edit.promptw + edit.maxwidth + 1)
+  var x = 0
+  for r in edit.prompt.runes:
+    result[x].str &= $r
+    x += r.width()
+    if x >= result.width: break
+  for i in 0 ..< edit.padding:
+    if x < result.width:
+      result[x].str = " "
+      inc x
+  var i = edit.shifti
+  while i < edit.news.len:
+    var r: Rune
+    fastRuneAt(edit.news, i, r)
+    if not edit.hide:
+      let w = r.width()
+      if x + w > result.width: break
+      if r.isControlChar():
+        result[x].str &= '^'
+        inc x
+        result[x].str &= char(r).getControlLetter()
+        inc x
+      else:
+        result[x].str &= $r
+        x += w
+    else:
+      if x + 1 > result.width: break
+      result[x].str &= '*'
+      inc x
+
+proc getCursorX*(edit: LineEdit): int =
+  return edit.promptw + edit.cursorx + edit.padding - edit.shiftx
+
+proc insertCharseq(edit: LineEdit, s: string) =
+  let s = if edit.escNext:
+    s
+  else:
+    deleteChars(s, edit.disallowed)
+  edit.escNext = false
+  if s.len == 0:
+    return
+  let rem = edit.news.substr(edit.cursori)
+  edit.news.setLen(edit.cursori)
+  edit.news &= s
+  edit.news &= rem
+  edit.cursori += s.len
+  edit.cursorx += s.notwidth()
+  edit.invalid = true
+
+proc cancel(edit: LineEdit) {.jsfunc.} =
+  edit.state = CANCEL
+
+proc submit(edit: LineEdit) {.jsfunc.} =
+  if edit.hist.lines.len == 0 or edit.news != edit.hist.lines[^1]:
+    edit.hist.lines.add(edit.news)
+  edit.state = FINISH
+
+proc backspace(edit: LineEdit) {.jsfunc.} =
+  if edit.cursori > 0:
+    let (r, len) = edit.news.lastRune(edit.cursori - 1)
+    edit.news.delete(edit.cursori - len .. edit.cursori - 1)
+    edit.cursori -= len
+    edit.cursorx -= r.width()
+    edit.invalid = true
+
+proc write*(edit: LineEdit, s: string, cs: Charset): bool =
+  if cs == CHARSET_UTF_8:
+    if s.validateUTF8Surr() != -1:
+      return false
+    edit.insertCharseq(s)
+  else:
+    let td = newTextDecoder(cs)
+    var success = false
+    let s = td.decodeAll(s, success)
+    if not success:
+      return false
+    edit.insertCharseq(s)
+  return true
+
+proc write(edit: LineEdit, s: string): bool {.jsfunc.} =
+  edit.write(s, CHARSET_UTF_8)
+
+proc delete(edit: LineEdit) {.jsfunc.} =
+  if edit.cursori < edit.news.len:
+    let len = edit.news.runeLenAt(edit.cursori)
+    edit.news.delete(edit.cursori ..< edit.cursori + len)
+    edit.invalid = true
+
+proc escape(edit: LineEdit) {.jsfunc.} =
+  edit.escNext = true
+
+proc clear(edit: LineEdit) {.jsfunc.} =
+  if edit.cursori > 0:
+    edit.news.delete(0..edit.cursori - 1)
+    edit.cursori = 0
+    edit.cursorx = 0
+    edit.invalid = true
+
+proc kill(edit: LineEdit) {.jsfunc.} =
+  if edit.cursori < edit.news.len:
+    edit.news.setLen(edit.cursori)
+    edit.invalid = true
+
+proc backward(edit: LineEdit) {.jsfunc.} =
+  if edit.cursori > 0:
+    let (r, len) = edit.news.lastRune(edit.cursori - 1)
+    edit.cursori -= len
+    edit.cursorx -= r.width()
+    if edit.cursorx < edit.shiftx:
+      edit.invalid = true
+
+proc forward(edit: LineEdit) {.jsfunc.} =
+  if edit.cursori < edit.news.len:
+    var r: Rune
+    fastRuneAt(edit.news, edit.cursori, r)
+    edit.cursorx += r.width()
+    if edit.cursorx >= edit.shiftx + edit.maxwidth:
+      edit.invalid = true
+
+proc prevWord(edit: LineEdit) {.jsfunc.} =
+  if edit.cursori == 0:
+    return
+  let (r, len) = edit.news.lastRune(edit.cursori - 1)
+  if r.breaksWord():
+    edit.cursori -= len
+    edit.cursorx -= r.width()
+  while edit.cursori > 0:
+    let (r, len) = edit.news.lastRune(edit.cursori - 1)
+    if r.breaksWord():
+      break
+    edit.cursori -= len
+    edit.cursorx -= r.width()
+  if edit.cursorx < edit.shiftx:
+    edit.invalid = true
+
+proc nextWord(edit: LineEdit) {.jsfunc.} =
+  if edit.cursori >= edit.news.len:
+    return
+  let oc = edit.cursori
+  var r: Rune
+  fastRuneAt(edit.news, edit.cursori, r)
+  if r.breaksWord():
+    edit.cursorx += r.width()
+  else:
+    edit.cursori = oc
+  while edit.cursori < edit.news.len:
+    let pc = edit.cursori
+    fastRuneAt(edit.news, edit.cursori, r)
+    if r.breaksWord():
+      edit.cursori = pc
+      break
+    edit.cursorx += r.width()
+  if edit.cursorx >= edit.shiftx + edit.maxwidth:
+    edit.invalid = true
+
+proc clearWord(edit: LineEdit) {.jsfunc.} =
+  let oc = edit.cursori
+  edit.prevWord()
+  if oc != edit.cursori:
+    edit.news.delete(edit.cursori .. oc - 1)
+    edit.invalid = true
+
+proc killWord(edit: LineEdit) {.jsfunc.} =
+  if edit.cursori >= edit.news.len:
+    return
+  let oc = edit.cursori
+  let ox = edit.cursorx
+  edit.nextWord()
+  if edit.cursori != oc:
+    if edit.cursori < edit.news.len:
+      let len = edit.news.runeLenAt(edit.cursori)
+      edit.news.delete(oc ..< edit.cursori + len)
+    else:
+      edit.news.delete(oc ..< edit.cursori)
+    edit.cursori = oc
+    edit.cursorx = ox
+    edit.invalid = true
+
+proc begin(edit: LineEdit) {.jsfunc.} =
+  edit.cursori = 0
+  edit.cursorx = 0
+  if edit.shiftx > 0:
+    edit.invalid = true
+
+proc `end`(edit: LineEdit) {.jsfunc.} =
+  if edit.cursori < edit.news.len:
+    edit.cursori = edit.news.len
+    edit.cursorx = edit.news.notwidth()
+    if edit.cursorx >= edit.shiftx + edit.maxwidth:
+      edit.invalid = true
+
+proc prevHist(edit: LineEdit) {.jsfunc.} =
+  if edit.histindex > 0:
+    if edit.news.len > 0:
+      edit.histtmp = $edit.news
+    dec edit.histindex
+    edit.news = edit.hist.lines[edit.histindex]
+    # The begin call is needed so the cursor doesn't get lost outside
+    # the string.
+    edit.begin()
+    edit.end()
+    edit.invalid = true
+
+proc nextHist(edit: LineEdit) {.jsfunc.} =
+  if edit.histindex + 1 < edit.hist.lines.len:
+    inc edit.histindex
+    edit.news = edit.hist.lines[edit.histindex]
+    edit.begin()
+    edit.end()
+    edit.invalid = true
+  elif edit.histindex < edit.hist.lines.len:
+    inc edit.histindex
+    edit.news = edit.histtmp
+    edit.begin()
+    edit.end()
+    edit.histtmp = ""
+
+proc windowChange*(edit: LineEdit, attrs: WindowAttributes) =
+  edit.maxwidth = attrs.width - edit.promptw - 1
+
+proc readLine*(prompt: string, termwidth: int, current = "",
+    disallowed: set[char] = {}, hide = false, hist: LineHistory): LineEdit =
+  result = LineEdit(
+    prompt: prompt,
+    promptw: prompt.width(),
+    news: current,
+    disallowed: disallowed,
+    hide: hide,
+    invalid: true
+  )
+  result.cursori = result.news.len
+  result.cursorx = result.news.notwidth()
+  # - 1, so that the cursor always has place
+  result.maxwidth = termwidth - result.promptw - 1
+  result.hist = hist
+  result.histindex = result.hist.lines.len
+
+proc addLineEditModule*(ctx: JSContext) =
+  ctx.registerType(LineEdit)