about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--res/config.toml41
-rw-r--r--src/bindings/quickjs.nim2
-rw-r--r--src/buffer/buffer.nim834
-rw-r--r--src/buffer/cell.nim (renamed from src/io/cell.nim)46
-rw-r--r--src/buffer/container.nim626
-rw-r--r--src/config/config.nim73
-rw-r--r--src/display/client.nim153
-rw-r--r--src/display/pager.nim796
-rw-r--r--src/io/buffer.nim1285
-rw-r--r--src/io/lineedit.nim396
-rw-r--r--src/io/loader.nim7
-rw-r--r--src/io/serialize.nim79
-rw-r--r--src/io/term.nim1
-rw-r--r--src/js/javascript.nim94
-rw-r--r--src/js/regex.nim25
-rw-r--r--src/main.nim7
-rw-r--r--src/render/renderdocument.nim2
-rw-r--r--src/render/rendertext.nim2
-rw-r--r--src/types/url.nim8
-rw-r--r--src/utils/twtstr.nim20
20 files changed, 2514 insertions, 1983 deletions
diff --git a/res/config.toml b/res/config.toml
index fb43d065..7d69d8dc 100644
--- a/res/config.toml
+++ b/res/config.toml
@@ -58,25 +58,30 @@ D = 'pager.discardBuffer()'
 '.' = 'pager.nextBuffer()'
 M-c = 'commandMode()'
 C = 'command()'
-'/' = 'pager.isearch()'
-'?' = 'pager.isearchBack()'
+'/' = 'pager.isearchForward()'
+'?' = 'pager.isearchBackward()'
 n = 'pager.searchNext()'
 N = 'pager.searchPrev()'
 
 [line]
-C-m = 'SUBMIT'
-C-j = 'SUBMIT'
-C-h = 'BACKSPACE'
-'C-?' = 'BACKSPACE'
-C-d = 'DELETE'
-C-c = 'CANCEL'
-M-b = 'PREV_WORD'
-M-f = 'NEXT_WORD'
-C-b = 'BACK'
-C-f = 'FORWARD'
-C-u = 'CLEAR'
-C-k = 'KILL'
-C-w = 'KILL_WORD'
-C-a = 'BEGIN'
-C-e = 'END'
-C-v = 'ESC'
+C-m = 'line.submit()'
+C-j = 'line.submit()'
+C-h = 'line.backspace()'
+'C-?' = 'line.backspace()'
+C-d = 'line.delete()'
+C-c = 'line.cancel()'
+M-b = 'line.prevWord()'
+M-f = 'line.nextWord()'
+C-b = 'line.backward()'
+C-f = 'line.forward()'
+C-u = 'line.clear()'
+C-_ = 'line.clear()'
+M-k = 'line.clear()'
+C-k = 'line.kill()'
+C-w = 'line.clearWord()'
+M-C-h = 'line.clearWord()'
+'M-C-?' = 'line.clearWord()'
+M-d = 'line.killWord()'
+C-a = 'line.begin()'
+C-e = 'line.end()'
+C-v = 'line.escape()'
diff --git a/src/bindings/quickjs.nim b/src/bindings/quickjs.nim
index 290cbeca..6decbf31 100644
--- a/src/bindings/quickjs.nim
+++ b/src/bindings/quickjs.nim
@@ -1,6 +1,6 @@
 import os
 
-const javascriptDirs = ["/usr", "/lib", "/usr/lib", "/usr/local/lib", "/usr/local"]
+const javascriptDirs = ["/usr/local/lib", "/usr/local", "/usr/lib", "/usr", "/lib"]
 const lib = (func(): string =
   when defined(posix):
     for dir in javascriptDirs:
diff --git a/src/buffer/buffer.nim b/src/buffer/buffer.nim
new file mode 100644
index 00000000..6c2654dd
--- /dev/null
+++ b/src/buffer/buffer.nim
@@ -0,0 +1,834 @@
+import macros
+import options
+import os
+import streams
+import tables
+import unicode
+
+when defined(posix):
+  import posix
+
+import buffer/cell
+import css/cascade
+import css/cssparser
+import css/mediaquery
+import css/sheet
+import css/stylednode
+import config/config
+import html/dom
+import html/tags
+import html/htmlparser
+import io/loader
+import io/process
+import io/request
+import io/serialize
+import io/socketstream
+import io/term
+import js/regex
+import layout/box
+import render/renderdocument
+import render/rendertext
+import types/color
+import types/url
+import utils/twtstr
+
+type
+  BufferCommand* = enum
+    LOAD, RENDER, DRAW_BUFFER, WINDOW_CHANGE, GOTO_ANCHOR, READ_SUCCESS,
+    READ_CANCELED, CLICK, FIND_NEXT_LINK, FIND_PREV_LINK, FIND_NEXT_MATCH,
+    FIND_PREV_MATCH, GET_SOURCE, GET_LINES, MOVE_CURSOR
+
+  ContainerCommand* = enum
+    SET_LINES, SET_NEEDS_AUTH, SET_CONTENT_TYPE, SET_REDIRECT, SET_TITLE,
+    SET_HOVER, READ_LINE, LOAD_DONE, ANCHOR_FOUND, ANCHOR_FAIL, JUMP, OPEN,
+    SOURCE_READY, RESHAPE
+
+  BufferSourceType* = enum
+    CLONE, LOAD_REQUEST, LOAD_PIPE
+
+  BufferSource* = object
+    location*: URL
+    contenttype*: Option[string] # override
+    case t*: BufferSourceType
+    of CLONE:
+      clonepid*: Pid
+    of LOAD_REQUEST:
+      request*: Request
+    of LOAD_PIPE:
+      fd*: FileHandle
+
+  BufferMatch* = object
+    success*: bool
+    x*: int
+    y*: int
+    str*: string
+
+  Buffer* = ref object
+    input: HTMLInputElement
+    contenttype: string
+    lines: FlexibleGrid
+    rendered: bool
+    bsource: BufferSource
+    width: int
+    height: int
+    attrs: TermAttributes
+    document: Document
+    viewport: Viewport
+    prevstyled: StyledNode
+    reshape: bool
+    nostatus: bool
+    location: Url
+    istream: Stream
+    pistream: Stream # for input pipe
+    postream: Stream # for output pipe
+    streamclosed: bool
+    source: string
+    prevnode: StyledNode
+    userstyle: CSSStylesheet
+    loader: FileLoader
+    config: Config
+
+macro writeCommand(buffer: Buffer, cmd: ContainerCommand, args: varargs[typed]) =
+  result = newStmtList()
+  result.add(quote do: `buffer`.postream.swrite(`cmd`))
+  for arg in args:
+    result.add(quote do: `buffer`.postream.swrite(`arg`))
+  result.add(quote do: `buffer`.postream.flush())
+
+func getLink(node: StyledNode): HTMLAnchorElement =
+  if node == nil:
+    return nil
+  if node.t == STYLED_ELEMENT and node.node != nil and Element(node.node).tagType == TAG_A:
+    return HTMLAnchorElement(node.node)
+  if node.node != nil:
+    return HTMLAnchorElement(node.node.findAncestor({TAG_A}))
+  #TODO ::before links?
+
+const ClickableElements = {
+  TAG_A, TAG_INPUT, TAG_OPTION
+}
+
+func getClickable(styledNode: StyledNode): Element =
+  if styledNode == nil or styledNode.node == nil:
+    return nil
+  if styledNode.t == STYLED_ELEMENT:
+    let element = Element(styledNode.node)
+    if element.tagType in ClickableElements:
+      return element
+  styledNode.node.findAncestor(ClickableElements)
+
+func getCursorClickable(buffer: Buffer, cursorx, cursory: int): Element =
+  let i = buffer.lines[cursory].findFormatN(cursorx) - 1
+  if i >= 0:
+    return buffer.lines[cursory].formats[i].node.getClickable()
+
+func cursorBytes(buffer: Buffer, y: int, cc: int): int =
+  let line = buffer.lines[y].str
+  var w = 0
+  var i = 0
+  while i < line.len and w < cc:
+    var r: Rune
+    fastRuneAt(line, i, r)
+    w += r.width()
+  return i
+
+func findNextLink(buffer: Buffer, cursorx, cursory: int): tuple[x, y: int] =
+  let line = buffer.lines[cursory]
+  var i = line.findFormatN(cursorx) - 1
+  var link: Element = nil
+  if i >= 0:
+    link = line.formats[i].node.getClickable()
+  inc i
+
+  while i < line.formats.len:
+    let format = line.formats[i]
+    let fl = format.node.getClickable()
+    if fl != nil and fl != link:
+      return (format.pos, cursory)
+    inc i
+
+  for y in (cursory + 1)..(buffer.lines.len - 1):
+    let line = buffer.lines[y]
+    i = 0
+    while i < line.formats.len:
+      let format = line.formats[i]
+      let fl = format.node.getClickable()
+      if fl != nil and fl != link:
+        return (format.pos, y)
+      inc i
+  return (-1, -1)
+
+func findPrevLink(buffer: Buffer, cursorx, cursory: int): tuple[x, y: int] =
+  let line = buffer.lines[cursory]
+  var i = line.findFormatN(cursorx) - 1
+  var link: Element = nil
+  if i >= 0:
+    link = line.formats[i].node.getClickable()
+  dec i
+
+  var ly = 0 #last y
+  var lx = 0 #last x
+  template link_beginning() =
+    #go to beginning of link
+    ly = y #last y
+    lx = format.pos #last x
+
+    #on the current line
+    let line = buffer.lines[y]
+    while i >= 0:
+      let format = line.formats[i]
+      let nl = format.node.getClickable()
+      if nl == fl:
+        lx = format.pos
+      dec i
+
+    #on previous lines
+    for iy in countdown(ly - 1, 0):
+      let line = buffer.lines[iy]
+      i = line.formats.len - 1
+      while i >= 0:
+        let format = line.formats[i]
+        let nl = format.node.getClickable()
+        if nl == fl:
+          ly = iy
+          lx = format.pos
+        dec i
+
+  while i >= 0:
+    let format = line.formats[i]
+    let fl = format.node.getClickable()
+    if fl != nil and fl != link:
+      let y = cursory
+      link_beginning
+      return (lx, ly)
+    dec i
+
+  for y in countdown(cursory - 1, 0):
+    let line = buffer.lines[y]
+    i = line.formats.len - 1
+    while i >= 0:
+      let format = line.formats[i]
+      let fl = format.node.getClickable()
+      if fl != nil and fl != link:
+        link_beginning
+        return (lx, ly)
+      dec i
+  return (-1, -1)
+
+proc findNextMatch(buffer: Buffer, regex: Regex, cursorx, cursory: int, wrap: bool): BufferMatch =
+  template return_if_match =
+    if res.success and res.captures.len > 0:
+      let cap = res.captures[0]
+      let x = buffer.lines[y].str.width(cap.s)
+      let str = buffer.lines[y].str.substr(cap.s, cap.e - 1)
+      return BufferMatch(success: true, x: x, y: y, str: str)
+  var y = cursory
+  let b = buffer.cursorBytes(y, cursorx)
+  let b2 = if buffer.lines[y].str.len > b: b + buffer.lines[y].str.runeLenAt(b) else: b
+  let res = regex.exec(buffer.lines[y].str, b2, buffer.lines[y].str.len)
+  return_if_match
+  inc y
+  while true:
+    if y > buffer.lines.high:
+      if wrap:
+        y = 0
+      else:
+        break
+    if y == cursory:
+      let res = regex.exec(buffer.lines[y].str, 0, b)
+      return_if_match
+      break
+    let res = regex.exec(buffer.lines[y].str)
+    return_if_match
+    inc y
+
+proc findPrevMatch(buffer: Buffer, regex: Regex, cursorx, cursory: int, wrap: bool): BufferMatch =
+  template return_if_match =
+    if res.success and res.captures.len > 0:
+      let cap = res.captures[^1]
+      let x = buffer.lines[y].str.width(cap.s)
+      let str = buffer.lines[y].str.substr(cap.s, cap.e - 1)
+      return BufferMatch(success: true, x: x, y: y, str: str)
+  var y = cursory
+  let b = buffer.cursorBytes(y, cursorx)
+  let b2 = if b > 0: b - buffer.lines[y].str.lastRune(b)[1] else: 0
+  let res = regex.exec(buffer.lines[y].str, 0, b2)
+  return_if_match
+  dec y
+  while true:
+    if y < 0:
+      if wrap:
+        y = buffer.lines.high
+      else:
+        break
+    if y == cursory:
+      let res = regex.exec(buffer.lines[y].str, b, buffer.lines[y].str.len)
+      return_if_match
+      break
+    let res = regex.exec(buffer.lines[y].str)
+    return_if_match
+    dec y
+
+proc gotoAnchor(buffer: Buffer) =
+  if buffer.document == nil: return
+  let anchor = buffer.document.getElementById(buffer.location.anchor)
+  if anchor == nil: return
+  for y in 0..<buffer.lines.len:
+    let line = buffer.lines[y]
+    var i = 0
+    while i < line.formats.len:
+      let format = line.formats[i]
+      if format.node != nil and anchor in format.node.node:
+        buffer.writeCommand(JUMP, format.pos, y)
+        return
+      inc i
+
+proc windowChange(buffer: Buffer) =
+  buffer.width = buffer.attrs.width - 1
+  buffer.height = buffer.attrs.height - 1
+  buffer.reshape = true
+
+proc updateHover(buffer: Buffer, cursorx, cursory: int) =
+  var thisnode: StyledNode
+  let i = buffer.lines[cursory].findFormatN(cursorx) - 1
+  if i >= 0:
+    thisnode = buffer.lines[cursory].formats[i].node
+  let prevnode = buffer.prevnode
+
+  if thisnode != prevnode and (thisnode == nil or prevnode == nil or thisnode.node != prevnode.node):
+    for styledNode in thisnode.branch:
+      if styledNode.t == STYLED_ELEMENT and styledNode.node != nil:
+        let elem = Element(styledNode.node)
+        if not elem.hover:
+          elem.hover = true
+          buffer.reshape = true
+
+    let link = thisnode.getLink()
+    if link != nil:
+      buffer.writeCommand(SET_HOVER, link.href)
+    else:
+      buffer.writeCommand(SET_HOVER, "")
+
+    for styledNode in prevnode.branch:
+      if styledNode.t == STYLED_ELEMENT and styledNode.node != nil:
+        let elem = Element(styledNode.node)
+        if elem.hover:
+          elem.hover = false
+          buffer.reshape = true
+
+  buffer.prevnode = thisnode
+
+proc loadResource(buffer: Buffer, document: Document, elem: HTMLLinkElement) =
+  let url = parseUrl(elem.href, document.location.some)
+  if url.isSome:
+    let url = url.get
+    if url.scheme == buffer.location.scheme:
+      let media = elem.media
+      if media != "":
+        let media = parseMediaQueryList(parseListOfComponentValues(newStringStream(media)))
+        if not media.applies(): return
+      let fs = buffer.loader.doRequest(newRequest(url))
+      if fs.body != nil and fs.contenttype == "text/css":
+        elem.sheet = parseStylesheet(fs.body)
+
+proc loadResources(buffer: Buffer, document: Document) =
+  var stack: seq[Element]
+  if document.html != nil:
+    stack.add(document.html)
+  while stack.len > 0:
+    let elem = stack.pop()
+
+    if elem.tagType == TAG_LINK:
+      let elem = HTMLLinkElement(elem)
+      if elem.rel == "stylesheet":
+        buffer.loadResource(document, elem)
+
+    for child in elem.children_rev:
+      stack.add(child)
+
+proc setupSource(buffer: Buffer): int =
+  let source = buffer.bsource
+  let setct = source.contenttype.isNone
+  if not setct:
+    buffer.contenttype = source.contenttype.get
+  buffer.location = source.location
+  case source.t
+  of CLONE:
+    buffer.istream = connectSocketStream(source.clonepid)
+    if setct:
+      buffer.contenttype = "text/plain"
+  of LOAD_PIPE:
+    var f: File
+    if not open(f, source.fd, fmRead):
+      return 1
+    buffer.istream = newFileStream(f)
+    if setct:
+      buffer.contenttype = "text/plain"
+  of LOAD_REQUEST:
+    let request = source.request
+    let response = buffer.loader.doRequest(request)
+    if response.body == nil:
+      return response.res
+    if setct:
+      buffer.contenttype = response.contenttype
+    buffer.istream = response.body
+    if response.status == 401: # Unauthorized
+      buffer.writeCommand(SET_NEEDS_AUTH)
+    if response.redirect.isSome:
+      buffer.writeCommand(SET_REDIRECT, response.redirect.get)
+  if setct:
+    buffer.writeCommand(SET_CONTENT_TYPE, buffer.contenttype)
+
+proc load(buffer: Buffer) =
+  case buffer.contenttype
+  of "text/html":
+    if not buffer.streamclosed:
+      buffer.source = buffer.istream.readAll()
+      buffer.istream.close()
+      buffer.istream = newStringStream(buffer.source)
+      buffer.document = parseHTML5(buffer.istream)
+      buffer.streamclosed = true
+    else:
+      buffer.document = parseHTML5(newStringStream(buffer.source))
+    buffer.writeCommand(SET_TITLE, buffer.document.title)
+    buffer.document.location = buffer.location
+    buffer.loadResources(buffer.document)
+  else:
+    if not buffer.streamclosed:
+      buffer.source = buffer.istream.readAll()
+      buffer.istream.close()
+      buffer.streamclosed = true
+
+proc render(buffer: Buffer) =
+  case buffer.contenttype
+  of "text/html":
+    if buffer.viewport == nil:
+      buffer.viewport = Viewport(term: buffer.attrs)
+    if buffer.userstyle == nil:
+      buffer.userstyle = buffer.config.stylesheet.parseStylesheet()
+    let ret = renderDocument(buffer.document, buffer.attrs, buffer.userstyle, buffer.viewport, buffer.prevstyled)
+    buffer.lines = ret[0]
+    buffer.prevstyled = ret[1]
+  else:
+    if not buffer.rendered:
+      buffer.lines = renderPlainText(buffer.source)
+      buffer.rendered = true
+
+# https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-the-form-data-set
+proc constructEntryList(form: HTMLFormElement, submitter: Element = nil, encoding: string = ""): seq[tuple[name, value: string]] =
+  if form.constructingentrylist:
+    return
+  form.constructingentrylist = true
+
+  var entrylist: seq[tuple[name, value: string]]
+  for field in form.controls:
+    if field.findAncestor({TAG_DATALIST}) != nil or
+        field.attrb("disabled") or
+        field.isButton() and Element(field) != submitter:
+      continue
+
+    if field.tagType == TAG_INPUT:
+      let field = HTMLInputElement(field)
+      if field.inputType == INPUT_IMAGE:
+        let name = if field.attr("name") != "":
+          field.attr("name") & '.'
+        else:
+          ""
+        entrylist.add((name & 'x', $field.xcoord))
+        entrylist.add((name & 'y', $field.ycoord))
+        continue
+
+    #TODO custom elements
+
+    let name = field.attr("name")
+
+    if name == "":
+      continue
+
+    if field.tagType == TAG_SELECT:
+      let field = HTMLSelectElement(field)
+      for option in field.options:
+        if option.selected or option.disabled:
+          entrylist.add((name, option.value))
+    elif field.tagType == TAG_INPUT and HTMLInputElement(field).inputType in {INPUT_CHECKBOX, INPUT_RADIO}:
+      let value = if field.attr("value") != "":
+        field.attr("value")
+      else:
+        "on"
+      entrylist.add((name, value))
+    elif field.tagType == TAG_INPUT and HTMLInputElement(field).inputType == INPUT_FILE:
+      #TODO file
+      discard
+    elif field.tagType == TAG_INPUT and HTMLInputElement(field).inputType == INPUT_HIDDEN and name.equalsIgnoreCase("_charset_"):
+      let charset = if encoding != "":
+        encoding
+      else:
+        "UTF-8"
+      entrylist.add((name, charset))
+    else:
+      if field.tagType == TAG_INPUT:
+        entrylist.add((name, HTMLInputElement(field).value))
+      else:
+        assert false
+    if field.tagType == TAG_TEXTAREA or
+        field.tagType == TAG_INPUT and HTMLInputElement(field).inputType in {INPUT_TEXT, INPUT_SEARCH}:
+      if field.attr("dirname") != "":
+        let dirname = field.attr("dirname")
+        let dir = "ltr" #TODO bidi
+        entrylist.add((dirname, dir))
+
+  form.constructingentrylist = false
+  return entrylist
+
+#https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart/form-data-encoding-algorithm
+proc makeCRLF(s: string): string =
+  result = newStringOfCap(s.len)
+  var i = 0
+  while i < s.len - 1:
+    if s[i] == '\r' and s[i + 1] != '\n':
+      result &= '\r'
+      result &= '\n'
+    elif s[i] != '\r' and s[i + 1] == '\n':
+      result &= s[i]
+      result &= '\r'
+      result &= '\n'
+      inc i
+    else:
+      result &= s[i]
+    inc i
+
+proc serializeMultipartFormData(kvs: seq[(string, string)]): MimeData =
+  for it in kvs:
+    let name = makeCRLF(it[0])
+    let value = makeCRLF(it[1])
+    result[name] = value
+
+proc serializePlainTextFormData(kvs: seq[(string, string)]): string =
+  for it in kvs:
+    let (name, value) = it
+    result &= name
+    result &= '='
+    result &= value
+    result &= "\r\n"
+
+proc submitForm(form: HTMLFormElement, submitter: Element): Option[Request] =
+  let entrylist = form.constructEntryList(submitter)
+
+  let action = if submitter.action() == "":
+    $form.document.location
+  else:
+    submitter.action()
+
+  let url = parseUrl(action, submitter.document.baseUrl.some)
+  if url.isnone:
+    return none(Request)
+
+  var parsedaction = url.get
+  let scheme = parsedaction.scheme
+  let enctype = submitter.enctype()
+  let formmethod = submitter.formmethod()
+  if formmethod == FORM_METHOD_DIALOG:
+    #TODO
+    return none(Request)
+  let httpmethod = if formmethod == FORM_METHOD_GET:
+    HTTP_GET
+  else:
+    assert formmethod == FORM_METHOD_POST
+    HTTP_POST
+
+  #let target = if submitter.isSubmitButton() and submitter.attrb("formtarget"):
+  #  submitter.attr("formtarget")
+  #else:
+  #  submitter.target()
+  #let noopener = true #TODO
+
+  template mutateActionUrl() =
+    let query = serializeApplicationXWWWFormUrlEncoded(entrylist)
+    parsedaction.query = query.some
+    return newRequest(parsedaction, httpmethod).some
+
+  template submitAsEntityBody() =
+    var mimetype: string
+    var body = none(string)
+    var multipart = none(MimeData)
+    case enctype
+    of FORM_ENCODING_TYPE_URLENCODED:
+      body = serializeApplicationXWWWFormUrlEncoded(entrylist).some
+      mimeType = $enctype
+    of FORM_ENCODING_TYPE_MULTIPART:
+      multipart = serializeMultipartFormData(entrylist).some
+      mimetype = $enctype
+    of FORM_ENCODING_TYPE_TEXT_PLAIN:
+      body = serializePlainTextFormData(entrylist).some
+      mimetype = $enctype
+    return newRequest(parsedaction, httpmethod, {"Content-Type": mimetype}, body, multipart).some
+
+  template getActionUrl() =
+    return newRequest(parsedaction).some
+
+  case scheme
+  of "http", "https":
+    if formmethod == FORM_METHOD_GET:
+      mutateActionUrl
+    else:
+      assert formmethod == FORM_METHOD_POST
+      submitAsEntityBody
+  of "ftp":
+    getActionUrl
+  of "data":
+    if formmethod == FORM_METHOD_GET:
+      mutateActionUrl
+    else:
+      assert formmethod == FORM_METHOD_POST
+      getActionUrl
+
+template set_focus(e: Element) =
+  if buffer.document.focus != e:
+    buffer.document.focus = e
+    buffer.reshape = true
+
+template restore_focus =
+  if buffer.document.focus != nil:
+    buffer.document.focus = nil
+    buffer.reshape = true
+
+proc lineInput(buffer: Buffer, s: string) =
+  if buffer.input != nil:
+    let input = buffer.input
+    case input.inputType
+    of INPUT_SEARCH:
+      input.value = s
+      input.invalid = true
+      buffer.reshape = true
+      if input.form != nil:
+        let submitaction = submitForm(input.form, input)
+        if submitaction.isSome:
+          buffer.writeCommand(OPEN, submitaction.get)
+    of INPUT_TEXT, INPUT_PASSWORD:
+      input.value = s
+      input.invalid = true
+      buffer.reshape = true
+    of INPUT_FILE:
+      let cdir = parseUrl("file://" & getCurrentDir() & DirSep)
+      let path = parseUrl(s, cdir)
+      if path.issome:
+        input.file = path
+        input.invalid = true
+        buffer.reshape = true
+    else: discard
+    buffer.input = nil
+
+proc click(buffer: Buffer, cursorx, cursory: int) =
+  let clickable = buffer.getCursorClickable(cursorx, cursory)
+  if clickable != nil:
+    case clickable.tagType
+    of TAG_SELECT:
+      set_focus clickable
+    of TAG_A:
+      restore_focus
+      let url = parseUrl(HTMLAnchorElement(clickable).href, clickable.document.baseUrl.some)
+      if url.issome:
+        buffer.writeCommand(OPEN, newRequest(url.get, HTTP_GET))
+    of TAG_OPTION:
+      let option = HTMLOptionElement(clickable)
+      let select = option.select
+      if select != nil:
+        if buffer.document.focus == select:
+          # select option
+          if not select.attrb("multiple"):
+            for option in select.options:
+              option.selected = false
+          option.selected = true
+          restore_focus
+        else:
+          # focus on select
+          set_focus select
+    of TAG_INPUT:
+      restore_focus
+      let input = HTMLInputElement(clickable)
+      case input.inputType
+      of INPUT_SEARCH:
+        buffer.input = input
+        buffer.writeCommand(READ_LINE, "SEARCH: ", input.value, false)
+      of INPUT_TEXT, INPUT_PASSWORD:
+        buffer.input = input
+        buffer.writeCommand(READ_LINE, "TEXT: ", input.value, input.inputType == INPUT_PASSWORD)
+      of INPUT_FILE:
+        var path = if input.file.issome:
+          input.file.get.path.serialize_unicode()
+        else:
+          ""
+        buffer.writeCommand(READ_LINE, "Filename: ", path, false)
+      of INPUT_CHECKBOX:
+        input.checked = not input.checked
+        input.invalid = true
+        buffer.reshape = true
+      of INPUT_RADIO:
+        for radio in input.radiogroup:
+          radio.checked = false
+          radio.invalid = true
+        input.checked = true
+        input.invalid = true
+        buffer.reshape = true
+      of INPUT_RESET:
+        if input.form != nil:
+          input.form.reset()
+          buffer.reshape = true
+      of INPUT_SUBMIT, INPUT_BUTTON:
+        if input.form != nil:
+          let submitaction = submitForm(input.form, input)
+          if submitaction.isSome:
+            buffer.writeCommand(OPEN, submitaction.get)
+      else:
+        restore_focus
+    else:
+      restore_focus
+
+proc drawBuffer(buffer: Buffer, ostream: Stream) =
+  var format = newFormat()
+  for line in buffer.lines:
+    if line.formats.len == 0:
+      ostream.swrite(line.str & "\n")
+    else:
+      var x = 0
+      var i = 0
+      var s = ""
+      for f in line.formats:
+        var outstr = ""
+        #assert f.pos < line.str.width(), "fpos " & $f.pos & "\nstr" & line.str & "\n"
+        while x < f.pos:
+          var r: Rune
+          fastRuneAt(line.str, i, r)
+          outstr &= r
+          x += r.width()
+        s &= outstr
+        s &= format.processFormat(f.format)
+      s &= line.str.substr(i) & format.processFormat(newFormat()) & "\n"
+      ostream.swrite(s)
+    ostream.flush()
+  ostream.swrite("")
+  ostream.flush()
+
+proc runBuffer(buffer: Buffer, istream, ostream: Stream) =
+  buffer.pistream = istream
+  buffer.postream = ostream
+  while true:
+    var cmd: BufferCommand
+    try:
+      istream.sread(cmd)
+      #eprint "cmd", cmd
+      case cmd
+      of LOAD:
+        let code = buffer.setupSource()
+        buffer.load()
+        buffer.writeCommand(LOAD_DONE, code)
+      of GOTO_ANCHOR:
+        var anchor: string
+        istream.sread(anchor)
+        if buffer.document != nil and buffer.document.getElementById(anchor) != nil:
+          buffer.writeCommand(ANCHOR_FOUND)
+        else:
+          buffer.writeCommand(ANCHOR_FAIL)
+      of RENDER:
+        buffer.render()
+        buffer.gotoAnchor()
+      of GET_LINES:
+        var w: Slice[int]
+        istream.sread(w)
+        ostream.swrite(SET_LINES)
+        ostream.swrite(buffer.lines.len)
+        w.b = min(buffer.lines.high, w.b)
+        ostream.swrite(w)
+        for y in w:
+          ostream.swrite(buffer.lines[y])
+          ostream.flush()
+        ostream.flush()
+      of DRAW_BUFFER:
+        buffer.drawBuffer(ostream)
+      of WINDOW_CHANGE:
+        istream.sread(buffer.attrs)
+        buffer.windowChange()
+      of FIND_PREV_LINK:
+        var cx, cy: int
+        istream.sread(cx)
+        istream.sread(cy)
+        let pl = buffer.findPrevLink(cx, cy)
+        buffer.writeCommand(JUMP, pl.x, pl.y)
+      of FIND_NEXT_LINK:
+        var cx, cy: int
+        istream.sread(cx)
+        istream.sread(cy)
+        let nl = buffer.findNextLink(cx, cy)
+        buffer.writeCommand(JUMP, nl.x, nl.y)
+      of FIND_PREV_MATCH:
+        var cx, cy: int
+        var regex: Regex
+        var wrap: bool
+        istream.sread(cx)
+        istream.sread(cy)
+        istream.sread(regex)
+        istream.sread(wrap)
+        let match = buffer.findPrevMatch(regex, cx, cy, wrap)
+        if match.success:
+          buffer.writeCommand(JUMP, match.x, match.y)
+      of FIND_NEXT_MATCH:
+        var cx, cy: int
+        var regex: Regex
+        var wrap: bool
+        istream.sread(cx)
+        istream.sread(cy)
+        istream.sread(regex)
+        istream.sread(wrap)
+        let match = buffer.findNextMatch(regex, cx, cy, wrap)
+        if match.success:
+          buffer.writeCommand(JUMP, match.x, match.y)
+      of READ_SUCCESS:
+        var s: string
+        istream.sread(s)
+        buffer.lineInput(s)
+      of READ_CANCELED:
+        buffer.input = nil
+      of CLICK:
+        var cx, cy: int
+        istream.sread(cx)
+        istream.sread(cy)
+        buffer.click(cx, cy)
+      of MOVE_CURSOR:
+        var cx, cy: int
+        istream.sread(cx)
+        istream.sread(cy)
+        buffer.updateHover(cx, cy)
+      of GET_SOURCE:
+        let ssock = initServerSocket(getpid())
+        buffer.writeCommand(SOURCE_READY)
+        let stream = ssock.acceptSocketStream()
+        if not buffer.streamclosed:
+          buffer.source = buffer.istream.readAll()
+          buffer.streamclosed = true
+        stream.write(buffer.source)
+        stream.close()
+        ssock.close()
+      if buffer.reshape:
+        buffer.reshape = false
+        buffer.render()
+        buffer.writeCommand(RESHAPE)
+    except IOError:
+      break
+  istream.close()
+  ostream.close()
+  when defined(posix):
+    #TODO remove this
+    if buffer.loader != nil:
+      assert kill(buffer.loader.process, cint(SIGTERM)) == 0
+      buffer.loader = nil
+  quit(0)
+
+proc launchBuffer*(config: Config, source: BufferSource, attrs: TermAttributes,
+                   istream, ostream: Stream) =
+  let buffer = new Buffer
+  buffer.attrs = attrs
+  buffer.windowChange()
+  buffer.config = config
+  buffer.loader = newFileLoader()
+  buffer.bsource = source
+  buffer.runBuffer(istream, ostream)
diff --git a/src/io/cell.nim b/src/buffer/cell.nim
index 8c5333b1..84efef46 100644
--- a/src/io/cell.nim
+++ b/src/buffer/cell.nim
@@ -21,32 +21,35 @@ type
   Format* = object
     fgcolor*: CellColor
     bgcolor*: CellColor
-    flags: set[FormatFlags]
-
-  Cell* = object of RootObj
-    format*: Format
-    node*: StyledNode
+    flags*: set[FormatFlags]
 
   # A FormatCell *starts* a new terminal formatting context.
   # If no FormatCell exists before a given cell, the default formatting is used.
-  FormatCell* = object of Cell
+  FormatCell* = object
+    format*: Format
     pos*: int
     computed*: ComputedFormat
+    node*: StyledNode
+
+  SimpleFormatCell* = object
+    format*: Format
+    pos*: int
 
   FlexibleLine* = object
     str*: string
     formats*: seq[FormatCell]
 
-  Mark* = ref object
-    x*: int
-    y*: int
-    width*: int
-    format*: Format
+  SimpleFlexibleLine* = object
+    str*: string
+    formats*: seq[SimpleFormatCell]
 
   FlexibleGrid* = seq[FlexibleLine]
 
-  FixedCell* = object of Cell
+  SimpleFlexibleGrid* = seq[SimpleFlexibleLine]
+
+  FixedCell* = object
     str*: string
+    format*: Format
 
   FixedGrid* = seq[FixedCell]
 
@@ -74,8 +77,7 @@ template `blink=`*(f: var Format, b: bool) = flag_template f, b, FLAG_BLINK
 
 func `==`*(a: FixedCell, b: FixedCell): bool =
   return a.format == b.format and
-    a.str == b.str and
-    a.node == b.node
+    a.str == b.str
 
 func newFixedGrid*(w: int, h: int = 1): FixedGrid =
   return newSeq[FixedCell](w * h)
@@ -90,7 +92,7 @@ func newFormat*(): Format =
   return Format(fgcolor: defaultColor, bgcolor: defaultColor)
 
 # Get the first format cell after pos, if any.
-func findFormatN*(line: FlexibleLine, pos: int): int =
+func findFormatN*(line: FlexibleLine|SimpleFlexibleLine, pos: int): int =
   var i = 0
   while i < line.formats.len:
     if line.formats[i].pos > pos:
@@ -105,6 +107,13 @@ func findFormat*(line: FlexibleLine, pos: int): FormatCell =
   else:
     result.pos = -1
 
+func findFormat*(line: SimpleFlexibleLine, pos: int): SimpleFormatCell =
+  let i = line.findFormatN(pos) - 1
+  if i != -1:
+    result = line.formats[i]
+  else:
+    result.pos = -1
+
 func findNextFormat*(line: FlexibleLine, pos: int): FormatCell =
   let i = line.findFormatN(pos)
   if i < line.formats.len:
@@ -112,6 +121,13 @@ func findNextFormat*(line: FlexibleLine, pos: int): FormatCell =
   else:
     result.pos = -1
 
+func findNextFormat*(line: SimpleFlexibleLine, pos: int): SimpleFormatCell =
+  let i = line.findFormatN(pos)
+  if i < line.formats.len:
+    result = line.formats[i]
+  else:
+    result.pos = -1
+
 proc addLine*(grid: var FlexibleGrid) =
   grid.add(FlexibleLine())
 
diff --git a/src/buffer/container.nim b/src/buffer/container.nim
new file mode 100644
index 00000000..beb5fedc
--- /dev/null
+++ b/src/buffer/container.nim
@@ -0,0 +1,626 @@
+import macros
+import options
+import streams
+import strformat
+import unicode
+
+when defined(posix):
+  import posix
+
+import buffer/buffer
+import buffer/cell
+import config/config
+import io/request
+import io/serialize
+import io/term
+import js/regex
+import types/url
+import utils/twtstr
+
+type
+  CursorPosition* = object
+    cursorx*: int
+    cursory*: int
+    xend*: int
+    fromx*: int
+    fromy*: int
+    setx: int
+
+  ContainerEventType* = enum
+    NO_EVENT, FAIL, SUCCESS, NEEDS_AUTH, REDIRECT, ANCHOR, NO_ANCHOR, UPDATE,
+    STATUS, JUMP, READ_LINE, OPEN
+
+  ContainerEvent* = object
+    case t*: ContainerEventType
+    of READ_LINE:
+      prompt*: string
+      value*: string
+      password*: bool
+    of OPEN:
+      request*: Request
+    else: discard
+
+  Container* = ref object
+    attrs*: TermAttributes
+    width*: int
+    height*: int
+    contenttype*: Option[string]
+    title*: string
+    hovertext*: string
+    source*: BufferSource
+    children*: seq[Container]
+    pos: CursorPosition
+    bpos: seq[CursorPosition]
+    parent*: Container
+    sourcepair*: Container
+    istream*: Stream
+    ostream*: Stream
+    ifd*: FileHandle
+    process: Pid
+    lines: SimpleFlexibleGrid
+    lineshift: int
+    numLines*: int
+    replace*: Container
+    code*: int
+    retry*: seq[URL]
+    redirect*: Option[URL]
+    ispipe: bool
+    jump: bool
+    pipeto: Container
+    tty: FileHandle
+
+proc c_setvbuf(f: File, buf: pointer, mode: cint, size: csize_t): cint {.
+  importc: "setvbuf", header: "<stdio.h>", tags: [].}
+
+proc newBuffer*(config: Config, source: BufferSource, tty: FileHandle, ispipe = false): Container =
+  let attrs = getTermAttributes(stdout)
+  when defined(posix):
+    var pipefd_in, pipefd_out: array[0..1, cint]
+    if pipe(pipefd_in) == -1:
+      raise newException(Defect, "Failed to open input pipe.")
+    if pipe(pipefd_out) == -1:
+      raise newException(Defect, "Failed to open output pipe.")
+    let pid = fork()
+    if pid == -1:
+      raise newException(Defect, "Failed to fork buffer process")
+    elif pid == 0:
+      discard close(tty)
+      discard close(stdout.getFileHandle())
+      # child process
+      discard close(pipefd_in[1]) # close write
+      discard close(pipefd_out[0]) # close read
+      var readf, writef: File
+      if not open(readf, pipefd_in[0], fmRead):
+        raise newException(Defect, "Failed to open input handle")
+      if not open(writef, pipefd_out[1], fmWrite):
+        raise newException(Defect, "Failed to open output handle")
+      let istream = newFileStream(readf)
+      let ostream = newFileStream(writef)
+      launchBuffer(config, source, attrs, istream, ostream)
+    else:
+      discard close(pipefd_in[0]) # close read
+      discard close(pipefd_out[1]) # close write
+      var readf, writef: File
+      if not open(writef, pipefd_in[1], fmWrite):
+        raise newException(Defect, "Failed to open output handle")
+      if not open(readf, pipefd_out[0], fmRead):
+        raise newException(Defect, "Failed to open input handle")
+      let istream = newFileStream(readf)
+      # Disable buffering of the read end so epoll doesn't get stuck
+      discard c_setvbuf(readf, nil, IONBF, 0)
+      let ostream = newFileStream(writef)
+      result = Container(istream: istream, ostream: ostream, source: source,
+                         ifd: pipefd_out[0], process: pid, attrs: attrs,
+                         width: attrs.width - 1, height: attrs.height - 1,
+                         contenttype: source.contenttype, ispipe: ispipe,
+                         tty: tty)
+      result.pos.setx = -1
+
+func lineLoaded(container: Container, y: int): bool =
+  return y - container.lineshift in 0..container.lines.high
+
+func getLine(container: Container, y: int): SimpleFlexibleLine =
+  if container.lineLoaded(y):
+    return container.lines[y - container.lineshift]
+
+iterator ilines*(container: Container, slice: Slice[int]): SimpleFlexibleLine {.inline.} =
+  for y in slice:
+    yield container.getLine(y)
+
+func cursorx*(container: Container): int {.inline.} = container.pos.cursorx
+func cursory*(container: Container): int {.inline.} = container.pos.cursory
+func fromx*(container: Container): int {.inline.} = container.pos.fromx
+func fromy*(container: Container): int {.inline.} = container.pos.fromy
+func xend*(container: Container): int {.inline.} = container.pos.xend
+func lastVisibleLine*(container: Container): int = min(container.fromy + container.height, container.numLines) - 1
+
+func acursorx*(container: Container): int =
+  max(0, container.cursorx - container.fromx)
+
+func acursory*(container: Container): int =
+  container.cursory - container.fromy
+
+func currentLine*(container: Container): string =
+  return container.getLine(container.cursory).str
+
+func cursorBytes(container: Container, y: int, cc = container.cursorx): int =
+  let line = container.getLine(y).str
+  var w = 0
+  var i = 0
+  while i < line.len and w < cc:
+    var r: Rune
+    fastRuneAt(line, i, r)
+    w += r.width()
+  return i
+
+func currentCursorBytes(container: Container, cc = container.cursorx): int =
+  return container.cursorBytes(container.cursory, cc)
+
+func prevWidth*(container: Container): int =
+  if container.numLines == 0: return 0
+  let line = container.currentLine
+  if line.len == 0: return 0
+  var w = 0
+  var i = 0
+  let cc = container.pos.fromx + container.pos.cursorx
+  var pr: Rune
+  var r: Rune
+  fastRuneAt(line, i, r)
+  while i < line.len and w < cc:
+    pr = r
+    fastRuneAt(line, i, r)
+    w += r.width()
+  return pr.width()
+
+func currentWidth*(container: Container): int =
+  if container.numLines == 0: return 0
+  let line = container.currentLine
+  if line.len == 0: return 0
+  var w = 0
+  var i = 0
+  let cc = container.cursorx
+  var r: Rune
+  fastRuneAt(line, i, r)
+  while i < line.len and w < cc:
+    fastRuneAt(line, i, r)
+    w += r.width()
+  return r.width()
+
+func maxScreenWidth(container: Container): int =
+  for line in container.ilines(container.fromy..container.lastVisibleLine):
+    result = max(line.str.width(), result)
+
+func getTitle*(container: Container): string =
+  if container.title != "":
+    return container.title
+  if container.ispipe:
+    return "*pipe*"
+  return container.source.location.serialize(excludepassword = true)
+
+func currentLineWidth*(container: Container): int =
+  if container.numLines == 0: return 0
+  return container.currentLine.width()
+
+func maxfromy(container: Container): int = max(container.numLines - container.height, 0)
+
+func maxfromx(container: Container): int = max(container.currentLineWidth() - container.width, 0)
+
+func atPercentOf*(container: Container): int =
+  if container.numLines == 0: return 100
+  return (100 * (container.cursory + 1)) div container.numLines
+
+func lineInfo*(container: Container): string =
+  fmt"line {container.cursory + 1}/{container.numLines} ({container.atPercentOf}%) col {container.cursorx + 1}/{container.currentLineWidth} (byte {container.currentCursorBytes})"
+
+func lineWindow(container: Container): Slice[int] =
+  if container.numLines == 0: # not loaded
+    return 0..container.height * 5
+  let n = (container.height * 5) div 2
+  var x = container.fromy - n + container.height div 2
+  var y = container.fromy + n + container.height div 2
+  if x < 0:
+    y += -x
+    x = 0
+  if y >= container.numLines:
+    x -= y - container.numLines
+    y = container.numLines
+  return max(x, 0) .. min(y, container.numLines - 1)
+
+macro writeCommand(container: Container, cmd: BufferCommand, args: varargs[typed]) =
+  result = newStmtList()
+  result.add(quote do: `container`.ostream.swrite(`cmd`))
+  for arg in args:
+    result.add(quote do: `container`.ostream.swrite(`arg`))
+  result.add(quote do: `container`.ostream.flush())
+
+proc setFromY*(container: Container, y: int) =
+  if container.pos.fromy != y:
+    container.pos.fromy = max(min(y, container.maxfromy), 0)
+    container.writeCommand(GET_LINES, container.lineWindow)
+
+proc setFromX*(container: Container, x: int) =
+  if container.pos.fromx != x:
+    container.pos.fromx = max(min(x, container.maxfromx), 0)
+
+proc setFromXY*(container: Container, x, y: int) =
+  container.setFromY(y)
+  container.setFromX(x)
+
+proc setCursorX*(container: Container, x: int, refresh = true, save = true) =
+  if not container.lineLoaded(container.cursory):
+    container.pos.setx = x
+    return
+  container.pos.setx = -1
+  let cw = container.currentLineWidth()
+  let x = max(min(x, cw - 1), 0)
+  if (not refresh) or (container.fromx <= x and x < container.fromx + container.width):
+    container.pos.cursorx = x
+  else:
+    if refresh and container.fromx > container.cursorx:
+      container.setFromX(max(cw - 1, 0))
+      container.pos.cursorx = container.fromx
+    elif x > container.cursorx:
+      container.setFromX(max(x - container.width + 1, 0))
+      container.pos.cursorx = x
+    elif x < container.cursorx:
+      container.setFromX(x)
+      container.pos.cursorx = x
+  container.writeCommand(MOVE_CURSOR, container.cursorx, container.cursory)
+  if save:
+    container.pos.xend = container.cursorx
+
+proc restoreCursorX(container: Container) =
+  container.setCursorX(max(min(container.currentLineWidth() - 1, container.xend), 0), false, false)
+
+proc setCursorY*(container: Container, y: int) =
+  let y = max(min(y, container.numLines - 1), 0)
+  if container.cursory == y: return
+  if y - container.fromy >= 0 and y - container.height < container.fromy:
+    container.pos.cursory = y
+  else:
+    if y > container.cursory:
+      container.setFromY(y - container.height + 1)
+    else:
+      container.setFromY(y)
+    container.pos.cursory = y
+  container.writeCommand(MOVE_CURSOR, container.cursorx, container.cursory)
+  container.restoreCursorX()
+
+proc centerLine*(container: Container) =
+  container.setFromY(container.cursory - container.height div 2)
+
+proc setCursorXY*(container: Container, x, y: int) =
+  let fy = container.fromy
+  container.setCursorY(y)
+  container.setCursorX(x)
+  if fy != container.fromy:
+    container.centerLine()
+
+proc cursorDown*(container: Container) =
+  container.setCursorY(container.cursory + 1)
+
+proc cursorUp*(container: Container) =
+  container.setCursorY(container.cursory - 1)
+
+proc cursorLeft*(container: Container) =
+  container.setCursorX(container.cursorx - container.prevWidth())
+
+proc cursorRight*(container: Container) =
+  container.setCursorX(container.cursorx + container.currentWidth())
+
+proc cursorLineBegin*(container: Container) =
+  container.setCursorX(0)
+
+proc cursorLineEnd*(container: Container) =
+  container.setCursorX(container.currentLineWidth() - 1)
+
+proc cursorNextWord*(container: Container) =
+  if container.numLines == 0: return
+  var r: Rune
+  var b = container.currentCursorBytes()
+  var x = container.cursorx
+  while b < container.currentLine.len:
+    let pb = b
+    fastRuneAt(container.currentLine, b, r)
+    if r.breaksWord():
+      b = pb
+      break
+    x += r.width()
+
+  while b < container.currentLine.len:
+    let pb = b
+    fastRuneAt(container.currentLine, b, r)
+    if not r.breaksWord():
+      b = pb
+      break
+    x += r.width()
+
+  if b < container.currentLine.len:
+    container.setCursorX(x)
+  else:
+    if container.cursory < container.numLines - 1:
+      container.cursorDown()
+      container.cursorLineBegin()
+    else:
+      container.cursorLineEnd()
+
+proc cursorPrevWord*(container: Container) =
+  if container.numLines == 0: return
+  var b = container.currentCursorBytes()
+  var x = container.cursorx
+  if container.currentLine.len > 0:
+    b = min(b, container.currentLine.len - 1)
+    while b >= 0:
+      let (r, o) = lastRune(container.currentLine, b)
+      if r.breaksWord():
+        break
+      b -= o
+      x -= r.width()
+
+    while b >= 0:
+      let (r, o) = lastRune(container.currentLine, b)
+      if not r.breaksWord():
+        break
+      b -= o
+      x -= r.width()
+  else:
+    b = -1
+
+  if b >= 0:
+    container.setCursorX(x)
+  else:
+    if container.cursory > 0:
+      container.cursorUp()
+      container.cursorLineEnd()
+    else:
+      container.cursorLineBegin()
+
+proc pageDown*(container: Container) =
+  container.setFromY(container.fromy + container.height)
+  container.setCursorY(container.cursory + container.height)
+  container.restoreCursorX()
+
+proc pageUp*(container: Container) =
+  container.setFromY(container.fromy - container.height)
+  container.setCursorY(container.cursory - container.height)
+  container.restoreCursorX()
+
+proc pageLeft*(container: Container) =
+  container.setFromX(container.fromx - container.width)
+  container.setCursorX(container.cursorx - container.width)
+
+proc pageRight*(container: Container) =
+  container.setFromX(container.fromx + container.width)
+  container.setCursorX(container.cursorx + container.width)
+
+proc halfPageUp*(container: Container) =
+  container.setFromY(container.fromy - container.height div 2 + 1)
+  container.setCursorY(container.cursory - container.height div 2 + 1)
+  container.restoreCursorX()
+
+proc halfPageDown*(container: Container) =
+  container.setFromY(container.fromy + container.height div 2 - 1)
+  container.setCursorY(container.cursory + container.height div 2 - 1)
+  container.restoreCursorX()
+
+proc cursorFirstLine*(container: Container) =
+  container.setCursorY(0)
+
+proc cursorLastLine*(container: Container) =
+  container.setCursorY(container.numLines - 1)
+
+proc cursorTop*(container: Container) =
+  container.setCursorY(container.fromy)
+
+proc cursorMiddle*(container: Container) =
+  container.setCursorY(container.fromy + (container.height - 2) div 2)
+
+proc cursorBottom*(container: Container) =
+  container.setCursorY(container.fromy + container.height - 1)
+
+proc cursorLeftEdge*(container: Container) =
+  container.setCursorX(container.fromx)
+
+proc cursorVertMiddle*(container: Container) =
+  container.setCursorX(container.fromx + (container.width - 2) div 2)
+
+proc cursorRightEdge*(container: Container) =
+  container.setCursorX(container.fromx + container.width - 1)
+
+proc scrollDown*(container: Container) =
+  if container.fromy + container.height < container.numLines:
+    container.setFromY(container.fromy + 1)
+    if container.fromy > container.cursory:
+      container.cursorDown()
+  else:
+    container.cursorDown()
+
+proc scrollUp*(container: Container) =
+  if container.fromy > 0:
+    container.setFromY(container.fromy - 1)
+    if container.fromy + container.height <= container.cursory:
+      container.cursorUp()
+  else:
+    container.cursorUp()
+
+proc scrollRight*(container: Container) =
+  if container.fromx + container.width < container.maxScreenWidth():
+    container.setFromX(container.fromx + 1)
+
+proc scrollLeft*(container: Container) =
+  if container.fromx > 0:
+    container.setFromX(container.fromx - 1)
+    if container.cursorx < container.fromx:
+      container.setCursorX(container.currentLineWidth() - 1)
+
+proc updateCursor(container: Container) =
+  if container.pos.setx > -1:
+    container.setCursorX(container.pos.setx)
+  if container.fromy > container.lastVisibleLine:
+    container.setFromY(0)
+    container.setCursorY(container.lastVisibleLine)
+  if container.cursory >= container.numLines:
+    container.pos.cursory = max(0, container.numLines - 1)
+  if container.numLines == 0:
+    container.pos.cursory = 0
+
+proc pushCursorPos*(container: Container) =
+  container.bpos.add(container.pos)
+
+proc popCursorPos*(container: Container) =
+  container.pos = container.bpos.pop()
+  container.updateCursor()
+  container.writeCommand(MOVE_CURSOR, container.cursorx, container.cursory)
+  container.writeCommand(GET_LINES, container.lineWindow)
+
+macro proxy(fun: typed) =
+  let name = fun[0] # sym
+  let params = fun[3] # formalparams
+  let retval = params[0] # sym
+  var body = newStmtList()
+  assert params.len >= 2 # return type, container
+  var x = name.strVal.toScreamingSnakeCase()
+  if x[^1] == '=':
+    x = "SET_" & x[0..^2]
+  let nup = ident(x)
+  let container = params[1][0]
+  body.add(quote do:
+    `container`.ostream.swrite(`nup`))
+  for c in params[2..^1]:
+    let s = c[0] # sym e.g. url
+    body.add(quote do:
+      `container`.ostream.swrite(`s`))
+  body.add(quote do:
+    `container`.ostream.flush())
+  if retval.kind != nnkEmpty:
+    body.add(quote do:
+      `container`.istream.sread(result))
+  var params2: seq[NimNode]
+  for x in params.children: params2.add(x)
+  result = newProc(name, params2, body)
+
+proc cursorNextLink*(container: Container) =
+  container.writeCommand(FIND_NEXT_LINK, container.cursorx, container.cursory)
+  container.jump = true
+
+proc cursorPrevLink*(container: Container) =
+  container.writeCommand(FIND_PREV_LINK, container.cursorx, container.cursory)
+  container.jump = true
+
+proc cursorNextMatch*(container: Container, regex: Regex, wrap: bool) =
+  container.writeCommand(FIND_NEXT_MATCH, container.cursorx, container.cursory, regex, wrap)
+  container.jump = true
+
+proc cursorPrevMatch*(container: Container, regex: Regex, wrap: bool) =
+  container.writeCommand(FIND_PREV_MATCH, container.cursorx, container.cursory, regex, wrap)
+  container.jump = true
+
+proc load*(container: Container) {.proxy.} = discard
+proc gotoAnchor*(container: Container, anchor: string) {.proxy.} = discard
+proc readCanceled*(container: Container) {.proxy.} = discard
+proc readSuccess*(container: Container, s: string) {.proxy.} = discard
+
+proc render*(container: Container) =
+  container.writeCommand(RENDER)
+  container.jump = true # may jump to anchor
+  container.writeCommand(GET_LINES, container.lineWindow)
+
+proc dupeBuffer*(container: Container, config: Config, location = none(URL), contenttype = none(string)): Container =
+  var pipefd: array[0..1, cint]
+  if pipe(pipefd) == -1:
+    raise newException(Defect, "Failed to open dupe pipe.")
+  let source = BufferSource(
+    t: CLONE,
+    location: location.get(container.source.location),
+    contenttype: if contenttype.isSome: contenttype else: container.contenttype,
+    clonepid: container.process,
+  )
+  container.pipeto = newBuffer(config, source, container.tty, container.ispipe)
+  container.writeCommand(GET_SOURCE)
+  return container.pipeto
+
+proc click*(container: Container) =
+  container.writeCommand(CLICK, container.cursorx, container.cursory)
+
+proc drawBuffer*(container: Container) =
+  container.writeCommand(DRAW_BUFFER)
+  while true:
+    var s: string
+    container.istream.sread(s)
+    if s == "": break
+    try:
+      stdout.write(s)
+    except IOError: # couldn't write to stdout; it's probably just a broken pipe.
+      quit(1)
+    stdout.flushFile()
+
+proc windowChange*(container: Container, attrs: TermAttributes) =
+  container.attrs = attrs
+  container.width = attrs.width - 1
+  container.height = attrs.height - 1
+  container.writeCommand(WINDOW_CHANGE, attrs)
+
+proc handleEvent*(container: Container): ContainerEvent =
+  var cmd: ContainerCommand
+  container.istream.sread(cmd)
+  case cmd
+  of SET_LINES:
+    var w: Slice[int]
+    container.istream.sread(container.numLines)
+    container.istream.sread(w)
+    container.lines.setLen(w.len)
+    container.lineshift = w.a
+    for y in 0 ..< w.len:
+      container.istream.sread(container.lines[y])
+    container.updateCursor()
+    let cw = container.fromy ..< container.fromy + container.height
+    if w.a in cw or w.b in cw or cw.a in w or cw.b in w:
+      return ContainerEvent(t: UPDATE)
+  of SET_NEEDS_AUTH:
+    return ContainerEvent(t: NEEDS_AUTH)
+  of SET_CONTENT_TYPE:
+    var ctype: string
+    container.istream.sread(ctype)
+    container.contenttype = some(ctype)
+  of SET_REDIRECT:
+    var redirect: URL
+    container.istream.sread(redirect)
+    container.redirect = some(redirect)
+    return ContainerEvent(t: REDIRECT)
+  of SET_TITLE:
+    container.istream.sread(container.title)
+    return ContainerEvent(t: STATUS)
+  of SET_HOVER:
+    container.istream.sread(container.hovertext)
+    return ContainerEvent(t: STATUS)
+  of LOAD_DONE:
+    container.istream.sread(container.code)
+    if container.code != 0:
+      return ContainerEvent(t: FAIL)
+    return ContainerEvent(t: SUCCESS)
+  of ANCHOR_FOUND:
+    return ContainerEvent(t: ANCHOR)
+  of ANCHOR_FAIL:
+    return ContainerEvent(t: FAIL)
+  of READ_LINE:
+    var prompt, str: string
+    var pwd: bool
+    container.istream.sread(prompt)
+    container.istream.sread(str)
+    container.istream.sread(pwd)
+    return ContainerEvent(t: READ_LINE, prompt: prompt, value: str, password: pwd)
+  of JUMP:
+    var x, y: int
+    container.istream.sread(x)
+    container.istream.sread(y)
+    if container.jump and x >= 0 and y >= 0:
+      container.setCursorXY(x, y)
+      container.jump = false
+      return ContainerEvent(t: UPDATE)
+  of OPEN:
+    return ContainerEvent(t: OPEN, request: container.istream.readRequest())
+  of SOURCE_READY:
+    if container.pipeto != nil:
+      container.pipeto.load()
+  of RESHAPE:
+    container.writeCommand(GET_LINES, container.lineWindow)
diff --git a/src/config/config.nim b/src/config/config.nim
index 10e097bd..a3b121ba 100644
--- a/src/config/config.nim
+++ b/src/config/config.nim
@@ -1,6 +1,5 @@
 import tables
 import os
-import strutils
 import streams
 
 import config/toml
@@ -8,41 +7,10 @@ import types/color
 import utils/twtstr
 
 type
-  TwtAction* =
-    enum
-    NO_ACTION,
-    ACTION_FEED_NEXT,
-    ACTION_QUIT,
-    ACTION_CURSOR_UP, ACTION_CURSOR_DOWN, ACTION_CURSOR_LEFT, ACTION_CURSOR_RIGHT,
-    ACTION_CURSOR_LINEEND, ACTION_CURSOR_LINEBEGIN,
-    ACTION_CURSOR_NEXT_WORD, ACTION_CURSOR_PREV_WORD,
-    ACTION_CURSOR_NEXT_LINK, ACTION_CURSOR_PREV_LINK,
-    ACTION_PAGE_DOWN, ACTION_PAGE_UP, ACTION_PAGE_LEFT, ACTION_PAGE_RIGHT,
-    ACTION_HALF_PAGE_DOWN, ACTION_HALF_PAGE_UP,
-    ACTION_SCROLL_DOWN, ACTION_SCROLL_UP, ACTION_SCROLL_LEFT, ACTION_SCROLL_RIGHT,
-    ACTION_CLICK,
-    ACTION_CHANGE_LOCATION, ACTION_DUPE_BUFFER,
-    ACTION_PREV_BUFFER, ACTION_NEXT_BUFFER, ACTION_DISCARD_BUFFER,
-    ACTION_RELOAD, ACTION_RESHAPE, ACTION_REDRAW, ACTION_TOGGLE_SOURCE,
-    ACTION_CURSOR_FIRST_LINE, ACTION_CURSOR_LAST_LINE,
-    ACTION_CURSOR_TOP, ACTION_CURSOR_MIDDLE, ACTION_CURSOR_BOTTOM,
-    ACTION_CURSOR_RIGHT_EDGE, ACTION_CURSOR_VERT_MIDDLE, ACTION_CURSOR_LEFT_EDGE,
-    ACTION_CENTER_LINE, ACTION_LINE_INFO,
-    ACTION_COMMAND,
-    ACTION_SEARCH, ACTION_SEARCH_BACK, ACTION_ISEARCH, ACTION_ISEARCH_BACK,
-    ACTION_SEARCH_NEXT, ACTION_SEARCH_PREV,
-    ACTION_LINED_SUBMIT, ACTION_LINED_CANCEL,
-    ACTION_LINED_BACKSPACE, ACTION_LINED_DELETE,
-    ACTION_LINED_CLEAR, ACTION_LINED_KILL, ACTION_LINED_KILL_WORD,
-    ACTION_LINED_BACK, ACTION_LINED_FORWARD,
-    ACTION_LINED_PREV_WORD, ACTION_LINED_NEXT_WORD,
-    ACTION_LINED_BEGIN, ACTION_LINED_END,
-    ACTION_LINED_ESC
-
-  ActionMap = Table[string, TwtAction]
+  ActionMap = Table[string, string]
   Config* = ref ConfigObj
   ConfigObj = object
-    nmap*: Table[string, string]
+    nmap*: ActionMap
     lemap*: ActionMap
     stylesheet*: string
     startup*: string
@@ -77,7 +45,6 @@ func getRealKey(key: string): string =
     else:
       if meta == 2:
         realk &= '\e'
-        realk &= c
         meta = 0
       if control == 2:
         realk &= getControlChar(c)
@@ -90,27 +57,7 @@ func getRealKey(key: string): string =
     realk &= 'M'
   return realk
 
-func constructActionTable*(origTable: ActionMap): ActionMap =
-  var newTable: ActionMap
-  var strs: seq[string]
-  for k in origTable.keys:
-    let realk = getRealKey(k)
-    var teststr = ""
-    for c in realk:
-      teststr &= c
-      strs.add(teststr)
-
-  for k, v in origTable:
-    let realk = getRealKey(k)
-    var teststr = ""
-    for c in realk:
-      teststr &= c
-      if strs.contains(teststr):
-        newTable[teststr] = ACTION_FEED_NEXT
-    newTable[realk] = v
-  return newTable
-
-func constructActionTable2*(origTable: Table[string, string]): Table[string, string] =
+func constructActionTable*(origTable: Table[string, string]): Table[string, string] =
   var strs: seq[string]
   for k in origTable.keys:
     let realk = getRealKey(k)
@@ -128,11 +75,6 @@ func constructActionTable2*(origTable: Table[string, string]): Table[string, str
         result[teststr] = "client.feedNext()"
     result[realk] = v
 
-func getLineAction(s: string): TwtAction =
-  if s == "NULL":
-    return NO_ACTION
-  return parseEnum[TwtAction]("ACTION_LINED_" & s)
-
 proc readUserStylesheet(dir, file: string): string =
   if file.len == 0:
     return ""
@@ -154,11 +96,10 @@ proc parseConfig(config: Config, dir: string, t: TomlValue) =
       config.ambiguous_double = general["double-width-ambiguous"].b
   if "page" in t:
     for k, v in t["page"].pairs:
-      #config.nmap[getRealKey(k)] = getAction(v.s)
       config.nmap[getRealKey(k)] = v.s
   if "line" in t:
     for k, v in t["line"].pairs:
-      config.lemap[getRealKey(k)] = getLineAction(v.s)
+      config.lemap[getRealKey(k)] = v.s
   if "css" in t:
     let css = t["css"]
     if "include" in css:
@@ -184,7 +125,7 @@ proc parseConfig(config: Config, dir: string, t: TomlValue) =
       of "magenta": config.markcolor = CellColor(rgb: false, color: 45u8)
       of "cyan": config.markcolor = CellColor(rgb: false, color: 46u8)
       of "white": config.markcolor = CellColor(rgb: false, color: 47u8)
-      of "terminal": config.markcolor = defaultColor
+      of "terminal": config.markcolor = CellColor(rgb: false, color: 0)
 
 proc parseConfig(config: Config, dir: string, stream: Stream) =
   config.parseConfig(dir, parseToml(stream))
@@ -209,10 +150,10 @@ proc getNormalAction*(config: Config, s: string): string =
     return config.nmap[s]
   return ""
 
-proc getLinedAction*(config: Config, s: string): TwtAction =
+proc getLinedAction*(config: Config, s: string): string =
   if config.lemap.hasKey(s):
     return config.lemap[s]
-  return NO_ACTION
+  return ""
 
 proc readConfig*(): Config =
   new(result)
diff --git a/src/display/client.nim b/src/display/client.nim
index 24f65f06..2f4771f8 100644
--- a/src/display/client.nim
+++ b/src/display/client.nim
@@ -9,13 +9,14 @@ when defined(posix):
   import posix
 
 import std/monotimes
+import std/selectors
 
+import buffer/container
 import css/sheet
 import config/config
 import display/pager
 import html/dom
 import html/htmlparser
-import io/buffer
 import io/lineedit
 import io/loader
 import io/request
@@ -36,6 +37,7 @@ type
     loader: FileLoader
     console {.jsget.}: Console
     pager {.jsget.}: Pager
+    line {.jsget.}: LineEdit
     config: Config
     jsrt: JSRuntime
     jsctx: JSContext
@@ -49,7 +51,7 @@ type
 
   Console* = ref object
     err*: Stream
-    lastbuf*: Buffer
+    lastcontainer*: Container
     ibuf: string
     tty: File
 
@@ -69,18 +71,6 @@ proc statusMode(client: Client) =
   print(HVP(client.attrs.height + 1, 1))
   print(EL())
 
-proc readPipe(client: Client, ctype: string) =
-  let buffer = newBuffer(client.config, client.console.tty)
-  buffer.contenttype = if ctype != "": ctype else: "text/plain"
-  buffer.ispipe = true
-  buffer.istream = newFileStream(stdin)
-  buffer.location = newURL("file://-")
-  client.pager.addBuffer(buffer)
-  if client.console.tty != nil:
-    buffer.setupBuffer()
-  else:
-    buffer.load()
-
 proc doRequest(client: Client, req: Request): Response {.jsfunc.} =
   client.loader.doRequest(req)
 
@@ -99,7 +89,8 @@ proc interruptHandler(rt: JSRuntime, opaque: pointer): int {.cdecl.} =
 
 proc evalJS(client: Client, src, filename: string): JSObject =
   unblockStdin(client.console.tty.getFileHandle())
-  return client.jsctx.eval(src, filename, JS_EVAL_TYPE_GLOBAL)
+  result = client.jsctx.eval(src, filename, JS_EVAL_TYPE_GLOBAL)
+  restoreStdin(client.console.tty.getFileHandle())
 
 proc evalJSFree(client: Client, src, filename: string) =
   free(client.evalJS(src, filename))
@@ -126,69 +117,103 @@ proc command(client: Client, src: string) =
   let previ = client.console.err.getPosition()
   client.command0(src)
   client.console.err.setPosition(previ)
-  if client.console.lastbuf == nil:
-    let buffer = newBuffer(client.config, client.console.tty)
-    buffer.istream = newStringStream(client.console.err.readAll()) #TODO
-    buffer.contenttype = "text/plain"
-    buffer.location = parseUrl("javascript:void(0);").get
-    client.console.lastbuf = buffer
-    client.pager.addBuffer(buffer)
-  else:
-    client.console.lastbuf.istream = newStringStream(client.console.lastbuf.source & client.console.err.readAll())
-    client.console.lastbuf.streamclosed = false
-  client.console.lastbuf.setupBuffer()
-  client.console.lastbuf.cursorLastLine()
+  #TODO yeah this won't work...
+  #if client.console.lastcontainer == nil:
+  #  let source = BufferSource(t: STRING, slocation: parseUrl("javascript:void(0);").get, str: client.console.err.readAll(), contenttype: some("text/plain"))
+  #  let container = newBuffer(client.config, client.console.tty, source)
+  #  client.console.lastcontainer = container
+  #  client.pager.addContainer(container)
+  #else:
+  #  client.console.lastbuf.istream = newStringStream(client.console.lastbuf.source & client.console.err.readAll())
+  #  client.console.lastbuf.streamclosed = false
+  #client.console.lastbuf.setupBuffer()
+  #client.console.lastbuf.cursorLastLine()
 
 proc command(client: Client): bool {.jsfunc.} =
-  var iput: string
   client.statusMode()
-  let status = readLine("COMMAND: ", iput, client.attrs.width, config = client.config, tty = client.console.tty)
-  if status:
-    client.command(iput)
-  return status
+  client.pager.lineedit = some(readLine("COMMAND: ", client.attrs.width, config = client.config, tty = client.console.tty))
 
 proc commandMode(client: Client) {.jsfunc.} =
   client.pager.commandMode = client.command()
 
 proc quit(client: Client, code = 0) {.jsfunc.} =
   if stdout.isatty():
-    print(HVP(getTermAttributes(stdout).height, 0))
+    print(HVP(getTermAttributes(stdout).height, 1))
+    print('\n')
     print(EL())
+    stdout.showCursor()
   when defined(posix):
     assert kill(client.loader.process, cint(SIGTERM)) == 0
-    for buffer in client.pager.buffers:
-      if buffer.loader != nil:
-        assert kill(buffer.loader.process, cint(SIGTERM)) == 0
   quit(code)
 
 proc feedNext(client: Client) {.jsfunc.} =
   client.feednext = true
 
 proc input(client: Client) =
+  restoreStdin(client.console.tty.getFileHandle())
   if client.pager.commandMode:
     client.commandMode()
     return
-  if not client.feednext:
-    client.s = ""
-  else:
-    client.feednext = false
-  restoreStdin(client.console.tty.getFileHandle())
   let c = client.console.readChar()
   client.s &= c
+  if client.pager.lineedit.isSome:
+    let edit = client.pager.lineedit.get
+    client.line = edit
+    if edit.escNext:
+      edit.escNext = false
+      if edit.write(client.s):
+        client.s = ""
+    else:
+      let action = getLinedAction(client.config, client.s)
+      if action == "":
+        if edit.write(client.s):
+          client.s = ""
+        else:
+          client.feedNext = true
+      elif not client.feedNext:
+        client.evalJSFree(action, "<command>")
+      client.pager.updateReadLine()
+      if client.pager.lineedit.isNone:
+        client.line = nil
+  else:
+    let action = getNormalAction(client.config, client.s)
+    client.evalJSFree(action, "<command>")
+  if not client.feedNext:
+    client.s = ""
+  else:
+    client.feedNext = false
+  if client.pager.container != nil:
+    if client.pager.lineedit.isNone:
+      client.pager.refreshStatusMsg()
+      client.pager.displayStatus()
+      client.pager.displayCursor()
 
-  let action = getNormalAction(client.config, client.s)
-  client.evalJSFree(action, "<command>")
+proc c_setvbuf(f: File, buf: pointer, mode: cint, size: csize_t): cint {.
+  importc: "setvbuf", header: "<stdio.h>", tags: [].}
 
 proc inputLoop(client: Client) =
+  discard c_setvbuf(client.console.tty, nil, IONBF, 0) #??? I thought raw mode would take care of this
+  client.pager.selector.registerHandle(int(client.console.tty.getFileHandle()), {Read}, nil)
   while true:
-    restoreStdin(client.console.tty.getFileHandle())
-    client.pager.displayPage()
-    client.pager.followRedirect()
-    if client.pager.container != nil:
-      client.pager.container.buffer.refreshBuffer()
-    if client.pager.container.needsauth: # Unauthorized
-      client.pager.checkAuth()
-    client.input()
+    let events = client.pager.selector.select(-1)
+    for event in events:
+      if event.fd == client.console.tty.getFileHandle():
+        client.input()
+        stdout.flushFile()
+      else:
+        let container = client.pager.fdmap[FileHandle(event.fd)]
+        if not client.pager.handleEvent(container):
+          disableRawMode()
+          for msg in client.pager.status:
+            eprint msg
+          client.quit(1)
+    if client.pager.lineedit.isNone and client.pager.switched:
+      client.pager.refreshDisplay(client.pager.container)
+      client.pager.displayPage()
+      client.pager.switched = false
+    if client.pager.command != "":
+      client.command(client.pager.command)
+      client.pager.command = ""
 
 #TODO this is dumb
 proc readFile(client: Client, path: string): string {.jsfunc.} =
@@ -283,7 +308,7 @@ proc jsEventLoop(client: Client) =
     if wait > 0:
       sleep(wait)
 
-proc launchClient*(client: Client, pages: seq[string], ctype: string, dump: bool) =
+proc launchClient*(client: Client, pages: seq[string], ctype: Option[string], dump: bool) =
   if client.config.startup != "":
     let s = readFile(client.config.startup)
     client.console.err = newFileStream(stderr)
@@ -293,26 +318,23 @@ proc launchClient*(client: Client, pages: seq[string], ctype: string, dump: bool
     quit()
   client.userstyle = client.config.stylesheet.parseStylesheet()
   if not stdin.isatty:
-    client.readPipe(ctype)
+    client.pager.readPipe(ctype)
   else:
     client.console.tty = stdin
 
   for page in pages:
-    client.pager.loadURL(page, force = true, ctype = ctype)
+    client.pager.loadURL(page, ctype = ctype)
 
   if stdout.isatty and not dump:
-    if client.pager.container != nil:
-      when defined(posix):
-        enableRawMode(client.console.tty.getFileHandle())
-      client.inputLoop()
-    else:
-      for msg in client.pager.status:
-        eprint msg
+    when defined(posix):
+      enableRawMode(client.console.tty.getFileHandle())
+    client.inputLoop()
   else:
     for msg in client.pager.status:
       eprint msg
-    for buffer in client.pager.buffers:
-      buffer.drawBuffer()
+    for container in client.pager.containers:
+      container.render()
+      container.drawBuffer()
     stdout.close()
   client.quit()
 
@@ -339,14 +361,14 @@ proc sleep(client: Client, millis: int) {.jsfunc.} =
 proc newClient*(config: Config): Client =
   new(result)
   result.config = config
-  result.loader = newFileLoader()
   result.console = newConsole()
   if stdin.isatty():
     result.console.tty = stdin
   elif stdout.isatty():
     discard open(result.console.tty, "/dev/tty", fmRead)
   result.attrs = getTermAttributes(stdout)
-  result.pager = newPager(config, result.attrs, result.loader, result.console.tty)
+  result.loader = newFileLoader()
+  result.pager = newPager(config, result.attrs, result.console.tty)
   let rt = newJSRuntime()
   rt.setInterruptHandler(interruptHandler, cast[pointer](result))
   let ctx = rt.newJSContext()
@@ -365,4 +387,5 @@ proc newClient*(config: Config): Client =
   ctx.addDOMModule()
   ctx.addHTMLModule()
   ctx.addRequestModule()
+  ctx.addLineEditModule()
   ctx.addPagerModule()
diff --git a/src/display/pager.nim b/src/display/pager.nim
index c7e994b7..38eed767 100644
--- a/src/display/pager.nim
+++ b/src/display/pager.nim
@@ -1,13 +1,17 @@
 import options
 import os
+import streams
+import tables
 import terminal
 import unicode
 
+import std/selectors
+
+import buffer/buffer
+import buffer/cell
+import buffer/container
 import config/config
-import io/buffer
-import io/cell
 import io/lineedit
-import io/loader
 import io/request
 import io/term
 import js/javascript
@@ -16,26 +20,32 @@ import types/url
 import utils/twtstr
 
 type
-  Container = ref object
-    buffer*: Buffer
-    children: seq[Container]
-    pos: CursorPosition
-    parent: Container
-    sourcepair: Container
-    needsauth*: bool #TODO move to buffer?
-    redirecturl: Option[URL]
+  LineMode* = enum
+    NO_LINEMODE, LOCATION, USERNAME, PASSWORD, COMMAND, BUFFER, SEARCH_F,
+    SEARCH_B, ISEARCH_F, ISEARCH_B
 
   Pager* = ref object
     attrs: TermAttributes
     commandMode*: bool
     container*: Container
+    lineedit*: Option[LineEdit]
+    linemode*: LineMode
+    username: string
+    command*: string
     config: Config
-    loader: FileLoader
     regex: Option[Regex]
+    iregex: Option[Regex]
     reverseSearch: bool
     status*: seq[string]
+    statusmsg*: FixedGrid
     switched*: bool
     tty: File
+    selector*: Selector[Container]
+    fdmap*: Table[FileHandle, Container]
+    icpos: CursorPosition
+    display: FixedGrid
+    bheight*: int
+    bwidth*: int
 
 iterator containers*(pager: Pager): Container =
   if pager.container != nil:
@@ -48,194 +58,252 @@ iterator containers*(pager: Pager): Container =
       for i in countdown(c.children.high, 0):
         stack.add(c.children[i])
 
-iterator buffers*(pager: Pager): Buffer =
-  for container in pager.containers:
-    yield container.buffer
-
 proc setContainer*(pager: Pager, c: Container) =
   pager.container = c
   pager.switched = true
 
-proc cursorLeft(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorLeft()
-proc cursorDown(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorDown()
-proc cursorUp(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorUp()
-proc cursorRight(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorRight()
-proc cursorLineBegin(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorLineBegin()
-proc cursorLineEnd(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorLineEnd()
-proc cursorNextWord(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorNextWord()
-proc cursorPrevWord(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorPrevWord()
-proc cursorNextLink(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorNextLink()
-proc cursorPrevLink(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorPrevLink()
-proc pageDown(pager: Pager) {.jsfunc.} = pager.container.buffer.pageDown()
-proc pageUp(pager: Pager) {.jsfunc.} = pager.container.buffer.pageUp()
-proc pageRight(pager: Pager) {.jsfunc.} = pager.container.buffer.pageRight()
-proc pageLeft(pager: Pager) {.jsfunc.} = pager.container.buffer.pageLeft()
-proc halfPageDown(pager: Pager) {.jsfunc.} = pager.container.buffer.halfPageDown()
-proc halfPageUp(pager: Pager) {.jsfunc.} = pager.container.buffer.halfPageUp()
-proc cursorFirstLine(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorFirstLine()
-proc cursorLastLine(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorLastLine()
-proc cursorTop(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorTop()
-proc cursorMiddle(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorMiddle()
-proc cursorBottom(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorBottom()
-proc cursorLeftEdge(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorLeftEdge()
-proc cursorVertMiddle(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorVertMiddle()
-proc cursorRightEdge(pager: Pager) {.jsfunc.} = pager.container.buffer.cursorRightEdge()
-proc centerLine(pager: Pager) {.jsfunc.} = pager.container.buffer.centerLine()
-proc scrollDown(pager: Pager) {.jsfunc.} = pager.container.buffer.scrollDown()
-proc scrollUp(pager: Pager) {.jsfunc.} = pager.container.buffer.scrollUp()
-proc scrollLeft(pager: Pager) {.jsfunc.} = pager.container.buffer.scrollLeft()
-proc scrollRight(pager: Pager) {.jsfunc.} = pager.container.buffer.scrollRight()
-proc lineInfo(pager: Pager) {.jsfunc.} = pager.container.buffer.lineInfo()
-proc reshape(pager: Pager) {.jsfunc.} = pager.container.buffer.reshape = true
-proc redraw(pager: Pager) {.jsfunc.} = pager.container.buffer.redraw = true
+proc cursorDown(pager: Pager) {.jsfunc.} = pager.container.cursorDown()
+proc cursorUp(pager: Pager) {.jsfunc.} = pager.container.cursorUp()
+proc cursorLeft(pager: Pager) {.jsfunc.} = pager.container.cursorLeft()
+proc cursorRight(pager: Pager) {.jsfunc.} = pager.container.cursorRight()
+proc cursorLineBegin(pager: Pager) {.jsfunc.} = pager.container.cursorLineBegin()
+proc cursorLineEnd(pager: Pager) {.jsfunc.} = pager.container.cursorLineEnd()
+proc cursorNextWord(pager: Pager) {.jsfunc.} = pager.container.cursorNextWord()
+proc cursorPrevWord(pager: Pager) {.jsfunc.} = pager.container.cursorPrevWord()
+proc cursorNextLink(pager: Pager) {.jsfunc.} = pager.container.cursorNextLink()
+proc cursorPrevLink(pager: Pager) {.jsfunc.} = pager.container.cursorPrevLink()
+proc pageUp(pager: Pager) {.jsfunc.} = pager.container.pageUp()
+proc pageDown(pager: Pager) {.jsfunc.} = pager.container.pageDown()
+proc pageRight(pager: Pager) {.jsfunc.} = pager.container.pageRight()
+proc pageLeft(pager: Pager) {.jsfunc.} = pager.container.pageLeft()
+proc halfPageDown(pager: Pager) {.jsfunc.} = pager.container.halfPageDown()
+proc halfPageUp(pager: Pager) {.jsfunc.} = pager.container.halfPageUp()
+proc cursorFirstLine(pager: Pager) {.jsfunc.} = pager.container.cursorFirstLine()
+proc cursorLastLine(pager: Pager) {.jsfunc.} = pager.container.cursorLastLine()
+proc cursorTop(pager: Pager) {.jsfunc.} = pager.container.cursorTop()
+proc cursorMiddle(pager: Pager) {.jsfunc.} = pager.container.cursorMiddle()
+proc cursorBottom(pager: Pager) {.jsfunc.} = pager.container.cursorBottom()
+proc cursorLeftEdge(pager: Pager) {.jsfunc.} = pager.container.cursorLeftEdge()
+proc cursorVertMiddle(pager: Pager) {.jsfunc.} = pager.container.cursorVertMiddle()
+proc cursorRightEdge(pager: Pager) {.jsfunc.} = pager.container.cursorRightEdge()
+proc centerLine(pager: Pager) {.jsfunc.} = pager.container.centerLine()
+proc scrollDown(pager: Pager) {.jsfunc.} = pager.container.scrollDown()
+proc scrollUp(pager: Pager) {.jsfunc.} = pager.container.scrollUp()
+proc scrollLeft(pager: Pager) {.jsfunc.} = pager.container.scrollLeft()
+proc scrollRight(pager: Pager) {.jsfunc.} = pager.container.scrollRight()
+proc reshape(pager: Pager) {.jsfunc.} = pager.container.render()
 
 proc searchNext(pager: Pager) {.jsfunc.} =
   if pager.regex.issome:
     if not pager.reverseSearch:
-      discard pager.container.buffer.cursorNextMatch(pager.regex.get)
+      pager.container.cursorNextMatch(pager.regex.get, true)
     else:
-      discard pager.container.buffer.cursorPrevMatch(pager.regex.get)
+      pager.container.cursorPrevMatch(pager.regex.get, true)
 
 proc searchPrev(pager: Pager) {.jsfunc.} =
   if pager.regex.issome:
     if not pager.reverseSearch:
-      discard pager.container.buffer.cursorPrevMatch(pager.regex.get)
+      pager.container.cursorPrevMatch(pager.regex.get, true)
     else:
-      discard pager.container.buffer.cursorNextMatch(pager.regex.get)
+      pager.container.cursorNextMatch(pager.regex.get, true)
 
 proc statusMode(pager: Pager) =
   print(HVP(pager.attrs.height + 1, 1))
   print(EL())
 
-proc search(pager: Pager) {.jsfunc.} =
+proc setLineEdit(pager: Pager, edit: LineEdit, mode: LineMode) =
   pager.statusMode()
-  var iput: string
-  let status = readLine("/", iput, pager.attrs.width, config = pager.config, tty = pager.tty)
-  if status:
-    if iput.len != 0:
-      pager.regex = compileSearchRegex(iput)
-    pager.reverseSearch = false
-    pager.searchNext()
-
-proc searchBack(pager: Pager) {.jsfunc.} =
-  pager.statusMode()
-  var iput: string
-  let status = readLine("?", iput, pager.attrs.width, config = pager.config, tty = pager.tty)
-  if status:
-    if iput.len != 0:
-      pager.regex = compileSearchRegex(iput)
-    pager.reverseSearch = true
-    pager.searchNext()
+  edit.writeStart()
+  stdout.flushFile()
+  pager.lineedit = some(edit)
+  pager.linemode = mode
 
-proc displayPage*(pager: Pager) =
-  let buffer = pager.container.buffer
-  if pager.switched or buffer.refreshBuffer():
-    pager.switched = false
-    stdout.hideCursor()
-    print(buffer.generateFullOutput())
-    stdout.showCursor()
-
-proc isearch(pager: Pager) {.jsfunc.} =
-  pager.statusMode()
-  var iput: string
-  let cpos = pager.container.buffer.cpos
-  var mark: Mark
-  template del_mark() =
-    if mark != nil:
-      pager.container.buffer.removeMark(mark)
-
-  let status = readLine("/", iput, pager.attrs.width, {}, false, pager.config, pager.tty, (proc(state: var LineState): bool =
-    del_mark
-    let regex = compileSearchRegex($state.news)
-    pager.container.buffer.cpos = cpos
-    if regex.issome:
-      let match = pager.container.buffer.cursorNextMatch(regex.get)
-      if match.success:
-        mark = pager.container.buffer.addMark(match.x, match.y, match.str.width())
-        pager.container.buffer.redraw = true
-        pager.container.buffer.refreshBuffer(true)
-        pager.displayPage()
-        print(HVP(pager.attrs.height + 1, 2))
-        print(SGR())
-      else:
-        del_mark
-        pager.container.buffer.redraw = true
-        pager.container.buffer.refreshBuffer(true)
-        pager.displayPage()
-        print(HVP(pager.attrs.height + 1, 2))
-        print(SGR())
-      return true
-    false
-  ))
-
-  del_mark
-  pager.container.buffer.redraw = true
-  pager.container.buffer.refreshBuffer(true)
-  if status:
-    pager.regex = compileSearchRegex(iput)
-  else:
-    pager.container.buffer.cpos = cpos
+proc clearLineEdit(pager: Pager) =
+  pager.lineedit = none(LineEdit)
 
-proc isearchBack(pager: Pager) {.jsfunc.} =
-  pager.statusMode()
-  var iput: string
-  let cpos = pager.container.buffer.cpos
-  var mark: Mark
-  template del_mark() =
-    if mark != nil:
-      pager.container.buffer.removeMark(mark)
-  let status = readLine("?", iput, pager.container.buffer.width, {}, false, pager.config, pager.tty, (proc(state: var LineState): bool =
-    del_mark
-    let regex = compileSearchRegex($state.news)
-    pager.container.buffer.cpos = cpos
-    if regex.issome:
-      let match = pager.container.buffer.cursorPrevMatch(regex.get)
-      if match.success:
-        mark = pager.container.buffer.addMark(match.x, match.y, match.str.width())
-        pager.container.buffer.redraw = true
-        pager.container.buffer.refreshBuffer(true)
-        pager.displayPage()
-        print(HVP(pager.attrs.height + 1, 2))
-        print(SGR())
-      else:
-        del_mark
-        pager.container.buffer.redraw = true
-        pager.container.buffer.refreshBuffer(true)
-        pager.displayPage()
-        print(HVP(pager.attrs.height + 1, 2))
-        print(SGR())
-      return true
-    false
-  ))
-  del_mark
-  pager.container.buffer.redraw = true
-  if status:
-    pager.regex = compileSearchRegex(iput)
-  else:
-    pager.container.buffer.cpos = cpos
+proc searchForward(pager: Pager) {.jsfunc.} =
+  pager.setLineEdit(readLine("/", pager.attrs.width, config = pager.config, tty = pager.tty), SEARCH_F)
 
-proc newContainer(buffer: Buffer, parent: Container): Container =
-  new(result)
-  result.buffer = buffer
-  result.parent = parent
+proc searchBackward(pager: Pager) {.jsfunc.} =
+  pager.setLineEdit(readLine("?", pager.attrs.width, config = pager.config, tty = pager.tty), SEARCH_B)
+
+proc isearchForward(pager: Pager) {.jsfunc.} =
+  pager.container.pushCursorPos()
+  pager.setLineEdit(readLine("/", pager.attrs.width, config = pager.config, tty = pager.tty), ISEARCH_F)
 
-proc newPager*(config: Config, attrs: TermAttributes, loader: FileLoader, tty: File): Pager =
+proc isearchBackward(pager: Pager) {.jsfunc.} =
+  pager.container.pushCursorPos()
+  pager.setLineEdit(readLine("?", pager.attrs.width, config = pager.config, tty = pager.tty), ISEARCH_B)
+
+proc newPager*(config: Config, attrs: TermAttributes, tty: File): Pager =
   new(result)
   result.config = config
   result.attrs = attrs
-  result.loader = loader
   result.tty = tty
+  result.selector = newSelector[Container]()
+  result.bwidth = attrs.width - 1 # writing to the last column is a bad idea it seems
+  result.bheight = attrs.height - 1
+  result.display = newFixedGrid(result.bwidth, result.bheight)
+
+proc clearDisplay(pager: Pager) =
+  pager.display = newFixedGrid(pager.bwidth, pager.bheight)
+
+proc refreshDisplay*(pager: Pager, container = pager.container) =
+  var r: Rune
+  var by = 0
+  pager.clearDisplay()
+  for line in container.ilines(container.fromy ..< min(container.fromy + pager.bheight, container.numLines)):
+    var w = 0 # width of the row so far
+    var i = 0 # byte in line.str
+    # Skip cells till buffer.fromx.
+    while w < container.fromx and i < line.str.len:
+      fastRuneAt(line.str, i, r)
+      w += r.width()
+    let dls = by * container.width # starting position of row in display
+    # Fill in the gap in case we skipped more cells than fromx mandates (i.e.
+    # we encountered a double-width character.)
+    var k = 0
+    if w > container.fromx:
+      while k < w - container.fromx:
+        pager.display[dls + k].str &= ' '
+        inc k
+    var cf = line.findFormat(w)
+    var nf = line.findNextFormat(w)
+    let startw = w # save this for later
+    # Now fill in the visible part of the row.
+    while i < line.str.len:
+      let pw = w
+      fastRuneAt(line.str, i, r)
+      w += r.width()
+      if w > container.fromx + pager.bwidth:
+        break # die on exceeding the width limit
+      if nf.pos != -1 and nf.pos <= pw:
+        cf = nf
+        nf = line.findNextFormat(pw)
+      pager.display[dls + k].str &= r
+      if cf.pos != -1:
+        pager.display[dls + k].format = cf.format
+      let tk = k + r.width()
+      while k < tk and k < pager.bwidth - 1:
+        inc k
+    # Then, for each cell that has a mark, override its formatting with that
+    # specified by the mark.
+    #TODO honestly this was always broken anyways. not sure about how to re-implement it
+    #var l = 0
+    #while l < pager.marks.len and buffer.marks[l].y < by:
+    #  inc l # linear search to find the first applicable mark
+    #let aw = buffer.width - (startw - buffer.fromx) # actual width
+    #while l < buffer.marks.len and buffer.marks[l].y == by:
+    #  let mark = buffer.marks[l]
+    #  inc l
+    #  if mark.x >= startw + aw or mark.x + mark.width < startw: continue
+    #  for i in max(mark.x, startw)..<min(mark.x + mark.width, startw + aw):
+    #    buffer.display[dls + i - startw].format = mark.format
+    inc by
+
+func generateStatusMessage*(pager: Pager): string =
+  var format = newFormat()
+  var w = 0
+  for cell in pager.statusmsg:
+    result &= format.processFormat(cell.format)
+    result &= cell.str
+    w += cell.width()
+  if w < pager.bwidth:
+    result &= EL()
+
+proc clearStatusMessage(pager: Pager) =
+  pager.statusmsg = newFixedGrid(pager.bwidth)
+
+proc writeStatusMessage(pager: Pager, str: string, format: Format = Format()) =
+  pager.clearStatusMessage()
+  var i = 0
+  for r in str.runes:
+    i += r.width()
+    if i >= pager.statusmsg.len:
+      pager.statusmsg[^1].str = "$"
+      break
+    pager.statusmsg[i].str &= r
+    pager.statusmsg[i].format = format
+
+proc refreshStatusMsg*(pager: Pager) =
+  let container = pager.container
+  if container != nil:
+    var msg = $(container.cursory + 1) & "/" & $container.numLines & " (" &
+              $container.atPercentOf() & "%) " & "<" & container.getTitle() & ">"
+    if container.hovertext.len > 0:
+      msg &= " " & container.hovertext
+    var format: Format
+    format.reverse = true
+    pager.writeStatusMessage(msg, format)
+
+func generateStatusOutput(pager: Pager): string =
+  if pager.status.len > 0:
+    result = pager.status[0] & EL()
+    pager.status = pager.status[1..^1]
+  else:
+    return pager.generateStatusMessage()
+
+func generateFullOutput(pager: Pager): string =
+  var x = 0
+  var w = 0
+  var format = newFormat()
+  result &= HVP(1, 1)
+  for cell in pager.display:
+    if x >= pager.bwidth:
+      result &= EL()
+      result &= "\r\n"
+      x = 0
+      w = 0
+    result &= format.processFormat(cell.format)
+    result &= cell.str
+    w += cell.width()
+    inc x
+  result &= EL()
+  result &= "\r\n"
+
+proc displayCursor*(pager: Pager) =
+  if pager.container == nil: return
+  print(HVP(pager.container.acursory + 1, pager.container.acursorx + 1))
+  stdout.flushFile()
+
+proc displayStatus*(pager: Pager) =
+  if pager.lineedit.isNone:
+    pager.statusMode()
+    print(pager.generateStatusOutput())
+    stdout.flushFile()
+
+proc displayPage*(pager: Pager) =
+  stdout.hideCursor()
+  print(SGR())
+  print(pager.generateFullOutput())
+  pager.displayStatus()
+  pager.displayCursor()
+  stdout.showCursor()
+  if pager.lineedit.isSome:
+    pager.statusMode()
+    pager.lineedit.get.writePrompt()
+    pager.lineedit.get.fullRedraw()
+  stdout.flushFile()
 
-proc addBuffer*(pager: Pager, buffer: Buffer) =
-  var ncontainer = newContainer(buffer, pager.container)
+proc redraw(pager: Pager) {.jsfunc.} =
+  pager.refreshDisplay()
+  pager.refreshStatusMsg()
+  pager.displayPage()
+
+proc addContainer*(pager: Pager, container: Container) =
+  container.parent = pager.container
   if pager.container != nil:
-    pager.container.children.add(ncontainer)
-  pager.setContainer(ncontainer)
+    pager.container.children.add(container)
+  pager.setContainer(container)
+  assert int(container.ifd) != 0
+  pager.fdmap[container.ifd] = container
+  pager.selector.registerHandle(int(container.ifd), {Read}, pager.container)
+
+proc dupeContainer(pager: Pager, container: Container, location: Option[URL]): Container =
+  return container.dupeBuffer(pager.config, location)
 
 proc dupeBuffer*(pager: Pager, location = none(URL)) {.jsfunc.} =
-  var clone: Buffer
-  clone = pager.container.buffer.dupeBuffer(location)
-  pager.addBuffer(clone)
+  pager.addContainer(pager.dupeContainer(pager.container, location))
 
 # The prevBuffer and nextBuffer procedures emulate w3m's PREV and NEXT
 # commands by traversing the container tree in a depth-first order.
@@ -244,15 +312,13 @@ proc prevBuffer*(pager: Pager): bool {.jsfunc.} =
     return false
   if pager.container.parent == nil:
     return false
-  for i in 0..pager.container.parent.children.high:
-    let child = pager.container.parent.children[i]
-    if child == pager.container:
-      if i > 0:
-        pager.setContainer(pager.container.parent.children[i - 1])
-      else:
-        pager.setContainer(pager.container.parent)
-      return true
-  assert false, "Container not a child of its parent"
+  let n = pager.container.parent.children.find(pager.container)
+  assert n != -1, "Container not a child of its parent"
+  if n > 0:
+    pager.setContainer(pager.container.parent.children[n - 1])
+  else:
+    pager.setContainer(pager.container.parent)
+  return true
 
 proc nextBuffer*(pager: Pager): bool {.jsfunc.} =
   if pager.container == nil:
@@ -262,95 +328,97 @@ proc nextBuffer*(pager: Pager): bool {.jsfunc.} =
     return true
   if pager.container.parent == nil:
     return false
-  for i in countdown(pager.container.parent.children.high, 0):
-    let child = pager.container.parent.children[i]
-    if child == pager.container:
-      if i < pager.container.parent.children.high:
-        pager.setContainer(pager.container.parent.children[i + 1])
-        return true
-      return false
-  assert false, "Container not a child of its parent"
+  let n = pager.container.parent.children.find(pager.container)
+  assert n != -1, "Container not a child of its parent"
+  if n < pager.container.parent.children.high:
+    pager.setContainer(pager.container.parent.children[n + 1])
+    return true
+  return false
 
-#TODO we should have a separate status message stack for all buffers AND the
-# pager.
-proc setStatusMessage(pager: Pager, msg: string) =
-  if pager.container != nil:
-    pager.container.buffer.setStatusMessage(msg)
+proc setStatusMessage*(pager: Pager, msg: string) =
+  pager.status.add(msg)
+  pager.refreshStatusMsg()
+
+proc lineInfo(pager: Pager) {.jsfunc.} =
+  pager.setStatusMessage(pager.container.lineInfo())
+
+proc deleteContainer(pager: Pager, container: Container) =
+  if container.parent == nil and container.children.len == 0 and container != pager.container:
+    return
+  if container.parent != nil:
+    let parent = container.parent
+    let n = parent.children.find(container)
+    assert n != -1, "Container not a child of its parent"
+    for i in countdown(container.children.high, 0):
+      let child = container.children[i]
+      child.parent = container.parent
+      parent.children.insert(child, n + 1)
+    parent.children.delete(n)
+    if container == pager.container:
+      pager.setContainer(parent)
+  elif container.children.len > 0:
+    let parent = container.children[0]
+    parent.parent = nil
+    for i in 1..container.children.high:
+      container.children[i].parent = parent
+      parent.children.add(container.children[i])
+    if container == pager.container:
+      pager.setContainer(parent)
   else:
-    pager.status.add(msg)
+    for child in container.children:
+      child.parent = nil
+    if container == pager.container:
+      pager.setContainer(nil)
+  container.parent = nil
+  container.children.setLen(0)
+  pager.fdmap.del(container.ifd)
+  pager.selector.unregister(int(container.ifd))
+  container.istream.close()
+  container.ostream.close()
 
 proc discardBuffer*(pager: Pager) {.jsfunc.} =
-  if pager.container.parent == nil and pager.container.children.len == 0:
+  if pager.container == nil or pager.container.parent == nil and
+      pager.container.children.len == 0:
     pager.setStatusMessage("Cannot discard last buffer!")
   else:
-    if pager.container.parent != nil:
-      let parent = pager.container.parent
-      let n = parent.children.find(pager.container)
-      assert n != -1, "Container not a child of its parent"
-      for i in countdown(pager.container.children.high, 0):
-        let child = pager.container.children[i]
-        child.parent = pager.container.parent
-        parent.children.insert(child, n + 1)
-      parent.children.delete(n)
-      pager.setContainer(parent)
-    else:
-      pager.setContainer(pager.container.children[0])
-      pager.container.parent = nil
-
-proc drawBuffer*(pager: Pager) {.jsfunc.} =
-  pager.container.buffer.drawBuffer() #TODO move this to pager
+    pager.deleteContainer(pager.container)
 
 proc toggleSource*(pager: Pager) {.jsfunc.} =
   if pager.container.sourcepair != nil:
     pager.setContainer(pager.container.sourcepair)
   else:
-    let buffer = newBuffer(pager.config, pager.tty)
-    buffer.source = pager.container.buffer.source
-    buffer.streamclosed = true
-    buffer.location = pager.container.buffer.location
-    buffer.ispipe = pager.container.buffer.ispipe
-    if pager.container.buffer.contenttype == "text/plain":
-      buffer.contenttype = "text/html"
+    let contenttype = if pager.container.contenttype.get("") == "text/html":
+      some("text/plain")
     else:
-      buffer.contenttype = "text/plain"
-    buffer.setupBuffer()
-    let container = newContainer(buffer, pager.container)
+      some("text/html")
+    let container = pager.container.dupeBuffer(pager.config, contenttype = contenttype)
     container.sourcepair = pager.container
     pager.container.sourcepair = container
     pager.container.children.add(container)
 
 # Load request in a new buffer.
-proc gotoURL*(pager: Pager, request: Request, prevurl = none(URL), force = false, ctype = "", replace = false): bool {.discardable.} =
-  if force or prevurl.isnone or not prevurl.get.equals(request.url, true) or
+proc gotoURL*(pager: Pager, request: Request, prevurl = none(URL), ctype = none(string), replace: Container = nil) =
+  if prevurl.isnone or not prevurl.get.equals(request.url, true) or
       request.url.hash == "" or request.httpmethod != HTTP_GET:
     # Basically, we want to reload the page *only* when
-    # a) force == true
+    # a) we force a reload (by setting prevurl to none)
     # b) or the new URL isn't just the old URL + an anchor
     # I think this makes navigation pretty natural, or at least very close to
     # what other browsers do. Still, it would be nice if we got some visual
     # feedback on what is actually going to happen when typing a URL; TODO.
-    let response = pager.loader.doRequest(request)
-    if response.body != nil:
-      let buffer = newBuffer(pager.config, pager.tty)
-      buffer.contenttype = if ctype != "": ctype else: response.contenttype
-      buffer.istream = response.body
-      buffer.location = request.url
-      buffer.setupBuffer()
-      if replace:
-        pager.discardBuffer()
-      pager.addBuffer(buffer)
-      pager.container.needsauth = response.status == 401 # Unauthorized
-      pager.container.redirecturl = response.redirect
-    else:
-      pager.setStatusMessage("Couldn't load " & $request.url & " (" & $response.res & ")")
-      return false
+    let source = BufferSource(
+      t: LOAD_REQUEST,
+      request: request,
+      contenttype: ctype,
+      location: request.url
+    )
+    let container = newBuffer(pager.config, source, pager.tty.getFileHandle())
+    container.replace = replace
+    pager.addContainer(container)
+    container.load()
   else:
-    if pager.container.buffer.hasAnchor(request.url.anchor):
-      pager.dupeBuffer(request.url.some)
-    else:
-      pager.setStatusMessage("Couldn't find anchor " & request.url.anchor)
-      return false
-  return true
+    pager.container.redirect = some(request.url)
+    pager.container.gotoAnchor(request.url.anchor)
 
 # When the user has passed a partial URL as an argument, they might've meant
 # either:
@@ -358,90 +426,198 @@ proc gotoURL*(pager: Pager, request: Request, prevurl = none(URL), force = false
 # * https://<url>
 # So we attempt to load both, and see what works.
 # (TODO: make this optional)
-proc loadURL*(pager: Pager, url: string, force = false, ctype = "") =
+proc loadURL*(pager: Pager, url: string, ctype = none(string)) =
   let firstparse = parseURL(url)
   if firstparse.issome:
     let prev = if pager.container != nil:
-      some(pager.container.buffer.location)
+      some(pager.container.source.location)
     else:
       none(URL)
-    pager.gotoURL(newRequest(firstparse.get), prev, force, ctype)
+    pager.gotoURL(newRequest(firstparse.get), prev, ctype)
     return
+  var urls: seq[URL]
+  let pageurl = parseURL("https://" & url)
+  if pageurl.isSome: # attempt to load remote page
+    urls.add(pageurl.get)
   let cdir = parseURL("file://" & getCurrentDir() & DirSep)
-  let newurl = parseURL(url, cdir)
-  if newurl.isSome:
-    # attempt to load local file
-    if pager.gotoURL(newRequest(newurl.get), force = force, ctype = ctype):
-      return
-  block:
-    let purl = percentEncode(url, LocalPathPercentEncodeSet)
-    if purl != url:
-      let newurl = parseURL(purl, cdir)
-      if newurl.isSome:
-        if pager.gotoURL(newRequest(newurl.get), force = force, ctype = ctype):
-          pager.status.setLen(0)
-          return
-  block:
-    let newurl = parseURL("https://" & url)
+  let purl = percentEncode(url, LocalPathPercentEncodeSet)
+  if purl != url:
+    let newurl = parseURL(purl, cdir)
     if newurl.isSome:
-      # attempt to load remote page
-      if pager.gotoURL(newRequest(newurl.get), force = force, ctype = ctype):
-        pager.status.setLen(0)
-        return
-  pager.setStatusMessage("Invalid URL " & url)
+      urls.add(newurl.get)
+  let localurl = parseURL(url, cdir)
+  if localurl.isSome: # attempt to load local file
+    urls.add(localurl.get)
+  if urls.len == 0:
+    pager.setStatusMessage("Invalid URL " & url)
+  else:
+    let prevc = pager.container
+    pager.gotoURL(newRequest(urls.pop()), ctype = ctype)
+    if pager.container != prevc:
+      pager.container.retry = urls
+
+proc readPipe*(pager: Pager, ctype: Option[string]) =
+  let source = BufferSource(
+    t: LOAD_PIPE,
+    fd: stdin.getFileHandle(),
+    contenttype: some(ctype.get("text/plain")),
+    location: parseUrl("file://-").get
+  )
+  let container = newBuffer(pager.config, source, pager.tty.getFileHandle(), ispipe = true)
+  pager.addContainer(container)
+  container.load()
+
+proc updateReadLineISearch(pager: Pager, linemode: LineMode) =
+  let lineedit = pager.lineedit.get
+  case lineedit.state
+  of CANCEL:
+    pager.iregex = none(Regex)
+    pager.container.popCursorPos()
+  of EDIT:
+    let x = $lineedit.news
+    if x != "": pager.iregex = compileSearchRegex(x)
+    pager.container.popCursorPos()
+    if pager.iregex.isSome:
+      if linemode == ISEARCH_F:
+        pager.container.cursorNextMatch(pager.iregex.get, true)
+      else:
+        pager.container.cursorPrevMatch(pager.iregex.get, true)
+    pager.container.pushCursorPos()
+    pager.displayPage()
+    pager.statusMode()
+    pager.lineedit.get.fullRedraw()
+  of FINISH:
+    if pager.iregex.isSome:
+      pager.regex = pager.iregex
+    pager.reverseSearch = linemode == ISEARCH_B
+
+proc updateReadLine*(pager: Pager) =
+  let lineedit = pager.lineedit.get
+  template s: string = $lineedit.news
+  if pager.linemode in {ISEARCH_F, ISEARCH_B}:
+    pager.updateReadLineISearch(pager.linemode)
+  else:
+    case lineedit.state
+    of EDIT: return
+    of FINISH:
+      case pager.linemode
+      of LOCATION: pager.loadURL(s)
+      of USERNAME:
+        pager.username = s
+        pager.setLineEdit(readLine("Password: ", pager.attrs.width, hide = true, config = pager.config, tty = pager.tty), PASSWORD)
+      of PASSWORD:
+        let url = newURL(pager.container.source.location)
+        url.username = pager.username
+        url.password = s
+        pager.username = ""
+        pager.gotoURL(newRequest(url), some(pager.container.source.location), replace = pager.container)
+      of COMMAND: pager.command = s
+      of BUFFER: pager.container.readSuccess(s)
+      of SEARCH_F:
+        let x = s
+        if x != "": pager.regex = compileSearchRegex(x)
+        pager.reverseSearch = false
+        pager.searchNext()
+      of SEARCH_B:
+        let x = s
+        if x != "": pager.regex = compileSearchRegex(x)
+        pager.reverseSearch = true
+        pager.searchPrev()
+      else: discard
+    of CANCEL:
+      case pager.linemode
+      of USERNAME: pager.discardBuffer()
+      of PASSWORD:
+        pager.username = ""
+        pager.discardBuffer()
+      of BUFFER: pager.container.readCanceled()
+      else: discard
+  if lineedit.state in {CANCEL, FINISH}:
+    if pager.lineedit.get == lineedit:
+      pager.clearLineEdit()
+    print('\r')
+    print(EL())
+    pager.displayPage()
 
 # Open a URL prompt and visit the specified URL.
 proc changeLocation(pager: Pager) {.jsfunc.} =
-  var url = pager.container.buffer.location.serialize()
-  pager.statusMode()
-  let status = readLine("URL: ", url, pager.attrs.width, config = pager.config, tty = pager.tty)
-  if status:
-    pager.loadURL(url)
+  var url = pager.container.source.location.serialize()
+  pager.setLineEdit(readLine("URL: ", pager.attrs.width, current = url, config = pager.config, tty = pager.tty), LOCATION)
 
 # Reload the page in a new buffer, then kill the previous buffer.
 proc reloadPage(pager: Pager) {.jsfunc.} =
-  pager.gotoURL(newRequest(pager.container.buffer.location), none(URL), true, pager.container.buffer.contenttype, true)
+  pager.gotoURL(newRequest(pager.container.source.location), none(URL), pager.container.contenttype, pager.container)
 
 proc click(pager: Pager) {.jsfunc.} =
-  #TODO this conflicts with the planned event loop
-  let req = pager.container.buffer.click()
-  if req.issome:
-    pager.gotoURL(req.get, pager.container.buffer.location.some)
-
-proc followRedirect*(pager: Pager)
-
-proc checkAuth*(pager: Pager) =
-  if pager.container != nil and pager.container.needsauth:
-    pager.container.buffer.refreshBuffer()
-    pager.statusMode()
-    var username = ""
-    let ustatus = readLine("Username: ", username, pager.attrs.width, config = pager.config, tty = pager.tty)
-    if not ustatus:
-      pager.container.needsauth = false
-      return
-    pager.statusMode()
-    var password = ""
-    let pstatus = readLine("Password: ", password, pager.attrs.width, hide = true, config = pager.config, tty = pager.tty)
-    if not pstatus:
-      pager.container.needsauth = false
-      return
-    var url = pager.container.buffer.location
-    url.username = username
-    url.password = password
-    pager.gotoURL(newRequest(url), prevurl = some(pager.container.buffer.location), replace = true)
-    pager.followRedirect()
-
-proc followRedirect*(pager: Pager) =
-  while pager.container != nil and pager.container.redirecturl.issome:
-    pager.statusMode()
-    print("Redirecting to ", $pager.container.redirecturl.get)
-    stdout.flushFile()
-    pager.container.buffer.refreshBuffer(true)
-    let redirecturl = pager.container.redirecturl.get
-    pager.container.redirecturl = none(URL)
-    pager.gotoURL(newRequest(redirecturl), prevurl = some(pager.container.buffer.location), replace = true)
-    if pager.container.needsauth:
-      pager.checkAuth()
+  pager.container.click()
+
+proc authorize*(pager: Pager) =
+  pager.setLineEdit(readLine("Username: ", pager.attrs.width, config = pager.config, tty = pager.tty), USERNAME)
+
+proc handleEvent*(pager: Pager, container: Container): bool =
+  let event = container.handleEvent()
+  case event.t
+  of FAIL:
+    pager.deleteContainer(container)
+    if container.retry.len > 0:
+      pager.gotoURL(newRequest(container.retry.pop()), ctype = container.contenttype)
+    else:
+      pager.setStatusMessage("Couldn't load " & $container.source.location & " (error code " & $container.code & ")")
+      pager.displayStatus()
+      pager.displayCursor()
+    if pager.container == nil:
+      return false
+  of SUCCESS:
+    container.render()
+    if container.replace != nil:
+      container.children.add(container.replace.children)
+      for child in container.children:
+        child.parent = container
+      container.replace.children.setLen(0)
+      if container.replace.parent != nil:
+        container.parent = container.replace.parent
+        let n = container.replace.parent.children.find(container.replace)
+        assert n != -1, "Container not a child of its parent"
+        container.parent.children[n] = container
+      if pager.container == container.replace:
+        pager.setContainer(container)
+  of NEEDS_AUTH:
+    if pager.container == container:
+      pager.authorize()
+  of REDIRECT:
+    let redirect = container.redirect.get
+    pager.setStatusMessage("Redirecting to " & $redirect)
+    pager.displayStatus()
+    pager.displayCursor()
+    pager.gotoURL(newRequest(redirect), some(pager.container.source.location), replace = pager.container)
+  of ANCHOR:
+    pager.addContainer(pager.dupeContainer(container, container.redirect))
+  of NO_ANCHOR:
+    pager.setStatusMessage("Couldn't find anchor " & container.redirect.get.anchor)
+    pager.displayStatus()
+    pager.displayCursor()
+  of UPDATE:
+    if container == pager.container:
+      pager.refreshDisplay()
+      pager.refreshStatusMsg()
+      pager.displayPage()
+  of JUMP:
+    if container == pager.container:
+      pager.refreshStatusMsg()
+      pager.displayStatus()
+      pager.displayCursor()
+  of STATUS:
+    if container == pager.container:
+      pager.refreshStatusMsg()
+      pager.displayStatus()
+      pager.displayCursor()
+  of READ_LINE:
+    if container == pager.container:
+      pager.setLineEdit(readLine(event.prompt, pager.bwidth, current = event.value, hide = event.password, config = pager.config, tty = pager.tty), BUFFER)
+  of OPEN:
+    pager.gotoURL(event.request, some(container.source.location))
+  of NO_EVENT: discard
+  return true
 
 proc addPagerModule*(ctx: JSContext) =
   ctx.registerType(Pager)
diff --git a/src/io/buffer.nim b/src/io/buffer.nim
deleted file mode 100644
index f4cce8c4..00000000
--- a/src/io/buffer.nim
+++ /dev/null
@@ -1,1285 +0,0 @@
-import algorithm
-import options
-import os
-import streams
-import tables
-import terminal
-import unicode
-
-import css/cascade
-import css/cssparser
-import css/mediaquery
-import css/sheet
-import css/stylednode
-import config/config
-import html/dom
-import html/tags
-import html/htmlparser
-import io/cell
-import io/lineedit
-import io/loader
-import io/request
-import io/term
-import js/regex
-import layout/box
-import render/renderdocument
-import render/rendertext
-import types/color
-import types/url
-import utils/twtstr
-
-type
-  CursorPosition* = object
-    cursorx*: int
-    cursory*: int
-    xend*: int
-    fromx*: int
-    fromy*: int
-
-  BufferMatch* = object
-    success*: bool
-    x*: int
-    y*: int
-    str*: string
-
-  Buffer* = ref object
-    contenttype*: string
-    title*: string
-    lines*: FlexibleGrid
-    display*: FixedGrid
-    prevdisplay*: FixedGrid
-    statusmsg*: FixedGrid
-    hovertext*: string
-    width*: int
-    height*: int
-    cpos*: CursorPosition
-    attrs*: TermAttributes
-    document*: Document
-    viewport*: Viewport
-    prevstyled*: StyledNode
-    redraw*: bool
-    reshape*: bool
-    nostatus*: bool
-    location*: Url
-    ispipe*: bool
-    istream*: Stream
-    streamclosed*: bool
-    source*: string
-    prevnode*: StyledNode
-    userstyle*: CSSStylesheet
-    loader*: FileLoader
-    marks*: seq[Mark]
-    config*: Config
-    tty: File
-
-proc newBuffer*(config: Config, tty: File): Buffer =
-  new(result)
-  result.attrs = getTermAttributes(stdout)
-  result.width = result.attrs.width
-  result.height = result.attrs.height - 1
-  result.config = config
-  result.loader = newFileLoader()
-
-  result.display = newFixedGrid(result.width, result.height)
-  result.prevdisplay = newFixedGrid(result.width, result.height)
-  result.statusmsg = newFixedGrid(result.width)
-
-func cursorx*(buffer: Buffer): int {.inline.} = buffer.cpos.cursorx
-func cursory*(buffer: Buffer): int {.inline.} = buffer.cpos.cursory
-func fromx*(buffer: Buffer): int {.inline.} = buffer.cpos.fromx
-func fromy*(buffer: Buffer): int {.inline.} = buffer.cpos.fromy
-func xend*(buffer: Buffer): int {.inline.} = buffer.cpos.xend
-
-func generateFullOutput*(buffer: Buffer): string =
-  var x = 0
-  var w = 0
-  var format = newFormat()
-  result &= HVP(1, 1)
-
-  for cell in buffer.display:
-    if x >= buffer.width:
-      result &= EL()
-      result &= "\r\n"
-      x = 0
-      w = 0
-
-    result &= format.processFormat(cell.format)
-    result &= cell.str
-
-    w += cell.width()
-    inc x
-
-  result &= EL()
-  result &= "\r\n"
-
-# generate a sequence of instructions to replace the previous frame with the
-# current one. ideally should be used when small changes are made (e.g. hover
-# changes underlining)
-func generateSwapOutput(buffer: Buffer): string =
-  var format = newFormat()
-  let curr = buffer.display
-  let prev = buffer.prevdisplay
-  var i = 0
-  var x = 0
-  var y = 0
-  var line = ""
-  var lr = false
-  while i < curr.len:
-    if x >= buffer.width:
-      if lr:
-        result &= HVP(y + 1, 1)
-        result &= EL()
-        result &= line
-        lr = false
-      x = 0
-      inc y
-      line = ""
-    lr = lr or (curr[i] != prev[i])
-    line &= format.processFormat(curr[i].format)
-    line &= curr[i].str
-    inc i
-    inc x
-  if lr:
-    result &= HVP(y + 1, 1)
-    result &= EL()
-    result &= line
-    lr = false
-  
-  #TODO maybe fix this
-  #var x = 0
-  #var y = 0
-  #var cx = -1
-  #var cy = -1
-  #var i = 0
-  #var text = ""
-  #while i < max:
-  #  if x >= buffer.width:
-  #    x = 0
-  #    inc y
-
-  #  if curr[i] != prev[i]:
-  #    let currwidth = curr[i].runes.width()
-  #    let prevwidth = prev[i].runes.width()
-  #    if (curr[i].runes.len > 0 or currwidth < prevwidth) and (x != cx or y != cy):
-  #      if text.len > 0:
-  #        result &= text
-  #        text = ""
-  #      result &= HVP(y + 1, x + 1)
-  #      cx = x
-  #      cy = y
-
-  #    text &= format.processFormat(curr[i].format)
-
-  #    text &= $curr[i].runes
-  #    if currwidth < prevwidth:
-  #      var j = 0
-  #      while j < prevwidth - currwidth:
-  #        text &= ' '
-  #        inc j
-  #    if text.len > 0:
-  #      inc cx
-
-  #  inc x
-  #  inc i
-  #if text.len > 0:
-  #  result &= $text
-
-func generateStatusMessage*(buffer: Buffer): string =
-  var format = newFormat()
-  var w = 0
-  for cell in buffer.statusmsg:
-    result &= format.processFormat(cell.format)
-    result &= cell.str
-    w += cell.width()
-  if w < buffer.width:
-    result &= EL()
-
-func numLines(buffer: Buffer): int = buffer.lines.len
-
-func lastVisibleLine(buffer: Buffer): int = min(buffer.fromy + buffer.height, buffer.numLines)
-
-func currentLineWidth(buffer: Buffer): int =
-  return buffer.lines[buffer.cursory].width()
-
-func maxfromy(buffer: Buffer): int = max(buffer.numLines - buffer.height, 0)
-
-func maxfromx(buffer: Buffer): int = max(buffer.currentLineWidth() - buffer.width, 0)
-
-func acursorx(buffer: Buffer): int =
-  return max(0, buffer.cursorx - buffer.fromx)
-
-func acursory(buffer: Buffer): int =
-  return buffer.cursory - buffer.fromy
-
-func cellOrigin(buffer: Buffer, x, y: int): int =
-  let row = y * buffer.width
-  var ox = x
-  while ox > 0 and buffer.display[row + ox].str.len == 0:
-    dec ox
-  return ox
-
-func currentCellOrigin(buffer: Buffer): int =
-  return buffer.cellOrigin(buffer.acursorx, buffer.acursory)
-
-func currentDisplayCell(buffer: Buffer): FixedCell =
-  let row = (buffer.cursory - buffer.fromy) * buffer.width
-  return buffer.display[row + buffer.currentCellOrigin()]
-
-func getLink(node: StyledNode): HTMLAnchorElement =
-  if node == nil:
-    return nil
-  if node.t == STYLED_ELEMENT and node.node != nil and Element(node.node).tagType == TAG_A:
-    return HTMLAnchorElement(node.node)
-  if node.node != nil:
-    return HTMLAnchorElement(node.node.findAncestor({TAG_A}))
-  #TODO ::before links?
-
-const ClickableElements = {
-  TAG_A, TAG_INPUT, TAG_OPTION
-}
-
-func getClickable(styledNode: StyledNode): Element =
-  if styledNode == nil or styledNode.node == nil:
-    return nil
-  if styledNode.t == STYLED_ELEMENT:
-    let element = Element(styledNode.node)
-    if element.tagType in ClickableElements:
-      return element
-  styledNode.node.findAncestor(ClickableElements)
-
-func getCursorClickable(buffer: Buffer): Element =
-  return buffer.currentDisplayCell().node.getClickable()
-
-func currentLine(buffer: Buffer): string =
-  return buffer.lines[buffer.cursory].str
-
-func cursorBytes(buffer: Buffer, y: int, cc = buffer.fromx + buffer.cursorx): int =
-  assert y < buffer.lines.len
-  let line = buffer.lines[y].str
-  var w = 0
-  var i = 0
-  while i < line.len and w < cc:
-    var r: Rune
-    fastRuneAt(line, i, r)
-    w += r.width()
-  return i
-
-func currentCursorBytes(buffer: Buffer, cc = buffer.fromx + buffer.cursorx): int =
-  return buffer.cursorBytes(buffer.cursory, cc)
-
-func currentWidth(buffer: Buffer): int =
-  let line = buffer.currentLine
-  if line.len == 0: return 0
-  var w = 0
-  var i = 0
-  let cc = buffer.fromx + buffer.cursorx
-  var r: Rune
-  fastRuneAt(line, i, r)
-  while i < line.len and w < cc:
-    fastRuneAt(line, i, r)
-    w += r.width()
-  return r.width()
-
-func prevWidth(buffer: Buffer): int =
-  let line = buffer.currentLine
-  if line.len == 0: return 0
-  var w = 0
-  var i = 0
-  let cc = buffer.fromx + buffer.cursorx
-  var pr: Rune
-  var r: Rune
-  fastRuneAt(line, i, r)
-  while i < line.len and w < cc:
-    pr = r
-    fastRuneAt(line, i, r)
-    w += r.width()
-  return pr.width()
-
-func maxScreenWidth(buffer: Buffer): int =
-  for line in buffer.lines[buffer.fromy..buffer.lastVisibleLine - 1]:
-    result = max(line.width(), result)
-
-func atPercentOf(buffer: Buffer): int =
-  if buffer.lines.len == 0: return 100
-  return (100 * (buffer.cursory + 1)) div buffer.numLines
-
-func hasAnchor*(buffer: Buffer, anchor: string): bool =
-  return buffer.document.getElementById(anchor) != nil
-
-func getTitle(buffer: Buffer): string =
-  if buffer.document != nil:
-    result = buffer.document.title
-    if result != "": return result
-  if buffer.ispipe:
-    return "*pipe*"
-  return buffer.location.serialize(excludepassword = true)
-
-proc clearDisplay(buffer: Buffer) =
-  buffer.prevdisplay = buffer.display
-  buffer.display = newFixedGrid(buffer.width, buffer.height)
-
-proc refreshDisplay(buffer: Buffer) =
-  var r: Rune
-  var y = 0 # y position on screen
-  buffer.clearDisplay()
-
-  for by in buffer.fromy..buffer.lastVisibleLine - 1:
-    let line = buffer.lines[by] # by: y position in lines
-    var w = 0 # width of the row so far
-    var i = 0 # byte in line.str
-
-    # Skip cells till buffer.fromx.
-    while w < buffer.fromx and i < line.str.len:
-      fastRuneAt(line.str, i, r)
-      w += r.width()
-
-    let dls = y * buffer.width # starting position of row in display
-
-    # Fill in the gap in case we skipped more cells than fromx mandates (i.e.
-    # we encountered a double-width character.)
-    var k = 0
-    if w > buffer.fromx:
-      while k < w - buffer.fromx:
-        buffer.display[dls + k].str &= ' '
-        inc k
-
-    var cf = line.findFormat(w)
-    var nf = line.findNextFormat(w)
-
-    let startw = w # save this for later
-
-    # Now fill in the visible part of the row.
-    while i < line.str.len:
-      let pw = w
-      fastRuneAt(line.str, i, r)
-      w += r.width()
-      if w > buffer.fromx + buffer.width:
-        break # die on exceeding the width limit
-      if nf.pos != -1 and nf.pos <= pw:
-        cf = nf
-        nf = line.findNextFormat(pw)
-      buffer.display[dls + k].str &= r
-      if cf.pos != -1:
-        buffer.display[dls + k].format = cf.format
-        buffer.display[dls + k].node = cf.node
-      let tk = k + r.width()
-      while k < tk and k < buffer.width - 1:
-        inc k
-
-    # Then, for each cell that has a mark, override its formatting with that
-    # specified by the mark.
-    var l = 0
-    while l < buffer.marks.len and buffer.marks[l].y < by:
-      inc l # linear search to find the first applicable mark
-    let aw = buffer.width - (startw - buffer.fromx) # actual width
-    while l < buffer.marks.len and buffer.marks[l].y == by:
-      let mark = buffer.marks[l]
-      inc l
-      if mark.x >= startw + aw or mark.x + mark.width < startw: continue
-      for i in max(mark.x, startw)..<min(mark.x + mark.width, startw + aw):
-        buffer.display[dls + i - startw].format = mark.format
-
-    inc y
-
-proc setCursorX(buffer: Buffer, x: int, refresh = true, save = true) =
-  if (not refresh) or (buffer.fromx <= x and x < buffer.fromx + buffer.width):
-    buffer.cpos.cursorx = x
-  else:
-    if refresh and buffer.fromx > buffer.cursorx:
-      buffer.cpos.fromx = max(buffer.currentLineWidth() - 1, 0)
-      buffer.cpos.cursorx = buffer.fromx
-    elif x > buffer.cursorx:
-      buffer.cpos.fromx = max(x - buffer.width + 1, 0)
-      buffer.cpos.cursorx = x
-    elif x < buffer.cursorx:
-      buffer.cpos.fromx = x
-      buffer.cpos.cursorx = x
-    buffer.redraw = true
-  if save:
-    buffer.cpos.xend = buffer.cursorx
-
-proc restoreCursorX(buffer: Buffer) =
-  buffer.setCursorX(max(min(buffer.currentLineWidth() - 1, buffer.xend), 0), false, false)
-
-proc setCursorY(buffer: Buffer, y: int) =
-  if buffer.cursory == y:
-    return
-  if y - buffer.fromy >= 0 and y - buffer.height < buffer.fromy:
-    buffer.cpos.cursory = y
-  else:
-    if y > buffer.cursory:
-      buffer.cpos.fromy = max(y - buffer.height + 1, 0)
-    else:
-      buffer.cpos.fromy = min(y, buffer.maxfromy)
-    buffer.cpos.cursory = y
-    buffer.redraw = true
-  buffer.restoreCursorX()
-
-proc centerLine*(buffer: Buffer) =
-  let ny = max(min(buffer.cursory - buffer.height div 2, buffer.numLines - buffer.height), 0)
-  if ny != buffer.fromy:
-    buffer.cpos.fromy = ny
-    buffer.redraw = true
-
-proc setCursorXY*(buffer: Buffer, x, y: int) =
-  let fy = buffer.fromy
-  buffer.setCursorY(max(min(y, buffer.numLines - 1), 0))
-  buffer.setCursorX(max(min(buffer.currentLineWidth(), x), 0))
-  if fy != buffer.fromy:
-    buffer.centerLine()
-
-proc setFromXY*(buffer: Buffer, x, y: int) =
-  buffer.cpos.fromy = max(min(y, buffer.maxfromy), 0)
-  buffer.cpos.fromx = max(min(x, buffer.maxfromx), 0)
-
-proc cursorDown*(buffer: Buffer) =
-  if buffer.cursory < buffer.numLines - 1:
-    buffer.setCursorY(buffer.cursory + 1)
-
-proc cursorUp*(buffer: Buffer) =
-  if buffer.cursory > 0:
-    buffer.setCursorY(buffer.cursory - 1)
-
-proc cursorRight*(buffer: Buffer) =
-  let cellwidth = buffer.currentWidth()
-  if buffer.cursorx + cellwidth < buffer.currentLineWidth():
-    buffer.setCursorX(buffer.cursorx + cellwidth)
-
-proc cursorLeft*(buffer: Buffer) =
-  buffer.setCursorX(max(buffer.cursorx - buffer.prevWidth(), 0))
-
-proc cursorLineBegin*(buffer: Buffer) =
-  buffer.setCursorX(0)
-
-proc cursorLineEnd*(buffer: Buffer) =
-  buffer.setCursorX(max(buffer.currentLineWidth() - 1, 0))
-
-proc cursorNextWord*(buffer: Buffer) =
-  var r: Rune
-  var b = buffer.currentCursorBytes()
-  var x = buffer.cursorx
-  while b < buffer.currentLine.len:
-    let pb = b
-    fastRuneAt(buffer.currentLine, b, r)
-    if r.breaksWord():
-      b = pb
-      break
-    x += r.width()
-
-  while b < buffer.currentLine.len:
-    let pb = b
-    fastRuneAt(buffer.currentLine, b, r)
-    if not r.breaksWord():
-      b = pb
-      break
-    x += r.width()
-
-  if b < buffer.currentLine.len:
-    buffer.setCursorX(x)
-  else:
-    if buffer.cursory < buffer.numLines - 1:
-      buffer.cursorDown()
-      buffer.cursorLineBegin()
-    else:
-      buffer.cursorLineEnd()
-
-proc cursorPrevWord*(buffer: Buffer) =
-  var b = buffer.currentCursorBytes()
-  var x = buffer.cursorx
-  if buffer.currentLine.len > 0:
-    b = min(b, buffer.currentLine.len - 1)
-    while b >= 0:
-      let (r, o) = lastRune(buffer.currentLine, b)
-      if r.breaksWord():
-        break
-      b -= o
-      x -= r.width()
-
-    while b >= 0:
-      let (r, o) = lastRune(buffer.currentLine, b)
-      if not r.breaksWord():
-        break
-      b -= o
-      x -= r.width()
-  else:
-    b = -1
-
-  if b >= 0:
-    buffer.setCursorX(x)
-  else:
-    if buffer.cursory > 0:
-      buffer.cursorUp()
-      buffer.cursorLineEnd()
-    else:
-      buffer.cursorLineBegin()
-
-proc cursorNextLink*(buffer: Buffer) =
-  let line = buffer.lines[buffer.cursory]
-  var i = line.findFormatN(buffer.cursorx) - 1
-  var link: Element = nil
-  if i >= 0:
-    link = line.formats[i].node.getClickable()
-  inc i
-
-  while i < line.formats.len:
-    let format = line.formats[i]
-    let fl = format.node.getClickable()
-    if fl != nil and fl != link:
-      buffer.setCursorX(format.pos)
-      return
-    inc i
-
-  for y in (buffer.cursory + 1)..(buffer.numLines - 1):
-    let line = buffer.lines[y]
-    i = 0
-    while i < line.formats.len:
-      let format = line.formats[i]
-      let fl = format.node.getClickable()
-      if fl != nil and fl != link:
-        buffer.setCursorXY(format.pos, y)
-        return
-      inc i
-
-proc cursorPrevLink*(buffer: Buffer) =
-  let line = buffer.lines[buffer.cursory]
-  var i = line.findFormatN(buffer.cursorx) - 1
-  var link: Element = nil
-  if i >= 0:
-    link = line.formats[i].node.getClickable()
-  dec i
-
-  var ly = 0 #last y
-  var lx = 0 #last x
-  template link_beginning() =
-    #go to beginning of link
-    ly = y #last y
-    lx = format.pos #last x
-
-    #on the current line
-    let line = buffer.lines[y]
-    while i >= 0:
-      let format = line.formats[i]
-      let nl = format.node.getClickable()
-      if nl == fl:
-        lx = format.pos
-      dec i
-
-    #on previous lines
-    for iy in countdown(ly - 1, 0):
-      let line = buffer.lines[iy]
-      i = line.formats.len - 1
-      while i >= 0:
-        let format = line.formats[i]
-        let nl = format.node.getClickable()
-        if nl == fl:
-          ly = iy
-          lx = format.pos
-        dec i
-
-  while i >= 0:
-    let format = line.formats[i]
-    let fl = format.node.getClickable()
-    if fl != nil and fl != link:
-      let y = buffer.cursory
-      link_beginning
-      buffer.setCursorXY(lx, ly)
-      return
-    dec i
-
-  for y in countdown(buffer.cursory - 1, 0):
-    let line = buffer.lines[y]
-    i = line.formats.len - 1
-    while i >= 0:
-      let format = line.formats[i]
-      let fl = format.node.getClickable()
-      if fl != nil and fl != link:
-        link_beginning
-        buffer.setCursorXY(lx, ly)
-        return
-      dec i
-
-proc cursorFirstLine*(buffer: Buffer) =
-  buffer.setCursorY(0)
-
-proc cursorLastLine*(buffer: Buffer) =
-  buffer.setCursorY(buffer.numLines - 1)
-
-proc cursorTop*(buffer: Buffer) =
-  buffer.setCursorY(buffer.fromy)
-
-proc cursorMiddle*(buffer: Buffer) =
-  buffer.setCursorY(min(buffer.fromy + (buffer.height - 2) div 2, buffer.numLines - 1))
-
-proc cursorBottom*(buffer: Buffer) =
-  buffer.setCursorY(min(buffer.fromy + buffer.height - 1, buffer.numLines - 1))
-
-proc cursorLeftEdge*(buffer: Buffer) =
-  buffer.setCursorX(buffer.fromx)
-
-proc cursorVertMiddle*(buffer: Buffer) =
-  buffer.setCursorX(min(buffer.fromx + (buffer.width - 2) div 2, buffer.currentLineWidth))
-
-proc cursorRightEdge*(buffer: Buffer) =
-  buffer.setCursorX(min(buffer.fromx + buffer.width - 1, buffer.currentLineWidth))
-
-proc halfPageUp*(buffer: Buffer) =
-  buffer.cpos.cursory = max(buffer.cursory - buffer.height div 2 + 1, 0)
-  let nfy = max(0, buffer.fromy - buffer.height div 2 + 1)
-  if nfy != buffer.fromy:
-    buffer.cpos.fromy = nfy
-    buffer.redraw = true
-  buffer.restoreCursorX()
-
-proc halfPageDown*(buffer: Buffer) =
-  buffer.cpos.cursory = min(buffer.cursory + buffer.height div 2 - 1, buffer.numLines - 1)
-  let nfy = min(max(buffer.numLines - buffer.height, 0), buffer.fromy + buffer.height div 2 - 1)
-  if nfy != buffer.fromy:
-    buffer.cpos.fromy = nfy
-    buffer.redraw = true
-  buffer.restoreCursorX()
-
-proc pageUp*(buffer: Buffer) =
-  buffer.cpos.cursory = max(buffer.cursory - buffer.height, 0)
-  let nfy = max(0, buffer.fromy - buffer.height)
-  if nfy != buffer.fromy:
-    buffer.cpos.fromy = nfy
-    buffer.redraw = true
-  buffer.restoreCursorX()
-
-proc pageDown*(buffer: Buffer) =
-  buffer.cpos.cursory = min(buffer.cursory + buffer.height, buffer.numLines - 1)
-  let nfy = min(buffer.fromy + buffer.height, max(buffer.numLines - buffer.height, 0))
-  if nfy != buffer.fromy:
-    buffer.cpos.fromy = nfy
-    buffer.redraw = true
-  buffer.restoreCursorX()
-
-proc pageLeft*(buffer: Buffer) =
-  buffer.cpos.cursorx = max(buffer.cursorx - buffer.width, 0)
-  let nfx = max(0, buffer.fromx - buffer.width)
-  if nfx != buffer.fromx:
-    buffer.cpos.fromx = nfx
-    buffer.redraw = true
-
-proc pageRight*(buffer: Buffer) =
-  buffer.cpos.cursorx = min(buffer.fromx, buffer.currentLineWidth())
-  let nfx = min(max(buffer.maxScreenWidth() - buffer.width, 0), buffer.fromx + buffer.width)
-  if nfx != buffer.fromx:
-    buffer.cpos.fromx = nfx
-    buffer.redraw = true
-
-proc scrollDown*(buffer: Buffer) =
-  if buffer.fromy + buffer.height < buffer.numLines:
-    inc buffer.cpos.fromy
-    if buffer.fromy > buffer.cursory:
-      buffer.cursorDown()
-    buffer.redraw = true
-  else:
-    buffer.cursorDown()
-
-proc scrollUp*(buffer: Buffer) =
-  if buffer.fromy > 0:
-    dec buffer.cpos.fromy
-    if buffer.fromy + buffer.height <= buffer.cursory:
-      buffer.cursorUp()
-    buffer.redraw = true
-  else:
-    buffer.cursorUp()
-
-proc scrollRight*(buffer: Buffer) =
-  if buffer.fromx + buffer.width < buffer.maxScreenWidth():
-    inc buffer.cpos.fromx
-    buffer.redraw = true
-
-proc scrollLeft*(buffer: Buffer) =
-  if buffer.fromx > 0:
-    dec buffer.cpos.fromx
-    if buffer.cursorx < buffer.fromx:
-      buffer.setCursorX(max(buffer.currentLineWidth() - 1, 0))
-    buffer.redraw = true
-
-proc gotoAnchor*(buffer: Buffer) =
-  if buffer.document == nil: return
-  let anchor = buffer.document.getElementById(buffer.location.anchor)
-  if anchor == nil: return
-  for y in 0..<buffer.numLines:
-    let line = buffer.lines[y]
-    var i = 0
-    while i < line.formats.len:
-      let format = line.formats[i]
-      if format.node != nil and anchor in format.node.node:
-        buffer.setCursorY(y)
-        buffer.centerLine()
-        buffer.setCursorX(format.pos)
-        return
-      inc i
-
-proc addMark*(buffer: Buffer, x, y, width: int): Mark =
-  assert y < buffer.lines.len
-  var format = newFormat()
-  format.bgcolor = buffer.config.markcolor
-  result = Mark(x: x, y: y, width: width, format: format)
-  let previ = upperBound(buffer.marks, y, (proc(a: Mark, b: int): int = cmp(a.y, b)))
-  buffer.marks.insert(result, previ)
-
-proc removeMark*(buffer: Buffer, mark: Mark) =
-  let i = buffer.marks.find(mark)
-  if i != -1:
-    buffer.marks.delete(i)
-
-proc cursorNextMatch(buffer: Buffer, regex: Regex, sy, ey: int, wrap = false): BufferMatch =
-  for y in sy..ey:
-    let s = if y == buffer.cursory and not wrap:
-      buffer.currentCursorBytes(buffer.fromx + buffer.cursorx + 1)
-    else:
-      0
-    let res = regex.exec(buffer.lines[y].str, s)
-    if res.success and res.captures.len > 0:
-      let cap = res.captures[0]
-      let x = buffer.lines[y].str.width(cap.s)
-      buffer.setCursorXY(x, y)
-      result.success = true
-      result.y = y
-      result.x = x
-      result.str = buffer.lines[y].str.substr(cap.s, cap.e - 1)
-      return
-
-proc cursorNextMatch*(buffer: Buffer, regex: Regex, wrap = true): BufferMatch =
-  let s = buffer.currentCursorBytes(buffer.fromx + buffer.cursorx + 1)
-  var low = buffer.cursory
-  if s == buffer.lines.len:
-    low += 1
-  if low > buffer.lines.high:
-    low = 0
-  let ret = buffer.cursorNextMatch(regex, low, buffer.lines.high)
-  if ret.success:
-    return ret
-  if wrap:
-    return buffer.cursorNextMatch(regex, 0, low, true)
-
-proc cursorPrevMatch*(buffer: Buffer, regex: Regex, sy, ey: int, wrap = false): BufferMatch =
-  for y in countdown(sy, ey):
-    let e = if y == buffer.cursory and not wrap:
-      buffer.currentCursorBytes()
-    else:
-      buffer.lines[y].str.len + 1
-    let res = regex.exec(buffer.lines[y].str)
-    if res.success:
-      for i in countdown(res.captures.high, 0):
-        let cap = res.captures[i]
-        if cap.s < e:
-          let x = buffer.lines[y].str.width(cap.s)
-          buffer.setCursorXY(x, y)
-          result.success = true
-          result.y = y
-          result.x = x
-          result.str = buffer.lines[y].str.substr(cap.s, cap.e - 1)
-          return
-
-proc cursorPrevMatch*(buffer: Buffer, regex: Regex, wrap = true): BufferMatch =
-  var high = buffer.cursory
-  if buffer.fromx + buffer.cursorx - 1 < 0:
-    high -= 1
-  if high < 0:
-    high = buffer.lines.high
-  let ret = buffer.cursorPrevMatch(regex, high, 0)
-  if ret.success:
-    return ret
-  if wrap:
-    return buffer.cursorPrevMatch(regex, buffer.lines.high, high)
-
-proc refreshTermAttrs*(buffer: Buffer): bool =
-  let newAttrs = getTermAttributes(stdout)
-  if newAttrs != buffer.attrs:
-    buffer.attrs = newAttrs
-    buffer.width = newAttrs.width
-    buffer.height = newAttrs.height - 1
-    return true
-  return false
-
-proc updateCursor(buffer: Buffer) =
-  if buffer.fromy > buffer.lastVisibleLine - 1:
-    buffer.cpos.fromy = 0
-    buffer.cpos.cursory = buffer.lastVisibleLine - 1
-
-  if buffer.cursory >= buffer.lines.len:
-    buffer.cpos.cursory = max(0, buffer.lines.len - 1)
-
-  if buffer.lines.len == 0:
-    buffer.cpos.cursory = 0
-
-proc updateHover(buffer: Buffer) =
-  let thisnode = buffer.currentDisplayCell().node
-  let prevnode = buffer.prevnode
-
-  if thisnode != prevnode and (thisnode == nil or prevnode == nil or thisnode.node != prevnode.node):
-    for styledNode in thisnode.branch:
-      if styledNode.t == STYLED_ELEMENT and styledNode.node != nil:
-        let elem = Element(styledNode.node)
-        if not elem.hover:
-          elem.hover = true
-          buffer.reshape = true
-
-    let link = thisnode.getLink()
-    if link != nil:
-      buffer.hovertext = link.href
-    else:
-      buffer.hovertext = ""
-
-    for styledNode in prevnode.branch:
-      if styledNode.t == STYLED_ELEMENT and styledNode.node != nil:
-        let elem = Element(styledNode.node)
-        if elem.hover:
-          elem.hover = false
-          buffer.reshape = true
-
-  buffer.prevnode = thisnode
-
-proc loadResource(buffer: Buffer, document: Document, elem: HTMLLinkElement) =
-  let url = parseUrl(elem.href, document.location.some)
-  if url.isSome:
-    let url = url.get
-    if url.scheme == buffer.location.scheme:
-      let media = elem.media
-      if media != "":
-        let media = parseMediaQueryList(parseListOfComponentValues(newStringStream(media)))
-        if not media.applies(): return
-      let fs = buffer.loader.doRequest(newRequest(url))
-      if fs.body != nil and fs.contenttype == "text/css":
-        elem.sheet = parseStylesheet(fs.body)
-
-proc loadResources(buffer: Buffer, document: Document) =
-  var stack: seq[Element]
-  if document.html != nil:
-    stack.add(document.html)
-  while stack.len > 0:
-    let elem = stack.pop()
-
-    if elem.tagType == TAG_LINK:
-      let elem = HTMLLinkElement(elem)
-      if elem.rel == "stylesheet":
-        buffer.loadResource(document, elem)
-
-    for child in elem.children_rev:
-      stack.add(child)
-
-proc load*(buffer: Buffer) =
-  case buffer.contenttype
-  of "text/html":
-    if not buffer.streamclosed:
-      buffer.source = buffer.istream.readAll()
-      buffer.istream.close()
-      buffer.istream = newStringStream(buffer.source)
-      buffer.document = parseHTML5(buffer.istream)
-      buffer.streamclosed = true
-    else:
-      buffer.document = parseHTML5(newStringStream(buffer.source))
-    buffer.document.location = buffer.location
-    buffer.loadResources(buffer.document)
-  else:
-    if not buffer.streamclosed:
-      buffer.source = buffer.istream.readAll()
-      buffer.istream.close()
-      buffer.streamclosed = true
-    buffer.lines = renderPlainText(buffer.source)
-
-proc render*(buffer: Buffer) =
-  case buffer.contenttype
-  of "text/html":
-    if buffer.viewport == nil:
-      buffer.viewport = Viewport(term: buffer.attrs)
-    if buffer.userstyle == nil:
-      buffer.userstyle = buffer.config.stylesheet.parseStylesheet()
-    let ret = renderDocument(buffer.document, buffer.attrs, buffer.userstyle, buffer.viewport, buffer.prevstyled)
-    buffer.lines = ret[0]
-    buffer.prevstyled = ret[1]
-  else: discard
-  buffer.updateCursor()
-
-proc cursorBufferPos(buffer: Buffer) =
-  let x = buffer.acursorx
-  let y = buffer.acursory
-  print(HVP(y + 1, x + 1))
-
-proc clearStatusMessage(buffer: Buffer) =
-  buffer.statusmsg = newFixedGrid(buffer.width)
-
-proc writeStatusMessage(buffer: Buffer, str: string, format: Format = Format()) =
-  buffer.clearStatusMessage()
-  var i = 0
-  for r in str.runes:
-    i += r.width()
-    if i >= buffer.statusmsg.len:
-      buffer.statusmsg[^1].str = "$"
-      break
-    buffer.statusmsg[i].str &= r
-    buffer.statusmsg[i].format = format
-
-proc statusMsgForBuffer(buffer: Buffer) =
-  var msg = $(buffer.cursory + 1) & "/" & $buffer.numLines & " (" &
-            $buffer.atPercentOf() & "%) " & "<" & buffer.title & ">"
-  if buffer.hovertext.len > 0:
-    msg &= " " & buffer.hovertext
-  var format: Format
-  format.reverse = true
-  buffer.writeStatusMessage(msg, format)
-
-proc setStatusMessage*(buffer: Buffer, str: string) =
-  buffer.writeStatusMessage(str)
-  buffer.nostatus = true
-
-proc lineInfo*(buffer: Buffer) =
-    buffer.setStatusMessage("line " & $(buffer.cursory + 1) & "/" & $buffer.numLines & " col " & $(buffer.cursorx + 1) & "/" & $buffer.currentLineWidth() & " x: " & $buffer.currentCursorBytes())
-
-proc displayBufferSwapOutput(buffer: Buffer) =
-  print(buffer.generateSwapOutput())
-
-proc displayBuffer(buffer: Buffer) =
-  print(buffer.generateFullOutput())
-
-proc displayStatusMessage*(buffer: Buffer) =
-  print(HVP(buffer.height + 1, 1))
-  print(SGR())
-  print(buffer.generateStatusMessage())
-  print(SGR())
-
-# https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-the-form-data-set
-proc constructEntryList(form: HTMLFormElement, submitter: Element = nil, encoding: string = ""): seq[tuple[name, value: string]] =
-  if form.constructingentrylist:
-    return
-  form.constructingentrylist = true
-
-  var entrylist: seq[tuple[name, value: string]]
-  for field in form.controls:
-    if field.findAncestor({TAG_DATALIST}) != nil or
-        field.attrb("disabled") or
-        field.isButton() and Element(field) != submitter:
-      continue
-
-    if field.tagType == TAG_INPUT:
-      let field = HTMLInputElement(field)
-      if field.inputType == INPUT_IMAGE:
-        let name = if field.attr("name") != "":
-          field.attr("name") & '.'
-        else:
-          ""
-        entrylist.add((name & 'x', $field.xcoord))
-        entrylist.add((name & 'y', $field.ycoord))
-        continue
-
-    #TODO custom elements
-
-    let name = field.attr("name")
-
-    if name == "":
-      continue
-
-    if field.tagType == TAG_SELECT:
-      let field = HTMLSelectElement(field)
-      for option in field.options:
-        if option.selected or option.disabled:
-          entrylist.add((name, option.value))
-    elif field.tagType == TAG_INPUT and HTMLInputElement(field).inputType in {INPUT_CHECKBOX, INPUT_RADIO}:
-      let value = if field.attr("value") != "":
-        field.attr("value")
-      else:
-        "on"
-      entrylist.add((name, value))
-    elif field.tagType == TAG_INPUT and HTMLInputElement(field).inputType == INPUT_FILE:
-      #TODO file
-      discard
-    elif field.tagType == TAG_INPUT and HTMLInputElement(field).inputType == INPUT_HIDDEN and name.equalsIgnoreCase("_charset_"):
-      let charset = if encoding != "":
-        encoding
-      else:
-        "UTF-8"
-      entrylist.add((name, charset))
-    else:
-      if field.tagType == TAG_INPUT:
-        entrylist.add((name, HTMLInputElement(field).value))
-      else:
-        assert false
-    if field.tagType == TAG_TEXTAREA or
-        field.tagType == TAG_INPUT and HTMLInputElement(field).inputType in {INPUT_TEXT, INPUT_SEARCH}:
-      if field.attr("dirname") != "":
-        let dirname = field.attr("dirname")
-        let dir = "ltr" #TODO bidi
-        entrylist.add((dirname, dir))
-
-  form.constructingentrylist = false
-  return entrylist
-
-#https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart/form-data-encoding-algorithm
-proc makeCRLF(s: string): string =
-  result = newStringOfCap(s.len)
-  var i = 0
-  while i < s.len - 1:
-    if s[i] == '\r' and s[i + 1] != '\n':
-      result &= '\r'
-      result &= '\n'
-    elif s[i] != '\r' and s[i + 1] == '\n':
-      result &= s[i]
-      result &= '\r'
-      result &= '\n'
-      inc i
-    else:
-      result &= s[i]
-    inc i
-
-proc serializeMultipartFormData(kvs: seq[(string, string)]): MimeData =
-  for it in kvs:
-    let name = makeCRLF(it[0])
-    let value = makeCRLF(it[1])
-    result[name] = value
-
-proc serializePlainTextFormData(kvs: seq[(string, string)]): string =
-  for it in kvs:
-    let (name, value) = it
-    result &= name
-    result &= '='
-    result &= value
-    result &= "\r\n"
-
-proc submitForm(form: HTMLFormElement, submitter: Element): Option[Request] =
-  let entrylist = form.constructEntryList(submitter)
-
-  let action = if submitter.action() == "":
-    $form.document.location
-  else:
-    submitter.action()
-
-  let url = parseUrl(action, submitter.document.baseUrl.some)
-  if url.isnone:
-    return none(Request)
-
-  var parsedaction = url.get
-  let scheme = parsedaction.scheme
-  let enctype = submitter.enctype()
-  let formmethod = submitter.formmethod()
-  if formmethod == FORM_METHOD_DIALOG:
-    #TODO
-    return none(Request)
-  let httpmethod = if formmethod == FORM_METHOD_GET:
-    HTTP_GET
-  else:
-    assert formmethod == FORM_METHOD_POST
-    HTTP_POST
-
-  #let target = if submitter.isSubmitButton() and submitter.attrb("formtarget"):
-  #  submitter.attr("formtarget")
-  #else:
-  #  submitter.target()
-  #let noopener = true #TODO
-
-  template mutateActionUrl() =
-    let query = serializeApplicationXWWWFormUrlEncoded(entrylist)
-    parsedaction.query = query.some
-    return newRequest(parsedaction, httpmethod).some
-
-  template submitAsEntityBody() =
-    var mimetype: string
-    var body = none(string)
-    var multipart = none(MimeData)
-    case enctype
-    of FORM_ENCODING_TYPE_URLENCODED:
-      body = serializeApplicationXWWWFormUrlEncoded(entrylist).some
-      mimeType = $enctype
-    of FORM_ENCODING_TYPE_MULTIPART:
-      multipart = serializeMultipartFormData(entrylist).some
-      mimetype = $enctype
-    of FORM_ENCODING_TYPE_TEXT_PLAIN:
-      body = serializePlainTextFormData(entrylist).some
-      mimetype = $enctype
-    return newRequest(parsedaction, httpmethod, {"Content-Type": mimetype}, body, multipart).some
-
-  template getActionUrl() =
-    return newRequest(parsedaction).some
-
-  case scheme
-  of "http", "https":
-    if formmethod == FORM_METHOD_GET:
-      mutateActionUrl
-    else:
-      assert formmethod == FORM_METHOD_POST
-      submitAsEntityBody
-  of "ftp":
-    getActionUrl
-  of "data":
-    if formmethod == FORM_METHOD_GET:
-      mutateActionUrl
-    else:
-      assert formmethod == FORM_METHOD_POST
-      getActionUrl
-
-proc click*(buffer: Buffer): Option[Request] =
-  let clickable = buffer.getCursorClickable()
-  if clickable != nil:
-    template set_focus(e: Element) =
-      if buffer.document.focus != e:
-        buffer.document.focus = e
-        buffer.reshape = true
-    template restore_focus =
-      if buffer.document.focus != nil:
-        buffer.document.focus = nil
-        buffer.reshape = true
-    case clickable.tagType
-    of TAG_SELECT:
-      set_focus clickable
-    of TAG_A:
-      restore_focus
-      let url = parseUrl(HTMLAnchorElement(clickable).href, clickable.document.baseUrl.some)
-      if url.issome:
-        return newRequest(url.get, HTTP_GET).some
-    of TAG_OPTION:
-      let option = HTMLOptionElement(clickable)
-      let select = option.select
-      if select != nil:
-        if buffer.document.focus == select:
-          # select option
-          if not select.attrb("multiple"):
-            for option in select.options:
-              option.selected = false
-          option.selected = true
-          restore_focus
-        else:
-          # focus on select
-          set_focus select
-    of TAG_INPUT:
-      restore_focus
-      let input = HTMLInputElement(clickable)
-      case input.inputType
-      of INPUT_SEARCH:
-        var value = input.value
-        print(HVP(buffer.height + 1, 1))
-        print(EL())
-        let status = readLine("SEARCH: ", value, buffer.width, {'\r', '\n'}, config = buffer.config, tty = buffer.tty)
-        if status:
-          input.value = value
-          input.invalid = true
-          buffer.reshape = true
-        if input.form != nil:
-          let submitaction = submitForm(input.form, input)
-          return submitaction
-      of INPUT_TEXT, INPUT_PASSWORD:
-        var value = input.value
-        print(HVP(buffer.height + 1, 1))
-        print(EL())
-        let status = readLine("TEXT: ", value, buffer.width, {'\r', '\n'}, input.inputType == INPUT_PASSWORD, config = buffer.config, tty = buffer.tty)
-        if status:
-          input.value = value
-          input.invalid = true
-          buffer.reshape = true
-      of INPUT_FILE:
-        var path = if input.file.issome:
-          input.file.get.path.serialize_unicode()
-        else:
-          ""
-        print(HVP(buffer.height + 1, 1))
-        print(EL())
-        let status = readLine("Filename: ", path, buffer.width, {'\r', '\n'}, config = buffer.config, tty = buffer.tty)
-        if status:
-          let cdir = parseUrl("file://" & getCurrentDir() & DirSep)
-          let path = parseUrl(path, cdir)
-          if path.issome:
-            input.file = path
-            input.invalid = true
-            buffer.reshape = true
-      of INPUT_CHECKBOX:
-        input.checked = not input.checked
-        input.invalid = true
-        buffer.reshape = true
-      of INPUT_RADIO:
-        for radio in input.radiogroup:
-          radio.checked = false
-          radio.invalid = true
-        input.checked = true
-        input.invalid = true
-        buffer.reshape = true
-      of INPUT_RESET:
-        if input.form != nil:
-          input.form.reset()
-          buffer.reshape = true
-      of INPUT_SUBMIT, INPUT_BUTTON:
-        if input.form != nil:
-          let submitaction = submitForm(input.form, input)
-          return submitaction
-      else:
-        restore_focus
-    else:
-      restore_focus
-
-proc setupBuffer*(buffer: Buffer) =
-  buffer.load()
-  buffer.render()
-  buffer.gotoAnchor()
-  buffer.redraw = true
-
-proc dupeBuffer*(buffer: Buffer, location = none(URL)): Buffer =
-  let clone = newBuffer(buffer.config, buffer.tty)
-  clone.contenttype = buffer.contenttype
-  clone.ispipe = buffer.ispipe
-  if location.isSome:
-    clone.location = location.get
-  else:
-    clone.location = buffer.location
-  clone.istream = newStringStream(buffer.source)
-  clone.setupBuffer()
-  return clone
-
-proc drawBuffer*(buffer: Buffer) =
-  var format = newFormat()
-  for line in buffer.lines:
-    if line.formats.len == 0:
-      print(line.str & "\n")
-    else:
-      var x = 0
-      var i = 0
-      for f in line.formats:
-        var outstr = ""
-        #assert f.pos < line.str.width(), "fpos " & $f.pos & "\nstr" & line.str & "\n"
-        while x < f.pos:
-          var r: Rune
-          fastRuneAt(line.str, i, r)
-          outstr &= r
-          x += r.width()
-        print(outstr)
-        print(format.processFormat(f.format))
-      print(line.str.substr(i))
-      print(format.processFormat(newFormat()))
-      print("\n")
-
-proc refreshTitle*(buffer: Buffer) =
-  buffer.title = buffer.getTitle()
-
-proc refreshBuffer*(buffer: Buffer, peek = false): bool {.discardable.} =
-  buffer.refreshTitle()
-  stdout.hideCursor()
-
-  if buffer.refreshTermAttrs():
-    buffer.redraw = true
-    buffer.reshape = true
-
-  if buffer.redraw:
-    buffer.refreshDisplay()
-    buffer.displayBuffer()
-    #result = true
-    buffer.redraw = false
-
-  if not peek:
-    buffer.updateHover()
-
-  if buffer.reshape:
-    buffer.render()
-    buffer.reshape = false
-    buffer.refreshDisplay()
-    buffer.displayBufferSwapOutput()
-    #result = true
-
-  if not peek:
-    if not buffer.nostatus:
-      buffer.statusMsgForBuffer()
-    else:
-      buffer.nostatus = false
-    buffer.displayStatusMessage()
-    buffer.cursorBufferPos()
-  stdout.showCursor()
diff --git a/src/io/lineedit.nim b/src/io/lineedit.nim
index 3336d7ac..2d7da512 100644
--- a/src/io/lineedit.nim
+++ b/src/io/lineedit.nim
@@ -4,27 +4,30 @@ import strutils
 import sequtils
 import sugar
 
+import bindings/quickjs
 import config/config
-import io/term
+import js/javascript
 import utils/twtstr
 
-type LineState* = object
-  news*: seq[Rune]
-  prompt*: string
-  current: string
-  s: string
-  feedNext: bool
-  escNext: bool
-  cursor: int
-  shift: int
-  minlen: int
-  maxlen: int
-  displen: int
-  disallowed: set[char]
-  hide: bool
-  config: Config #TODO get rid of this
-  tty: File
-  callback: proc(state: var LineState): bool
+type
+  LineEditState* = enum
+    EDIT, FINISH, CANCEL
+
+  LineEdit* = ref object
+    news*: seq[Rune]
+    prompt*: string
+    current: string
+    state*: LineEditState
+    escNext*: bool
+    cursor: int
+    shift: int
+    minlen: int
+    maxlen: int
+    displen: int
+    disallowed: set[char]
+    hide: bool
+    config: Config #TODO get rid of this
+    tty: File
 
 func lwidth(r: Rune): int =
   if r.isControlChar():
@@ -52,57 +55,55 @@ func lwidth(s: seq[Rune], min: int): int =
     result += lwidth(s[i])
     inc i
 
-template kill(state: LineState, i: int) =
-  state.space(i)
-  state.backward(i)
+template kill0(edit: LineEdit, i: int) =
+  edit.space(i)
+  edit.backward0(i)
 
-template kill(state: LineState) =
-  let w = min(state.news.lwidth(state.cursor), state.displen)
-  state.kill(w)
+template kill0(edit: LineEdit) =
+  let w = min(edit.news.lwidth(edit.cursor), edit.displen)
+  edit.kill0(w)
 
-proc backward(state: LineState, i: int) =
+proc backward0(state: LineEdit, i: int) =
   if i > 0:
     if i == 1:
       print('\b')
     else:
       cursorBackward(i)
 
-proc forward(state: LineState, i: int) =
+proc forward0(state: LineEdit, i: int) =
   if i > 0:
     cursorForward(i)
 
-proc begin(state: LineState) =
+proc begin0(state: LineEdit) =
   print('\r')
-  state.forward(state.minlen)
+  state.forward0(state.minlen)
 
-proc space(state: LineState, i: int) =
+proc space(edit: LineEdit, i: int) =
   print(' '.repeat(i))
 
-proc redraw(state: var LineState) =
+proc redraw(state: LineEdit) =
   var dispw = state.news.lwidth(state.shift, state.shift + state.displen)
   if state.shift + state.displen > state.news.len:
     state.displen = state.news.len - state.shift
   while dispw > state.maxlen - 1:
     dispw -= state.news[state.shift + state.displen - 1].lwidth()
     dec state.displen
-
-  state.begin()
+  state.begin0()
   let os = state.news.substr(state.shift, state.shift + state.displen)
   if state.hide:
     printesc('*'.repeat(os.lwidth()))
   else:
     printesc($os)
   state.space(max(state.maxlen - state.minlen - os.lwidth(), 0))
+  state.begin0()
+  state.forward0(state.news.lwidth(state.shift, state.cursor))
 
-  state.begin()
-  state.forward(state.news.lwidth(state.shift, state.cursor))
-
-proc zeroShiftRedraw(state: var LineState) =
+proc zeroShiftRedraw(state: LineEdit) =
   state.shift = 0
   state.displen = state.maxlen - 1
   state.redraw()
 
-proc fullRedraw(state: var LineState) =
+proc fullRedraw*(state: LineEdit) =
   state.displen = state.maxlen - 1
   if state.cursor > state.shift:
     var shiftw = state.news.lwidth(state.shift, state.cursor)
@@ -111,10 +112,9 @@ proc fullRedraw(state: var LineState) =
       shiftw -= state.news[state.shift].lwidth()
   else:
     state.shift = max(state.cursor - 1, 0)
-
   state.redraw()
 
-proc insertCharseq(state: var LineState, cs: var seq[Rune], disallowed: set[char]) =
+proc insertCharseq(state: LineEdit, cs: var seq[Rune], disallowed: set[char]) =
   let escNext = state.escNext
   cs.keepIf((r) => (escNext or not r.isControlChar) and not (r.isAscii and char(r) in disallowed))
   state.escNext = false
@@ -133,169 +133,165 @@ proc insertCharseq(state: var LineState, cs: var seq[Rune], disallowed: set[char
     state.cursor += cs.len
     state.fullRedraw()
 
-proc readLine(state: var LineState): bool =
-  printesc(state.prompt)
-  if state.hide:
-    printesc('*'.repeat(state.current.lwidth()))
-  else:
-    printesc(state.current)
+proc cancel*(edit: LineEdit) {.jsfunc.} =
+  edit.state = CANCEL
 
-  while true:
-    if not state.feedNext:
-      state.s = ""
-    else:
-      state.feedNext = false
-
-    restoreStdin(state.tty.getFileHandle())
-    let c = state.tty.readChar()
-    state.s &= c
-
-    var action = getLinedAction(state.config, state.s)
-    if state.escNext:
-      action = NO_ACTION
-    case action
-    of ACTION_LINED_CANCEL:
-      return false
-    of ACTION_LINED_SUBMIT:
-      return true
-    of ACTION_LINED_BACKSPACE:
-      if state.cursor > 0:
-        let w = state.news[state.cursor - 1].lwidth()
-        state.news.delete(state.cursor - 1..state.cursor - 1)
-        dec state.cursor
-        if state.cursor == state.news.len and state.shift == 0:
-          state.backward(w)
-          state.kill(w)
-        else:
-          state.fullRedraw()
-    of ACTION_LINED_DELETE:
-      if state.cursor >= 0 and state.cursor < state.news.len:
-        let w = state.news[state.cursor].lwidth()
-        state.news.delete(state.cursor..state.cursor)
-        if state.cursor == state.news.len and state.shift == 0:
-          state.kill(w)
-        else:
-          state.fullRedraw()
-    of ACTION_LINED_ESC:
-      state.escNext = true
-    of ACTION_LINED_CLEAR:
-      if state.cursor > 0:
-        state.news.delete(0..state.cursor - 1)
-        state.cursor = 0
-        state.zeroShiftRedraw()
-    of ACTION_LINED_KILL:
-      if state.cursor < state.news.len:
-        state.kill()
-        state.news.setLen(state.cursor)
-    of ACTION_LINED_BACK:
-      if state.cursor > 0:
-        dec state.cursor
-        if state.cursor > state.shift or state.shift == 0:
-          state.backward(state.news[state.cursor].lwidth())
-        else:
-          state.fullRedraw()
-    of ACTION_LINED_FORWARD:
-      if state.cursor < state.news.len:
-        inc state.cursor
-        if state.news.lwidth(state.shift, state.cursor) < state.displen:
-          var n = 1
-          if state.news.len > state.cursor:
-            n = state.news[state.cursor].lwidth()
-          state.forward(n)
-        else:
-          state.fullRedraw()
-    of ACTION_LINED_PREV_WORD:
-      let oc = state.cursor
-      while state.cursor > 0:
-        dec state.cursor
-        if state.news[state.cursor].breaksWord():
-          break
-      if state.cursor != oc:
-        if state.cursor > state.shift or state.shift == 0:
-          state.backward(state.news.lwidth(state.cursor, oc))
-        else:
-          state.fullRedraw()
-    of ACTION_LINED_NEXT_WORD:
-      let oc = state.cursor
-      while state.cursor < state.news.len:
-        inc state.cursor
-        if state.cursor < state.news.len:
-          if state.news[state.cursor].breaksWord():
-            break
-
-      if state.cursor != oc:
-        let dw = state.news.lwidth(oc, state.cursor)
-        if oc + dw - state.shift < state.displen:
-          state.forward(dw)
-        else:
-          state.fullRedraw()
-    of ACTION_LINED_KILL_WORD:
-      var chars = 0
-      if state.cursor > chars:
-        inc chars
-
-      while state.cursor > chars:
-        inc chars
-        if state.news[state.cursor - chars].breaksWord():
-          dec chars
-          break
-      if chars > 0:
-        let w = state.news.lwidth(state.cursor - chars, state.cursor)
-        state.news.delete(state.cursor - chars..state.cursor - 1)
-        state.cursor -= chars
-        if state.cursor == state.news.len and state.shift == 0:
-          state.backward(w)
-          state.kill(w)
-        else:
-          state.fullRedraw()
-    of ACTION_LINED_BEGIN:
-      if state.cursor > 0:
-        if state.shift == 0:
-          state.backward(state.news.lwidth(0, state.cursor))
-        else:
-          state.fullRedraw()
-        state.cursor = 0
-    of ACTION_LINED_END:
-      if state.cursor < state.news.len:
-        if state.news.lwidth(state.shift, state.news.len) < state.maxlen:
-          state.forward(state.news.lwidth(state.cursor, state.news.len))
-        else:
-          state.fullRedraw()
-        state.cursor = state.news.len
-    of ACTION_FEED_NEXT:
-      state.feedNext = true
-    elif validateUtf8(state.s) == -1:
-      var cs = state.s.toRunes()
-      state.insertCharseq(cs, state.disallowed)
-      if state.callback(state):
-        state.fullRedraw()
+proc submit*(edit: LineEdit) {.jsfunc.} =
+  edit.state = FINISH
+
+proc backspace*(edit: LineEdit) {.jsfunc.} =
+  if edit.cursor > 0:
+    let w = edit.news[edit.cursor - 1].lwidth()
+    edit.news.delete(edit.cursor - 1..edit.cursor - 1)
+    dec edit.cursor
+    if edit.cursor == edit.news.len and edit.shift == 0:
+      edit.backward0(w)
+      edit.kill0(w)
     else:
-      state.feedNext = true
-
-proc readLine*(prompt: string, current: var string, termwidth: int,
-               disallowed: set[char], hide: bool, config: Config,
-               tty: File, callback: proc(state: var LineState): bool): bool =
-  var state: LineState
-
-  state.prompt = prompt
-  state.current = current
-  state.news = current.toRunes()
-  state.cursor = state.news.len
-  state.minlen = prompt.lwidth()
-  state.maxlen = termwidth - prompt.len
-  state.displen = state.maxlen - 1
-  state.disallowed = disallowed
-  state.callback = callback
-  state.hide = hide
-  state.config = config
-  state.tty = tty
-
-  if state.readLine():
-    current = $state.news
+      edit.fullRedraw()
+
+proc write*(edit: LineEdit, s: string): bool {.jsfunc.} =
+  if validateUtf8(s) == -1:
+    var cs = s.toRunes()
+    edit.insertCharseq(cs, edit.disallowed)
     return true
-  return false
 
-proc readLine*(prompt: string, current: var string, termwidth: int,
+proc delete*(edit: LineEdit) {.jsfunc.} =
+  if edit.cursor >= 0 and edit.cursor < edit.news.len:
+    let w = edit.news[edit.cursor].lwidth()
+    edit.news.delete(edit.cursor..edit.cursor)
+    if edit.cursor == edit.news.len and edit.shift == 0:
+      edit.kill0(w)
+    else:
+      edit.fullRedraw()
+
+proc escape*(edit: LineEdit) {.jsfunc.} =
+  edit.escNext = true
+
+proc clear*(edit: LineEdit) {.jsfunc.} =
+  if edit.cursor > 0:
+    edit.news.delete(0..edit.cursor - 1)
+    edit.cursor = 0
+    edit.zeroShiftRedraw()
+
+proc kill*(edit: LineEdit) {.jsfunc.} =
+  if edit.cursor < edit.news.len:
+    edit.kill0()
+    edit.news.setLen(edit.cursor)
+
+proc backward*(edit: LineEdit) {.jsfunc.} =
+  if edit.cursor > 0:
+    dec edit.cursor
+    if edit.cursor > edit.shift or edit.shift == 0:
+      edit.backward0(edit.news[edit.cursor].lwidth())
+    else:
+      edit.fullRedraw()
+
+proc forward*(edit: LineEdit) {.jsfunc.} =
+  if edit.cursor < edit.news.len:
+    inc edit.cursor
+    if edit.news.lwidth(edit.shift, edit.cursor) < edit.displen:
+      var n = 1
+      if edit.news.len > edit.cursor:
+        n = edit.news[edit.cursor].lwidth()
+      edit.forward0(n)
+    else:
+      edit.fullRedraw()
+
+proc prevWord*(edit: LineEdit, check = none(BoundaryFunction)) {.jsfunc.} =
+  let oc = edit.cursor
+  while edit.cursor > 0:
+    dec edit.cursor
+    if edit.news[edit.cursor].breaksWord(check):
+      break
+  if edit.cursor != oc:
+    if edit.cursor > edit.shift or edit.shift == 0:
+      edit.backward0(edit.news.lwidth(edit.cursor, oc))
+    else:
+      edit.fullRedraw()
+
+proc nextWord*(edit: LineEdit, check = none(BoundaryFunction)) {.jsfunc.} =
+  let oc = edit.cursor
+  while edit.cursor < edit.news.len:
+    inc edit.cursor
+    if edit.cursor < edit.news.len:
+      if edit.news[edit.cursor].breaksWord(check):
+        break
+  if edit.cursor != oc:
+    let dw = edit.news.lwidth(oc, edit.cursor)
+    if oc + dw - edit.shift < edit.displen:
+      edit.forward0(dw)
+    else:
+      edit.fullRedraw()
+
+proc clearWord*(edit: LineEdit, check = none(BoundaryFunction)) {.jsfunc.} =
+  var i = edit.cursor
+  if i > 0:
+    # point to the previous character
+    dec i
+  while i > 0:
+    dec i
+    if edit.news[i].breaksWord(check):
+      inc i
+      break
+  if i != edit.cursor:
+    edit.news.delete(i..<edit.cursor)
+    edit.cursor = i
+    edit.fullRedraw()
+
+proc killWord*(edit: LineEdit, check = none(BoundaryFunction)) {.jsfunc.} =
+  var i = edit.cursor
+  if i < edit.news.len and edit.news[i].breaksWord(check):
+    inc i
+  while i < edit.news.len:
+    if edit.news[i].breaksWord(check):
+      break
+    inc i
+  if i != edit.cursor:
+    edit.news.delete(edit.cursor..<i)
+    edit.fullRedraw()
+
+proc begin*(edit: LineEdit) {.jsfunc.} =
+  if edit.cursor > 0:
+    if edit.shift == 0:
+      edit.backward0(edit.news.lwidth(0, edit.cursor))
+    else:
+      edit.fullRedraw()
+    edit.cursor = 0
+
+proc `end`*(edit: LineEdit) {.jsfunc.} =
+  if edit.cursor < edit.news.len:
+    if edit.news.lwidth(edit.shift, edit.news.len) < edit.maxlen:
+      edit.forward0(edit.news.lwidth(edit.cursor, edit.news.len))
+    else:
+      edit.fullRedraw()
+    edit.cursor = edit.news.len
+
+proc writePrompt*(lineedit: LineEdit) =
+  printesc(lineedit.prompt)
+
+proc writeStart*(lineedit: LineEdit) =
+  lineedit.writePrompt()
+  if lineedit.hide:
+    printesc('*'.repeat(lineedit.current.lwidth()))
+  else:
+    printesc(lineedit.current)
+
+proc readLine*(prompt: string, termwidth: int, current = "",
                disallowed: set[char] = {}, hide = false, config: Config,
-               tty: File): bool =
-  readLine(prompt, current, termwidth, disallowed, hide, config, tty, (proc(state: var LineState): bool = false))
+               tty: File): LineEdit =
+  new(result)
+  result.prompt = prompt
+  result.current = current
+  result.news = current.toRunes()
+  result.cursor = result.news.len
+  result.minlen = prompt.lwidth()
+  result.maxlen = termwidth - prompt.len
+  result.displen = result.maxlen - 1
+  result.disallowed = disallowed
+  result.hide = hide
+  result.config = config
+  result.tty = tty
+
+proc addLineEditModule*(ctx: JSContext) =
+  ctx.registerType(LineEdit)
diff --git a/src/io/loader.nim b/src/io/loader.nim
index 7e6faab4..a8d0b969 100644
--- a/src/io/loader.nim
+++ b/src/io/loader.nim
@@ -76,10 +76,11 @@ proc loadResource(loader: FileLoader, request: Request, ostream: Stream) =
     ostream.swrite(-1) # error
     ostream.flush()
 
+var ssock: ServerSocket
 proc runFileLoader(loader: FileLoader, fd: cint) =
   if curl_global_init(CURL_GLOBAL_ALL) != CURLE_OK:
     raise newException(Defect, "Failed to initialize libcurl.")
-  let ssock = initServerSocket(getpid())
+  ssock = initServerSocket(getpid())
   # The server has been initialized, so the main process can resume execution.
   var writef: File
   if not open(writef, FileHandle(fd), fmWrite):
@@ -88,6 +89,10 @@ proc runFileLoader(loader: FileLoader, fd: cint) =
   writef.flushFile()
   close(writef)
   discard close(fd)
+  onSignal SIGTERM, SIGINT:
+    curl_global_cleanup()
+    ssock.close()
+    quit(1)
   while true:
     let stream = ssock.acceptSocketStream()
     try:
diff --git a/src/io/serialize.nim b/src/io/serialize.nim
index 932b82df..2dde0649 100644
--- a/src/io/serialize.nim
+++ b/src/io/serialize.nim
@@ -4,7 +4,10 @@ import options
 import streams
 import tables
 
+import buffer/cell
 import io/request
+import js/regex
+import types/color
 import types/url
 
 template swrite*[T](stream: Stream, o: T) =
@@ -47,11 +50,9 @@ proc swrite*[T](stream: Stream, s: seq[T]) =
     stream.swrite(m)
 
 proc swrite*[T](stream: Stream, o: Option[T]) =
+  stream.swrite(o.issome)
   if o.issome:
-    stream.swrite(1u8)
     stream.swrite(o.get)
-  else:
-    stream.swrite(0u8)
 
 proc swrite*(stream: Stream, request: Request) =
   stream.swrite(request.httpmethod)
@@ -60,6 +61,39 @@ proc swrite*(stream: Stream, request: Request) =
   stream.swrite(request.body)
   stream.swrite(request.multipart)
 
+proc swrite*(stream: Stream, color: CellColor) =
+  stream.swrite(color.rgb)
+  if color.rgb:
+    stream.swrite(color.rgbcolor)
+  else:
+    stream.swrite(color.color)
+
+proc swrite*(stream: Stream, format: Format) =
+  stream.swrite(format.fgcolor)
+  stream.swrite(format.bgcolor)
+  stream.swrite(format.flags)
+
+proc swrite*(stream: Stream, cell: SimpleFormatCell) =
+  stream.swrite(cell.format)
+  stream.swrite(cell.pos)
+
+proc swrite*(stream: Stream, line: SimpleFlexibleLine) =
+  stream.swrite(line.str)
+  stream.swrite(line.formats)
+
+proc swrite*(stream: Stream, cell: FormatCell) =
+  stream.swrite(cell.format)
+  stream.swrite(cell.pos)
+
+proc swrite*(stream: Stream, line: FlexibleLine) =
+  stream.swrite(line.str)
+  stream.swrite(line.formats)
+
+proc swrite*(stream: Stream, regex: Regex) =
+  stream.swrite(regex.plen)
+  stream.writeData(regex.bytecode, regex.plen)
+  stream.swrite(regex.buf)
+
 template sread*[T](stream: Stream, o: T) =
   stream.read(o)
 
@@ -119,13 +153,13 @@ proc sread*[T](stream: Stream, s: var seq[T]) =
     stream.sread(s[i])
 
 proc sread*[T](stream: Stream, o: var Option[T]) =
-  let c = uint8(stream.readChar())
-  if c == 1u8:
+  var x: bool
+  stream.sread(x)
+  if x:
     var m: T
     stream.sread(m)
     o = some(m)
   else:
-    assert c == 0u8
     o = none(T)
 
 proc sread*(stream: Stream, req: var RequestObj) =
@@ -135,6 +169,39 @@ proc sread*(stream: Stream, req: var RequestObj) =
   stream.sread(req.body)
   stream.sread(req.multipart)
 
+proc sread*(stream: Stream, color: var CellColor) =
+  var rgb: bool
+  stream.sread(rgb)
+  if rgb:
+    color = CellColor(rgb: true)
+    stream.sread(color.rgbcolor)
+  else:
+    color = CellColor(rgb: false)
+    stream.sread(color.color)
+
+proc sread*(stream: Stream, format: var Format) =
+  stream.sread(format.fgcolor)
+  stream.sread(format.bgcolor)
+  stream.sread(format.flags)
+
+proc sread*(stream: Stream, cell: var SimpleFormatCell) =
+  stream.sread(cell.format)
+  stream.sread(cell.pos)
+
+proc sread*(stream: Stream, line: var SimpleFlexibleLine) =
+  stream.sread(line.str)
+  stream.sread(line.formats)
+
+proc sread*(stream: Stream, regex: var Regex) =
+  assert regex.bytecode == nil
+  stream.sread(regex.plen)
+  regex.bytecode = cast[ptr uint8](alloc(regex.plen))
+  regex.clone = true
+  let l = stream.readData(regex.bytecode, regex.plen)
+  stream.sread(regex.buf)
+  if l != regex.plen:
+    `=destroy`(regex)
+
 proc readRequest*(stream: Stream): Request =
   new(result)
   stream.sread(result[])
diff --git a/src/io/term.nim b/src/io/term.nim
index bc0f2ae9..1eacd237 100644
--- a/src/io/term.nim
+++ b/src/io/term.nim
@@ -23,7 +23,6 @@ when defined(posix):
     discard tcSetAttr(stdin_fileno, TCSAFLUSH, addr orig_termios)
 
   proc enableRawMode*(fileno: FileHandle) =
-    eprint "raw mode"
     stdin_fileno = fileno
     addExitProc(disableRawMode)
     discard tcGetAttr(fileno, addr orig_termios)
diff --git a/src/js/javascript.nim b/src/js/javascript.nim
index b453cb13..08639aa9 100644
--- a/src/js/javascript.nim
+++ b/src/js/javascript.nim
@@ -4,6 +4,7 @@ import streams
 import strformat
 import strutils
 import tables
+import unicode
 
 import bindings/quickjs
 
@@ -498,6 +499,44 @@ proc fromJSSeq[T](ctx: JSContext, val: JSValue): Option[seq[T]] =
       return none(seq[T])
     result.get.add(genericRes.get)
 
+proc fromJSSet[T](ctx: JSContext, val: JSValue): Option[set[T]] =
+  let itprop = JS_GetProperty(ctx, val, ctx.getOpaque().sym_iterator)
+  if JS_IsException(itprop):
+    return none(set[T])
+  defer: JS_FreeValue(ctx, itprop)
+  let it = JS_Call(ctx, itprop, val, 0, nil)
+  if JS_IsException(it):
+    return none(set[T])
+  defer: JS_FreeValue(ctx, it)
+  let next_method = JS_GetProperty(ctx, it, ctx.getOpaque().next)
+  if JS_IsException(next_method):
+    return none(set[T])
+  defer: JS_FreeValue(ctx, next_method)
+  var s: set[T]
+  result = some(s)
+  while true:
+    let next = JS_Call(ctx, next_method, it, 0, nil)
+    if JS_IsException(next):
+      return none(set[T])
+    defer: JS_FreeValue(ctx, next)
+    let doneVal = JS_GetProperty(ctx, next, ctx.getOpaque().done)
+    if JS_IsException(doneVal):
+      return none(set[T])
+    defer: JS_FreeValue(ctx, doneVal)
+    let done = fromJS[bool](ctx, doneVal)
+    if done.isnone: # exception
+      return none(set[T])
+    if done.get:
+      break
+    let valueVal = JS_GetProperty(ctx, next, ctx.getOpaque().value)
+    if JS_IsException(valueVal):
+      return none(set[T])
+    defer: JS_FreeValue(ctx, valueVal)
+    let genericRes = fromJS[typeof(result.get.items)](ctx, valueVal)
+    if genericRes.isnone: # exception
+      return none(set[T])
+    result.get.incl(genericRes.get)
+
 proc fromJSTable[A, B](ctx: JSContext, val: JSValue): Option[Table[A, B]] =
   var ptab: ptr JSPropertyEnum
   var plen: uint32
@@ -526,16 +565,67 @@ proc fromJSTable[A, B](ctx: JSContext, val: JSValue): Option[Table[A, B]] =
       return none(Table[A, B])
     result.get[kn.get] = vn.get
 
+proc toJS*[T](ctx: JSContext, obj: T): JSValue
+
+# ew....
+proc fromJSFunction1[T, U](ctx: JSContext, val: JSValue): Option[proc(x: U): Option[T]] =
+  return some(proc(x: U): Option[T] =
+    var arg1 = toJS(ctx, x)
+    let ret = JS_Call(ctx, val, JS_UNDEFINED, 1, addr arg1)
+    return fromJS[T](ctx, ret)
+  )
+
+macro unpackReturnType(f: typed) =
+  var x = f.getTypeImpl()
+  while x.kind == nnkBracketExpr and x.len == 2:
+    x = x[1].getTypeImpl()
+  let params = x.findChild(it.kind == nnkFormalParams)
+  let rv = params[0]
+  assert rv[0].strVal == "Option"
+  let rvv = rv[1]
+  result = quote do: `rvv`
+
+macro unpackArg0(f: typed) =
+  var x = f.getTypeImpl()
+  while x.kind == nnkBracketExpr and x.len == 2:
+    x = x[1].getTypeImpl()
+  let params = x.findChild(it.kind == nnkFormalParams)
+  let rv = params[1]
+  assert rv.kind == nnkIdentDefs
+  let rvv = rv[1]
+  result = quote do: `rvv`
+
 proc fromJS[T](ctx: JSContext, val: JSValue): Option[T] =
   when T is string:
     return toString(ctx, val)
+  elif T is char:
+    let s = toString(ctx, val)
+    if s.isNone:
+      return none(char)
+    if s.get.len > 1:
+      return none(char)
+    return some(s.get[0])
+  elif T is Rune:
+    let s = toString(ctx, val)
+    if s.isNone:
+      return none(Rune)
+    var i = 0
+    var r: Rune
+    fastRuneAt(s.get, i, r)
+    if i < s.get.len:
+      return none(Rune)
+    return some(r)
+  elif T is (proc):
+    return fromJSFunction1[typeof(unpackReturnType(T)), typeof(unpackArg0(T))](ctx, val)
   elif typeof(result.unsafeGet) is Option: # unwrap
     let res = fromJS[typeof(result.get.get)](ctx, val)
     if res.isnone:
       return none(T)
     return some(res)
   elif T is seq:
-    return fromJSSeq[typeof(result.get[0])](ctx, val)
+    return fromJSSeq[typeof(result.get.items)](ctx, val)
+  elif T is set:
+    return fromJSSet[typeof(result.get.items)](ctx, val)
   elif T is tuple:
     return fromJSTuple[T](ctx, val)
   elif T is bool:
@@ -634,6 +724,8 @@ func toJSObject[T](ctx: JSContext, obj: T): JSValue =
 proc toJS*[T](ctx: JSContext, obj: T): JSValue =
   when T is string:
     return ctx.toJSString(obj)
+  elif T is Rune:
+    return ctx.toJSString($obj)
   elif T is SomeNumber:
     return ctx.toJSNumber(obj)
   elif T is bool:
diff --git a/src/js/regex.nim b/src/js/regex.nim
index dcaf1729..9f24b160 100644
--- a/src/js/regex.nim
+++ b/src/js/regex.nim
@@ -19,6 +19,9 @@ export
 type
   Regex* = object
     bytecode*: ptr uint8
+    plen*: cint
+    clone*: bool
+    buf*: string
 
   RegexResult* = object
     success*: bool
@@ -29,19 +32,20 @@ var dummyContext = dummyRuntime.newJSContextRaw()
 
 proc `=destroy`(regex: var Regex) =
   if regex.bytecode != nil:
-    dummyRuntime.js_free_rt(regex.bytecode)
+    if regex.clone:
+      dealloc(regex.bytecode)
+    else:
+      dummyRuntime.js_free_rt(regex.bytecode)
     regex.bytecode = nil
 
 proc compileRegex*(buf: string, flags: int): Option[Regex] =
   var regex: Regex
-  var len: cint
   var error_msg_size = 64
   var error_msg = cast[cstring](alloc0(error_msg_size))
-  let bytecode = lre_compile(addr len, error_msg, cint(error_msg_size), cstring(buf), csize_t(buf.len), cint(flags), dummyContext)
-
+  let bytecode = lre_compile(addr regex.plen, error_msg, cint(error_msg_size), cstring(buf), csize_t(buf.len), cint(flags), dummyContext)
+  regex.buf = buf
   if error_msg != nil:
     #TODO error handling?
-    #eprint "err", error_msg
     dealloc(error_msg)
     error_msg = nil
   if bytecode == nil:
@@ -80,8 +84,8 @@ proc compileSearchRegex*(str: string): Option[Regex] =
     else: assert false
   return compileRegex(str.substr(0, flagsi - 1), flags)
 
-proc exec*(regex: Regex, str: string, start = 0): RegexResult =
-  assert 0 <= start and start <= str.len
+proc exec*(regex: Regex, str: string, start = 0, length = str.len): RegexResult =
+  assert 0 <= start and start <= length, "Start: " & $start & ", length: " & $length & " str: " & $str
 
   let captureCount = lre_get_capture_count(regex.bytecode)
 
@@ -97,12 +101,15 @@ proc exec*(regex: Regex, str: string, start = 0): RegexResult =
       break
   var ustr: string16
   if not ascii:
-    ustr = toUTF16(str)
+    if start != 0 or length != str.len:
+      ustr = toUTF16(str.substr(start, length))
+    else:
+      ustr = toUTF16(str)
     cstr = cstring(ustr)
 
   let ret = lre_exec(capture, regex.bytecode,
                      cast[ptr uint8](cstr), cint(start),
-                     cint(str.len), cint(not ascii), dummyContext)
+                     cint(length), cint(not ascii), dummyContext)
 
   result.success = ret == 1 #TODO error handling? (-1)
 
diff --git a/src/main.nim b/src/main.nim
index 817ec190..09eb3f6b 100644
--- a/src/main.nim
+++ b/src/main.nim
@@ -1,3 +1,4 @@
+import options
 import os
 import terminal
 
@@ -37,7 +38,7 @@ Options:
   quit(i)
 
 var i = 0
-var ctype = ""
+var ctype = none(string)
 var pages: seq[string]
 var dump = false
 var escape_all = false
@@ -54,7 +55,7 @@ while i < params.len:
   of "-T":
     inc i
     if i < params.len:
-      ctype = params[i]
+      ctype = some(params[i])
     else:
       help(1)
   of "-":
@@ -94,7 +95,7 @@ if pages.len == 0 and conf.startup == "":
   if stdin.isatty:
     help(1)
 
-conf.nmap = constructActionTable2(conf.nmap)
+conf.nmap = constructActionTable(conf.nmap)
 conf.lemap = constructActionTable(conf.lemap)
 
 width_table = makewidthtable(conf.ambiguous_double)
diff --git a/src/render/renderdocument.nim b/src/render/renderdocument.nim
index 4fddddc7..44b369f7 100644
--- a/src/render/renderdocument.nim
+++ b/src/render/renderdocument.nim
@@ -1,12 +1,12 @@
 import strutils
 import unicode
 
+import buffer/cell
 import css/cascade
 import css/sheet
 import css/stylednode
 import css/values
 import html/dom
-import io/cell
 import io/term
 import layout/box
 import layout/engine
diff --git a/src/render/rendertext.nim b/src/render/rendertext.nim
index b893d781..f164c4dc 100644
--- a/src/render/rendertext.nim
+++ b/src/render/rendertext.nim
@@ -1,6 +1,6 @@
 import streams
 
-import io/cell
+import buffer/cell
 import utils/twtstr
 
 proc renderPlainText*(text: string): FlexibleGrid =
diff --git a/src/types/url.nim b/src/types/url.nim
index 8d274b57..fc9e20e8 100644
--- a/src/types/url.nim
+++ b/src/types/url.nim
@@ -947,6 +947,14 @@ proc set*(params: URLSearchParams, name: string, value: string) {.jsfunc.} =
         first = false
         params.list[i][1] = value
 
+proc newURL*(url: URL): URL =
+  new(result)
+  result[] = url[]
+  if url.searchParams != nil: #TODO ideally this should never be false
+    result.searchParams = URLSearchParams()
+    result.searchParams[] = url.searchParams[]
+    result.searchParams.url = some(result)
+
 #TODO add Option wrapper
 proc newURL*(s: string, base: Option[string] = none(string)): URL {.jserr, jsctor.} =
   if base.issome:
diff --git a/src/utils/twtstr.nim b/src/utils/twtstr.nim
index 3132510e..3636da14 100644
--- a/src/utils/twtstr.nim
+++ b/src/utils/twtstr.nim
@@ -125,6 +125,16 @@ func toHeaderCase*(str: string): string =
       result[i] = result[i].toUpperAscii()
     flip = result[i] == '-'
 
+func toScreamingSnakeCase*(str: string): string = # input is camel case
+  if str.len >= 1: result &= str[0].toUpperAscii()
+  for c in str[1..^1]:
+    if c in AsciiUpperAlpha:
+      result &= '_'
+      result &= c
+    else:
+      result &= c.toUpperAscii()
+
+
 func startsWithNoCase*(str, prefix: string): bool =
   if str.len < prefix.len: return false
   # prefix.len is always lower
@@ -899,6 +909,16 @@ func width*(s: seq[Rune], min: int): int =
 func breaksWord*(r: Rune): bool =
   return not (r.isDigitAscii() or r.width() == 0 or r.isAlpha())
 
+type BoundaryFunction* = proc(x: Rune): Option[bool]
+
+proc breaksWord*(r: Rune, check: Option[BoundaryFunction]): bool =
+  if check.isSome:
+    let f = check.get()
+    let v = f(r)
+    if v.isSome: #TODO report error?
+      return v.get()
+  return r.breaksWord()
+
 func padToWidth*(str: string, size: int, schar = '$'): string =
   if str.width() < size:
     return str & ' '.repeat(size - str.width())