about summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/client.nim23
-rw-r--r--src/css/parser.nim6
-rw-r--r--src/html/dom.nim227
-rw-r--r--src/html/parser.nim23
-rw-r--r--src/io/buffer.nim274
-rw-r--r--src/io/lineedit.nim12
-rw-r--r--src/io/loader.nim10
-rw-r--r--src/layout/box.nim3
-rw-r--r--src/layout/engine.nim85
-rw-r--r--src/render/renderdocument.nim5
-rw-r--r--src/types/url.nim2
-rw-r--r--src/utils/twtstr.nim38
12 files changed, 580 insertions, 128 deletions
diff --git a/src/client.nim b/src/client.nim
index 330b04aa..bba2911d 100644
--- a/src/client.nim
+++ b/src/client.nim
@@ -99,12 +99,15 @@ proc readPipe(client: Client, ctype: string) =
     client.buffer.drawBuffer()
 
 var g_client: Client
-proc gotoUrl(client: Client, url: Url, prevurl = none(Url), force = false, newbuf = true, ctype = "") =
+proc gotoUrl(client: Client, url: Url, click = none(ClickAction), prevurl = none(Url), force = false, newbuf = true, ctype = "") =
   setControlCHook(proc() {.noconv.} =
     raise newException(InterruptError, "Interrupted"))
   if force or prevurl.issome or not prevurl.get.equals(url, true):
     try:
-      let page = client.loader.getPage(url)
+      let page = if click.isnone:
+        client.loader.getPage(url)
+      else:
+        client.loader.getPage(url, click.get.smethod, click.get.mimetype, click.get.body, click.get.multipart)
       if page.s != nil:
         if newbuf:
           client.addBuffer()
@@ -126,29 +129,29 @@ proc gotoUrl(client: Client, url: Url, prevurl = none(Url), force = false, newbu
   client.buffer.location = url
   client.setupBuffer()
 
-proc gotoUrl(client: Client, url: string, prevurl = none(Url), force = false, newbuf = true, ctype = "") =
+proc gotoUrl(client: Client, url: string, click = none(ClickAction), prevurl = none(Url), force = false, newbuf = true, ctype = "") =
   var oldurl = prevurl
   if oldurl.isnone and client.buffer != nil:
     oldurl = client.buffer.location.some
   let newurl = parseUrl(url, oldurl)
   if newurl.isnone:
     loadError("Invalid URL " & url)
-  client.gotoUrl(newurl.get, oldurl, force, newbuf, ctype)
+  client.gotoUrl(newurl.get, click, oldurl, force, newbuf, ctype)
 
 proc loadUrl(client: Client, url: string, ctype = "") =
   let firstparse = parseUrl(url)
   if firstparse.issome:
-    client.gotoUrl(url, none(Url), true, true, ctype)
+    client.gotoUrl(url, none(ClickAction), none(Url), true, true, ctype)
   else:
     try:
       let cdir = parseUrl("file://" & getCurrentDir() & DirSep)
-      client.gotoUrl(url, cdir, true, true, ctype)
+      client.gotoUrl(url, none(ClickAction), cdir, true, true, ctype)
     except LoadError:
-      client.gotoUrl("http://" & url, none(Url), true, true, ctype)
+      client.gotoUrl("http://" & url, none(ClickAction), none(Url), true, true, ctype)
 
 proc reloadPage(client: Client) =
   let pbuffer = client.buffer
-  client.gotoUrl(pbuffer.location, none(Url), true, false)
+  client.gotoUrl(pbuffer.location, none(ClickAction), none(Url), true, false)
   client.buffer.setCursorXY(pbuffer.cursorx, pbuffer.cursory)
   client.buffer.setFromXY(pbuffer.fromx, pbuffer.fromy)
   client.buffer.contenttype = pbuffer.contenttype
@@ -164,8 +167,8 @@ proc changeLocation(client: Client) =
 
 proc click(client: Client) =
   let s = client.buffer.click()
-  if s != "":
-    client.gotoUrl(s)
+  if s.issome and s.get.url != "":
+    client.gotoUrl(s.get.url, s)
 
 proc toggleSource*(client: Client) =
   let buffer = client.buffer
diff --git a/src/css/parser.nim b/src/css/parser.nim
index 0277bf41..1db94958 100644
--- a/src/css/parser.nim
+++ b/src/css/parser.nim
@@ -374,6 +374,8 @@ proc consumeComments(state: var CSSTokenizerState) =
 
 proc consumeToken(state: var CSSTokenizerState): CSSToken =
   state.consumeComments()
+  if not state.has():
+    return
   let r = state.consume()
   case r
   of Rune('\n'), Rune('\t'), Rune(' '), Rune('\f'), Rune('\r'):
@@ -456,7 +458,9 @@ proc tokenizeCSS*(inputStream: Stream): seq[CSSParsedItem] =
   state.stream = inputStream
   state.buf = state.stream.readLine().toRunes()
   while state.has():
-    result.add(state.consumeToken())
+    let tok = state.consumeToken()
+    if tok != nil:
+      result.add(tok)
 
   inputStream.close()
 
diff --git a/src/html/dom.nim b/src/html/dom.nim
index 22020c58..51bec09a 100644
--- a/src/html/dom.nim
+++ b/src/html/dom.nim
@@ -6,6 +6,7 @@ import css/values
 import css/sheet
 import html/tags
 import types/url
+import utils/twtstr
 
 type
   EventTarget* = ref EventTargetObj
@@ -77,11 +78,16 @@ type
   HTMLElement* = ref object of ElementObj
 
   HTMLInputElement* = ref object of HTMLElement
-    itype*: InputType
+    inputType*: InputType
     autofocus*: bool
     required*: bool
     value*: string
     size*: int
+    checked*: bool
+    xcoord*: int
+    ycoord*: int
+    file*: Option[Url]
+    form*: HTMLFormElement
 
   HTMLAnchorElement* = ref object of HTMLElement
     href*: string
@@ -121,17 +127,49 @@ type
     href*: string
     rel*: string
 
+  HTMLFormElement* = ref object of HTMLElement
+    name*: string
+    smethod*: string
+    enctype*: string
+    target*: string
+    novalidate*: bool
+    constructingentrylist*: bool
+    inputs*: seq[HTMLInputElement]
+
 # For debugging
-template `$`*(node: Node): string =
+func `$`*(node: Node): string =
   case node.nodeType
   of ELEMENT_NODE:
     let element = Element(node)
-    return "Element of " & $element.tagType & ", children: {\n" & $element.childNodes & "\n}"
+    "Element of " & $element.tagType & ", children: {\n" & $element.childNodes & "\n}"
   of TEXT_NODE:
     let text = Text(node)
-    return "Text: " & text.data
+    "Text: " & text.data
+  else:
+    "Node of " & $node.nodeType
+
+iterator elements*(document: Document, tag: TagType): Element {.inline.} =
+  for element in document.type_elements[tag]:
+    yield element
+
+iterator radiogroup(form: HTMLFormElement): HTMLInputElement {.inline.} =
+  for input in form.inputs:
+    if input.inputType == INPUT_RADIO:
+      yield input
+
+iterator radiogroup(document: Document): HTMLInputElement {.inline.} =
+  for input in document.elements(TAG_INPUT):
+    let input = HTMLInputElement(input)
+    if input.form == nil and input.inputType == INPUT_RADIO:
+      yield input
+
+iterator radiogroup*(input: HTMLInputElement): HTMLInputElement {.inline.} =
+  if input.form != nil:
+    for input in input.form.radiogroup:
+      yield input
   else:
-    return "Node of " & $node.nodeType
+    for input in input.ownerDocument.radiogroup:
+      yield input
 
 iterator textNodes*(node: Node): Text {.inline.} =
   for node in node.childNodes:
@@ -219,6 +257,21 @@ func firstNode*(node: Node): bool =
 func lastNode*(node: Node): bool =
   return node.parentElement != nil and node.parentElement.childNodes[^1] == node
 
+func attr*(element: Element, s: string): string =
+  return element.attributes.getOrDefault(s, "")
+
+func attri*(element: Element, s: string): Option[int] =
+  let a = element.attr(s)
+  try:
+    return some(parseInt(a))
+  except ValueError:
+    return none(int)
+
+func attrb*(element: Element, s: string): bool =
+  if s in element.attributes:
+    return true
+  return false
+
 func toInputType*(str: string): InputType =
   case str
   of "button": INPUT_BUTTON
@@ -245,46 +298,92 @@ func toInputType*(str: string): InputType =
   of "week": INPUT_WEEK
   else: INPUT_UNKNOWN
 
+func inputString*(input: HTMLInputElement): string =
+  var text = case input.inputType
+  of INPUT_CHECKBOX, INPUT_RADIO:
+    if input.checked: "*"
+    else: " "
+  of INPUT_SEARCH, INPUT_TEXT:
+    if input.size > 0: input.value.padToWidth(input.size)
+    else: input.value
+  of INPUT_PASSWORD:
+    '*'.repeat(input.value.len).padToWidth(input.size)
+  of INPUT_RESET:
+    if input.value != "": input.value
+    else: "RESET"
+  of INPUT_SUBMIT, INPUT_BUTTON:
+    if input.value != "": input.value
+    else: "SUBMIT"
+  of INPUT_FILE:
+    if input.file.isnone: "".padToWidth(input.size)
+    else: input.file.get.path.serialize_unicode().padToWidth(input.size)
+  else:
+    input.value
+  return text
+
+func isButton*(element: Element): bool =
+  if element.tagType == TAG_BUTTON:
+    return true
+  if element.tagType == TAG_INPUT:
+    let element = HTMLInputElement(element)
+    return element.inputType in {INPUT_SUBMIT, INPUT_BUTTON, INPUT_RESET, INPUT_IMAGE}
+  return false
+
+func isSubmitButton*(element: Element): bool =
+  if element.tagType == TAG_BUTTON:
+    return element.attr("type") == "submit"
+  elif element.tagType == TAG_INPUT:
+    let element = HTMLInputElement(element)
+    return element.inputType in {INPUT_SUBMIT, INPUT_IMAGE}
+  return false
+
+func action*(element: Element): string =
+  if element.isSubmitButton():
+    if element.attrb("formaction"):
+      return element.attr("formaction")
+  if element.tagType == TAG_INPUT:
+    let element = HTMLInputElement(element)
+    if element.form != nil:
+      if element.form.attrb("action"):
+        return element.form.attr("action")
+  return ""
+
+func enctype*(element: Element): string =
+  if element.isSubmitButton():
+    if element.attrb("formenctype"):
+      return element.attr("formenctype")
+  if element.tagType == TAG_INPUT:
+    let element = HTMLInputElement(element)
+    if element.form != nil:
+      if element.form.attrb("enctype"):
+        return element.form.attr("enctype")
+  return "application/x-www-form-urlencoded"
+
+func smethod*(element: Element): string =
+  if element.isSubmitButton():
+    if element.attrb("formmethod"):
+      return element.attr("formmethod")
+  if element.tagType == TAG_INPUT:
+    let element = HTMLInputElement(element)
+    if element.form != nil:
+      if element.form.attrb("method"):
+        return element.form.attr("method")
+  return "GET"
+
+func target*(element: Element): string =
+  if element.attrb("target"):
+    return element.attr("target")
+  for base in element.ownerDocument.elements(TAG_BASE):
+    if base.attrb("target"):
+      return base.attr("target")
+  return ""
+
 func findAncestor*(node: Node, tagTypes: set[TagType]): Element =
   for element in node.ancestors:
     if element.tagType in tagTypes:
       return element
   return nil
 
-func attr*(element: Element, s: string): string =
-  return element.attributes.getOrDefault(s, "")
-
-func attri*(element: Element, s: string): Option[int] =
-  let a = element.attr(s)
-  try:
-    return some(parseInt(a))
-  except ValueError:
-    return none(int)
-
-proc applyOrdinal*(elem: HTMLLIElement) =
-  let val = elem.attri("value")
-  if val.issome:
-    elem.ordinalvalue = val.get
-  else:
-    let owner = elem.findAncestor({TAG_OL, TAG_UL, TAG_MENU})
-    if owner == nil:
-      elem.ordinalvalue = 1
-    else:
-      case owner.tagType
-      of TAG_OL:
-        let ol = HTMLOListElement(owner)
-        elem.ordinalvalue = ol.ordinalcounter
-        inc ol.ordinalcounter
-      of TAG_UL:
-        let ul = HTMLUListElement(owner)
-        elem.ordinalvalue = ul.ordinalcounter
-        inc ul.ordinalcounter
-      of TAG_MENU:
-        let menu = HTMLMenuElement(owner)
-        elem.ordinalvalue = menu.ordinalcounter
-        inc menu.ordinalcounter
-      else: discard
-
 func newText*(): Text =
   new(result)
   result.nodeType = TEXT_NODE
@@ -323,6 +422,8 @@ func newHtmlElement*(document: Document, tagType: TagType): HTMLElement =
     result = new(HTMLStyleElement)
   of TAG_LINK:
     result = new(HTMLLinkElement)
+  of TAG_FORM:
+    result = new(HTMLFormElement)
   else:
     result = new(HTMLElement)
 
@@ -351,5 +452,53 @@ func getElementById*(document: Document, id: string): Element =
     return nil
   return document.id_elements[id][0]
 
+func baseUrl*(document: Document): Url =
+  var href = ""
+  for base in document.elements(TAG_BASE):
+    if base.attr("href") != "":
+      href = base.attr("href")
+  if href == "":
+    return document.location
+  let url = parseUrl(href, document.location.some)
+  if url.isnone:
+    return document.location
+  return url.get
+
 func getElementsByTag*(document: Document, tag: TagType): seq[Element] =
   return document.type_elements[tag]
+
+proc applyOrdinal*(elem: HTMLLIElement) =
+  let val = elem.attri("value")
+  if val.issome:
+    elem.ordinalvalue = val.get
+  else:
+    let owner = elem.findAncestor({TAG_OL, TAG_UL, TAG_MENU})
+    if owner == nil:
+      elem.ordinalvalue = 1
+    else:
+      case owner.tagType
+      of TAG_OL:
+        let ol = HTMLOListElement(owner)
+        elem.ordinalvalue = ol.ordinalcounter
+        inc ol.ordinalcounter
+      of TAG_UL:
+        let ul = HTMLUListElement(owner)
+        elem.ordinalvalue = ul.ordinalcounter
+        inc ul.ordinalcounter
+      of TAG_MENU:
+        let menu = HTMLMenuElement(owner)
+        elem.ordinalvalue = menu.ordinalcounter
+        inc menu.ordinalcounter
+      else: discard
+
+proc reset*(form: HTMLFormElement) =
+  for input in form.inputs:
+    case input.inputType
+    of INPUT_SEARCH, INPUT_TEXT, INPUT_PASSWORD:
+      input.value = input.attr("value")
+    of INPUT_CHECKBOX, INPUT_RADIO:
+      input.checked = input.attrb("checked")
+    of INPUT_FILE:
+      input.file = none(Url)
+    else: discard
+    input.rendered = false
diff --git a/src/html/parser.nim b/src/html/parser.nim
index 305d1afa..b998525a 100644
--- a/src/html/parser.nim
+++ b/src/html/parser.nim
@@ -24,6 +24,7 @@ type
     textNode: Text
     commentNode: Comment
     document: Document
+    formowners: seq[HTMLFormElement]
 
 func inputSize*(str: string): int =
   if str.len == 0:
@@ -249,9 +250,14 @@ proc processDocumentStartElement(state: var HTMLParseState, element: Element, ta
     HTMLSelectElement(element).name = element.attr("name")
     HTMLSelectElement(element).value = element.attr("value")
   of TAG_INPUT:
-    HTMLInputElement(element).value = element.attr("value")
-    HTMLInputElement(element).itype = element.attr("type").inputType()
-    HTMLInputElement(element).size = element.attr("size").inputSize()
+    let element = HTMLInputElement(element)
+    element.value = element.attr("value")
+    element.inputType = element.attr("type").inputType()
+    element.size = element.attr("size").inputSize()
+    element.checked = element.attrb("checked")
+    if state.formowners.len > 0:
+      element.form = state.formowners[^1]
+      element.form.inputs.add(element)
   of TAG_A:
     HTMLAnchorElement(element).href = element.attr("href")
   of TAG_OPTION:
@@ -287,6 +293,14 @@ proc processDocumentStartElement(state: var HTMLParseState, element: Element, ta
   of TAG_LINK:
     HTMLLinkElement(element).href = element.attr("href")
     HTMLLinkElement(element).rel = element.attr("rel")
+  of TAG_FORM:
+    let element = HTMLFormElement(element)
+    element.name = element.attr("name")
+    element.smethod = element.attr("method")
+    element.enctype = element.attr("enctype")
+    element.target = element.attr("target")
+    element.novalidate = element.attrb("novalidate")
+    state.formowners.add(element)
   else: discard
 
   if not state.in_body and not (element.tagType in HeadTagTypes):
@@ -325,6 +339,9 @@ proc processDocumentEndElement(state: var HTMLParseState, tag: DOMParsedTag) =
       return
     of TAG_BODY:
       return
+    of TAG_FORM:
+      if state.formowners.len > 0:
+        discard state.formowners.pop()
     of TAG_STYLE:
       let style = HTMLStyleElement(state.elementNode)
       var str = ""
diff --git a/src/io/buffer.nim b/src/io/buffer.nim
index 6663458f..8140ce7b 100644
--- a/src/io/buffer.nim
+++ b/src/io/buffer.nim
@@ -1,5 +1,8 @@
+import httpclient
 import options
+import os
 import streams
+import tables
 import terminal
 import unicode
 
@@ -9,6 +12,7 @@ import html/dom
 import html/tags
 import html/parser
 import io/cell
+import io/lineedit
 import io/loader
 import io/term
 import layout/box
@@ -197,16 +201,32 @@ func currentDisplayCell(buffer: Buffer): FixedCell =
   let row = (buffer.cursory - buffer.fromy) * buffer.width
   return buffer.display[row + buffer.currentCellOrigin()]
 
-func getLink(node: Node): Element =
+func getLink(node: Node): HTMLAnchorElement =
   if node == nil:
     return nil
   if node.nodeType == ELEMENT_NODE and Element(node).tagType == TAG_A:
-    return Element(node)
-  return node.findAncestor({TAG_A})
+    return HTMLAnchorElement(node)
+  return HTMLAnchorElement(node.findAncestor({TAG_A}))
+
+const ClickableElements = {
+  TAG_A, TAG_INPUT
+}
+
+func getClickable(node: Node): Element =
+  if node == nil:
+    return nil
+  if node.nodeType == ELEMENT_NODE:
+    let element = Element(node)
+    if element.tagType in ClickableElements:
+      return element
+  return node.findAncestor(ClickableElements)
 
 func getCursorLink(buffer: Buffer): Element =
   return buffer.currentDisplayCell().node.getLink()
 
+func getCursorClickable(buffer: Buffer): Element =
+  return buffer.currentDisplayCell().node.getClickable()
+
 func currentLine(buffer: Buffer): string =
   return buffer.lines[buffer.cursory].str
 
@@ -470,12 +490,12 @@ proc cursorNextLink*(buffer: Buffer) =
   var i = line.findFormatN(buffer.currentCursorBytes()) - 1
   var link: Element = nil
   if i >= 0:
-    link = line.formats[i].node.getLink()
+    link = line.formats[i].node.getClickable()
   inc i
 
   while i < line.formats.len:
     let format = line.formats[i]
-    let fl = format.node.getLink()
+    let fl = format.node.getClickable()
     if fl != nil and fl != link:
       buffer.setCursorXB(format.pos)
       return
@@ -486,7 +506,7 @@ proc cursorNextLink*(buffer: Buffer) =
     i = 0
     while i < line.formats.len:
       let format = line.formats[i]
-      let fl = format.node.getLink()
+      let fl = format.node.getClickable()
       if fl != nil and fl != link:
         buffer.setCursorXBY(format.pos, y)
         return
@@ -497,12 +517,12 @@ proc cursorPrevLink*(buffer: Buffer) =
   var i = line.findFormatN(buffer.currentCursorBytes()) - 1
   var link: Element = nil
   if i >= 0:
-    link = line.formats[i].node.getLink()
+    link = line.formats[i].node.getClickable()
   dec i
 
   while i >= 0:
     let format = line.formats[i]
-    let fl = format.node.getLink()
+    let fl = format.node.getClickable()
     if fl != nil and fl != link:
       buffer.setCursorXB(format.pos)
       return
@@ -513,7 +533,7 @@ proc cursorPrevLink*(buffer: Buffer) =
     i = line.formats.len - 1
     while i >= 0:
       let format = line.formats[i]
-      let fl = format.node.getLink()
+      let fl = format.node.getClickable()
       if fl != nil and fl != link:
         #go to beginning of link
         var ly = y #last y
@@ -523,7 +543,7 @@ proc cursorPrevLink*(buffer: Buffer) =
           i = line.formats.len - 1
           while i >= 0:
             let format = line.formats[i]
-            let nl = format.node.getLink()
+            let nl = format.node.getClickable()
             if nl == fl:
               ly = iy
               lx = format.pos
@@ -688,9 +708,7 @@ proc updateHover(buffer: Buffer) =
 
     let link = thisnode.getLink()
     if link != nil:
-      if link.tagType == TAG_A:
-        let anchor = HTMLAnchorElement(link)
-        buffer.hovertext = parseUrl(anchor.href, buffer.location.some).serialize()
+      buffer.hovertext = parseUrl(link.href, buffer.location.some).serialize()
     else:
       buffer.hovertext = ""
 
@@ -803,12 +821,230 @@ proc displayStatusMessage*(buffer: Buffer) =
   print(buffer.generateStatusMessage())
   print(SGR())
 
-proc click*(buffer: Buffer): string =
-  let link = buffer.getCursorLink()
-  if link != nil:
-    if link.tagType == TAG_A:
-      return HTMLAnchorElement(link).href
-  return ""
+type
+  ClickAction* = object
+    url*: string
+    smethod*: string
+    mimetype*: string
+    body*: string
+    multipart*: MultipartData
+
+# https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-the-form-data-set
+proc constructEntryList(form: HTMLFormElement, submitter: Element = nil, encoding: string = ""): Table[string, string] =
+  if form.constructingentrylist:
+    return
+  form.constructingentrylist = true
+
+  var entrylist: Table[string, string]
+  for field in form.inputs:
+    if field.findAncestor({TAG_DATALIST}) != nil or
+        field.attrb("disabled") or
+        field.isButton() and Element(field) != submitter:
+      continue
+
+    if field.inputType == INPUT_IMAGE:
+      let name = if field.attr("name") != "":
+        field.attr("name") & '.'
+      else:
+        ""
+      entrylist[name & 'x'] = $field.xcoord
+      entrylist[name & 'y'] = $field.ycoord
+      continue
+
+    if field.attr("name") == "":
+      continue
+
+    let name = field.attr("name")
+    #TODO select
+    if field.inputType in {INPUT_CHECKBOX, INPUT_RADIO}:
+      let value = if field.attr("value") != "":
+        field.attr("value")
+      else:
+        "on"
+      entrylist[name] = value
+    elif field.inputType == INPUT_FILE:
+      #TODO file
+      discard
+    elif field.inputType == INPUT_HIDDEN and name.equalsIgnoreCase("_charset_"):
+      let charset = if encoding != "":
+        encoding
+      else:
+        "UTF-8"
+      entrylist[name] = charset
+    else:
+      entrylist[name] = field.value
+    if field.tagType == TAG_TEXTAREA or
+        field.tagType == TAG_INPUT and field.inputType in {INPUT_TEXT, INPUT_SEARCH}:
+      if field.attr("dirname") != "":
+        let dirname = field.attr("dirname")
+        let dir = "ltr" #TODO bidi
+        entrylist[dirname] = dir
+
+  form.constructingentrylist = false
+  return entrylist
+
+#https://url.spec.whatwg.org/#concept-urlencoded-serializer
+proc serializeApplicationXWWFormUrlEncoded(kvs: Table[string, string]): string =
+  for name, value in kvs:
+    if result != "":
+      result &= '&'
+    result.percentEncode(name, ApplicationXWWWFormUrlEncodedSet, true)
+    result &= '='
+    result.percentEncode(value, ApplicationXWWWFormUrlEncodedSet, true)
+
+#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: Table[string, string]): MultipartData =
+  new(result)
+  for name, value in kvs:
+    let name = makeCRLF(name)
+    let value = makeCRLF(value)
+    result[name] = value
+
+proc serializePlainTextFormData(kvs: Table[string, string]): string =
+  for name, value in kvs:
+    result &= name
+    result &= '='
+    result &= value
+    result &= '\r'
+    result &= '\n'
+
+proc submitForm(form: HTMLFormElement, submitter: Element): Option[ClickAction] =
+  let entrylist = form.constructEntryList(submitter)
+
+  let action = if submitter.action() == "":
+    $form.ownerDocument.location
+  else:
+    submitter.action()
+
+  let url = parseUrl(action, submitter.ownerDocument.baseUrl.some)
+  if url.isnone:
+    return none(ClickAction)
+
+  var parsedaction = url.get
+  let scheme = parsedaction.scheme
+  let enctype = submitter.enctype()
+  let smethod = submitter.smethod().toupper()
+  if smethod notin ["GET", "POST"]:
+    return none(ClickAction) #TODO this shouldn't be possible
+
+  let target = if submitter.isSubmitButton() and submitter.attrb("formtarget"):
+    submitter.attr("formtarget")
+  else:
+    submitter.target()
+  let noopener = true #TODO
+
+  template mutateActionUrl() =
+    let query = serializeApplicationXWWFormUrlEncoded(entrylist)
+    parsedaction.query = query.some
+    return ClickAction(url: $parsedaction, smethod: smethod).some
+
+  template submitAsEntityBody() =
+    var body: string
+    var mimetype: string
+    var multipart: MultipartData
+    case enctype
+    of "application/x-www-form-urlencoded":
+      body = serializeApplicationXWWFormUrlEncoded(entrylist)
+      mimeType = enctype
+    of "multipart/form-data":
+      multipart = serializeMultipartFormData(entrylist) 
+      #mime type set by httpclient
+    of "text/plain":
+      body = serializePlainTextFormData(entrylist)
+      mimetype = enctype
+    else:
+      return none(ClickAction) #TODO this shouldn't be possible
+    return ClickAction(url: $parsedaction, smethod: smethod, body: body, mimetype: mimetype, multipart: multipart).some
+
+  template getActionUrl() =
+    return ClickAction(url: $parsedaction).some
+
+  case scheme
+  of "http", "https":
+    if smethod == "GET":
+      mutateActionUrl
+    elif smethod == "POST":
+      submitAsEntityBody
+  of "ftp":
+    getActionUrl
+  of "data":
+    if smethod == "GET":
+      mutateActionUrl
+    elif smethod == "POST":
+      getActionUrl
+
+proc click*(buffer: Buffer): Option[ClickAction] =
+  let clickable = buffer.getCursorClickable()
+  if clickable != nil:
+    case clickable.tagType
+    of TAG_A:
+      return ClickAction(url: HTMLAnchorElement(clickable).href).some
+    of TAG_INPUT:
+      let input = HTMLInputElement(clickable)
+      case input.inputType
+      of INPUT_SEARCH, 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'})
+        if status:
+          input.value = value
+          input.rendered = false
+          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'})
+        if status:
+          let cdir = parseUrl("file://" & getCurrentDir() & DirSep)
+          let path = parseUrl(path, cdir)
+          if path.issome:
+            input.file = path
+            input.rendered = false
+            buffer.reshape = true
+      of INPUT_CHECKBOX:
+        input.checked = not input.checked
+        input.rendered = false
+        buffer.reshape = true
+      of INPUT_RADIO:
+        for radio in input.radiogroup:
+          radio.checked = false
+          radio.rendered = false
+        input.checked = true
+        input.rendered = false
+        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:
+        discard
+    else:
+      discard
 
 proc drawBuffer*(buffer: Buffer) =
   var format = newFormat()
diff --git a/src/io/lineedit.nim b/src/io/lineedit.nim
index 67b6194b..ce719d47 100644
--- a/src/io/lineedit.nim
+++ b/src/io/lineedit.nim
@@ -104,9 +104,9 @@ proc fullRedraw(state: var LineState) =
 
   state.redraw()
 
-proc insertCharseq(state: var LineState, cs: var seq[Rune]) =
+proc insertCharseq(state: var LineState, cs: var seq[Rune], disallowed: set[char]) =
   let escNext = state.escNext
-  cs.keepIf((r) => escNext or not r.isControlChar())
+  cs.keepIf((r) => (escNext or not r.isControlChar) and not (r.isAscii and char(r) in disallowed))
   state.escNext = false
   if cs.len == 0:
     return
@@ -120,7 +120,7 @@ proc insertCharseq(state: var LineState, cs: var seq[Rune]) =
     state.cursor += cs.len
     state.fullRedraw()
 
-proc readLine*(current: var string, minlen, maxlen: int): bool =
+proc readLine*(current: var string, minlen, maxlen: int, disallowed: set[char] = {}): bool =
   var state: LineState
   state.news = current.toRunes()
   state.cursor = state.news.len
@@ -257,10 +257,10 @@ proc readLine*(current: var string, minlen, maxlen: int): bool =
       state.feedNext = true
     elif validateUtf8(state.s) == -1:
       var cs = state.s.toRunes()
-      state.insertCharseq(cs)
+      state.insertCharseq(cs, disallowed)
     else:
       state.feedNext = true
 
-proc readLine*(prompt: string, current: var string, termwidth: int): bool =
+proc readLine*(prompt: string, current: var string, termwidth: int, disallowed: set[char] = {}): bool =
   printesc(prompt)
-  readLine(current, prompt.lwidth(), termwidth - prompt.len)
+  readLine(current, prompt.lwidth(), termwidth - prompt.len, disallowed)
diff --git a/src/io/loader.nim b/src/io/loader.nim
index 5bd35a90..4bd365a8 100644
--- a/src/io/loader.nim
+++ b/src/io/loader.nim
@@ -18,7 +18,7 @@ proc newFileLoader*(): FileLoader =
   new(result)
   result.http = newHttpClient()
 
-proc getPage*(loader: FileLoader, url: Url): LoadResult =
+proc getPage*(loader: FileLoader, url: Url, smethod: string = "GET", mimetype = "", body: string = "", multipart: MultipartData = nil): LoadResult =
   if url.scheme == "file":
     when defined(windows) or defined(OS2) or defined(DOS):
       let path = url.path.serialize_unicode_windows()
@@ -27,10 +27,16 @@ proc getPage*(loader: FileLoader, url: Url): LoadResult =
     result.contenttype = guessContentType(path)
     result.s = newFileStream(path, fmRead)
   elif url.scheme == "http" or url.scheme == "https":
-    let resp = loader.http.get(url.serialize(true))
+    let requestheaders = newHttpHeaders({ "User-Agent": "chawan", "Content-Type": mimetype}, true)
+    let requestmethod = if smethod == "":
+      "GET"
+    else:
+      smethod
+    let resp = loader.http.request(url.serialize(true), requestmethod, body, requestheaders, multipart)
     let ct = resp.contentType()
     if ct != "":
       result.contenttype = ct.until(';')
     else:
       result.contenttype = guessContentType(url.path.serialize())
+    resp.bodystream.setPosition(0)
     result.s = resp.bodyStream
diff --git a/src/layout/box.nim b/src/layout/box.nim
index 64fe0741..9947cd22 100644
--- a/src/layout/box.nim
+++ b/src/layout/box.nim
@@ -54,11 +54,12 @@ type
     rows*: seq[InlineRow]
     thisrow*: InlineRow
 
-    whitespace*: bool
+    whitespacenum*: int
     maxwidth*: int
     viewport*: Viewport
     node*: Node
     shrink*: bool
+    format*: ComputedFormat
 
   BlockContext* = ref object of InlineAtom
     inline*: InlineContext
diff --git a/src/layout/engine.nim b/src/layout/engine.nim
index 18939a57..3752ce05 100644
--- a/src/layout/engine.nim
+++ b/src/layout/engine.nim
@@ -49,13 +49,13 @@ func cellheight(ictx: InlineContext): int {.inline.} =
 
 # Whitespace between words
 func computeShift(ictx: InlineContext, specified: CSSSpecifiedValues): int =
-  if ictx.whitespace:
+  if ictx.whitespacenum > 0:
     if ictx.thisrow.atoms.len > 0 or specified.whitespacepre:
       let spacing = specified{"word-spacing"}
       if spacing.auto:
-        return ictx.cellwidth
+        return ictx.cellwidth * ictx.whitespacenum
       #return spacing.cells_w(ictx.viewport, 0)
-      return spacing.px(ictx.viewport)
+      return spacing.px(ictx.viewport) * ictx.whitespacenum
   return 0
 
 func computeLineHeight(viewport: Viewport, specified: CSSSpecifiedValues): int =
@@ -65,13 +65,15 @@ func computeLineHeight(viewport: Viewport, specified: CSSSpecifiedValues): int =
 
 proc newWord(state: var InlineState) =
   let word = InlineWord()
-  word.format = ComputedFormat()
+  let format = ComputedFormat()
   let specified = state.specified
-  word.format.color = specified{"color"}
-  word.format.fontstyle = specified{"font-style"}
-  word.format.fontweight = specified{"font-weight"}
-  word.format.textdecoration = specified{"text-decoration"}
-  word.format.node = state.node
+  format.color = specified{"color"}
+  format.fontstyle = specified{"font-style"}
+  format.fontweight = specified{"font-weight"}
+  format.textdecoration = specified{"text-decoration"}
+  format.node = state.node
+  word.format = format
+  state.ictx.format = format
   state.word = word
 
 proc horizontalAlignRow(ictx: InlineContext, row: InlineRow, specified: CSSSpecifiedValues, maxwidth: int, last = false) =
@@ -155,8 +157,21 @@ proc verticalAlignRow(ictx: InlineContext) =
       baseline - atom.height
     atom.rely += diff
 
+proc addSpacing(row: InlineRow, width, height: int, format: ComputedFormat) {.inline.} =
+  let spacing = InlineSpacing(width: width, height: height, format: format)
+  spacing.relx = row.width
+  row.width += spacing.width
+  row.atoms.add(spacing)
+
+proc flushWhitespace(ictx: InlineContext, specified: CSSSpecifiedValues) =
+  let shift = ictx.computeShift(specified)
+  ictx.whitespacenum = 0
+  if shift > 0:
+    ictx.thisrow.addSpacing(shift, ictx.cellheight, ictx.format)
+
 proc finishRow(ictx: InlineContext, specified: CSSSpecifiedValues, maxwidth: int, force = false) =
   if ictx.thisrow.atoms.len != 0 or force:
+    ictx.flushWhitespace(specified)
     ictx.verticalAlignRow()
 
     let oldrow = ictx.thisrow
@@ -170,37 +185,30 @@ proc finish(ictx: InlineContext, specified: CSSSpecifiedValues, maxwidth: int) =
   for row in ictx.rows:
     ictx.horizontalAlignRow(row, specified, maxwidth, row == ictx.rows[^1])
 
-proc addSpacing(row: InlineRow, width, height: int, format: ComputedFormat) {.inline.} =
-  let spacing = InlineSpacing(width: width, height: height, format: format)
-  spacing.relx = row.width
-  row.width += spacing.width
-  row.atoms.add(spacing)
-
 proc addAtom(ictx: InlineContext, atom: InlineAtom, maxwidth: int, specified: CSSSpecifiedValues) =
   var shift = ictx.computeShift(specified)
-  ictx.whitespace = false
+  ictx.whitespacenum = 0
   # Line wrapping
-  if specified{"white-space"} notin {WHITESPACE_NOWRAP, WHITESPACE_PRE}:
+  if not specified.whitespacepre:
     if ictx.thisrow.width + atom.width + shift > maxwidth:
       ictx.finishRow(specified, maxwidth, false)
       # Recompute on newline
       shift = ictx.computeShift(specified)
-      ictx.whitespace = false
 
   if atom.width > 0 and atom.height > 0:
     atom.vertalign = specified{"vertical-align"}
 
     if shift > 0:
-      let format = if atom of InlineWord:
-        InlineWord(atom).format
-      else:
-        nil
-      ictx.thisrow.addSpacing(shift, ictx.cellheight, format)
+      ictx.thisrow.addSpacing(shift, ictx.cellheight, ictx.format)
 
     atom.relx += ictx.thisrow.width
     ictx.thisrow.lineheight = max(ictx.thisrow.lineheight, computeLineHeight(ictx.viewport, specified))
     ictx.thisrow.width += atom.width
     ictx.thisrow.height = max(ictx.thisrow.height, atom.height)
+    if atom of InlineWord:
+      ictx.format = InlineWord(atom).format
+    else:
+      ictx.format = nil
     ictx.thisrow.atoms.add(atom)
 
 proc addWord(state: var InlineState) =
@@ -224,23 +232,23 @@ proc checkWrap(state: var InlineState, r: Rune) =
     if state.ictx.thisrow.width + state.word.width + shift + r.width() * state.ictx.cellwidth > state.maxwidth:
       state.addWord()
       state.ictx.finishRow(state.specified, state.maxwidth, false)
-      state.ictx.whitespace = false
+      state.ictx.whitespacenum = 0
   of WORD_BREAK_KEEP_ALL:
     if state.ictx.thisrow.width + state.word.width + shift + r.width() * state.ictx.cellwidth > state.maxwidth:
       state.ictx.finishRow(state.specified, state.maxwidth, false)
-      state.ictx.whitespace = false
+      state.ictx.whitespacenum = 0
   else: discard
 
 proc processWhitespace(state: var InlineState, c: char) =
   state.addWord()
   case state.specified{"white-space"}
   of WHITESPACE_NORMAL, WHITESPACE_NOWRAP:
-    state.ictx.whitespace = true
+    state.ictx.whitespacenum = max(state.ictx.whitespacenum, 1)
   of WHITESPACE_PRE_LINE, WHITESPACE_PRE, WHITESPACE_PRE_WRAP:
     if c == '\n':
       state.ictx.flushLine(state.specified, state.maxwidth)
     else:
-      state.ictx.whitespace = true
+      inc state.ictx.whitespacenum
 
 proc renderText*(ictx: InlineContext, str: string, maxwidth: int, specified: CSSSpecifiedValues, node: Node) =
   var state: InlineState
@@ -248,6 +256,7 @@ proc renderText*(ictx: InlineContext, str: string, maxwidth: int, specified: CSS
   state.ictx = ictx
   state.maxwidth = maxwidth
   state.node = node
+  state.ictx.flushWhitespace(state.specified)
   state.newWord()
 
   #if str.strip().len > 0:
@@ -420,7 +429,10 @@ proc arrangeInlines(bctx: BlockContext, selfcontained: bool) =
 
   bctx.width += bctx.padding_right
 
-  bctx.width = min(bctx.width, bctx.compwidth)
+  if bctx.specified{"width"}.auto:
+    bctx.width = min(bctx.width, bctx.compwidth)
+  else:
+    bctx.width = bctx.compwidth
 
 proc alignBlock(box: BlockBox, selfcontained = false)
 
@@ -434,7 +446,7 @@ proc alignInlineBlock(bctx: BlockContext, box: InlineBlockBox) =
   box.bctx.width += box.bctx.margin_right
 
   box.ictx.addAtom(box.bctx, bctx.compwidth, box.specified)
-  box.ictx.whitespace = false
+  box.ictx.whitespacenum = 0
 
 proc alignInline(bctx: BlockContext, box: InlineBox) =
   assert box.ictx != nil
@@ -563,10 +575,7 @@ proc getTextBox(box: CSSBox): InlineBox =
   new(result)
   result.t = DISPLAY_INLINE
   result.inlinelayout = true
-  if box.specified{"display"} == DISPLAY_INLINE:
-    result.specified = box.specified
-  else:
-    result.specified = box.specified.inheritProperties()
+  result.specified = box.specified.inheritProperties()
 
 proc getPseudoBox(bctx: BlockContext, specified: CSSSpecifiedValues): CSSBox =
   let box = getBox(specified)
@@ -591,6 +600,12 @@ proc getPseudoBox(bctx: BlockContext, specified: CSSSpecifiedValues): CSSBox =
     box.children.add(content)
   return box
 
+func getInputBox(box: CSSBox, input: HTMLInputElement, viewport: Viewport): InlineBox =
+  let textbox = box.getTextBox()
+  textbox.node = input
+  textbox.text.add(input.inputString())
+  return textbox
+
 proc generateBox(elem: Element, viewport: Viewport, bctx: BlockContext = nil): CSSBox =
   elem.rendered = true
   if viewport.map[elem.uid] != nil:
@@ -679,6 +694,10 @@ proc generateBox(elem: Element, viewport: Viewport, bctx: BlockContext = nil): C
       bbox.node = elem
       add_box(bbox)
 
+  if elem.tagType == TAG_INPUT:
+    let input = HTMLInputElement(elem)
+    add_box(box.getInputBox(input, viewport))
+
   for child in elem.childNodes:
     case child.nodeType
     of ELEMENT_NODE:
diff --git a/src/render/renderdocument.nim b/src/render/renderdocument.nim
index 732bcf47..8c34e273 100644
--- a/src/render/renderdocument.nim
+++ b/src/render/renderdocument.nim
@@ -128,6 +128,11 @@ proc renderInlineContext(grid: var FlexibleGrid, ctx: InlineContext, x, y: int,
   for row in ctx.rows:
     let x = x + row.relx
     let y = y + row.rely
+
+    let r = y div term.ppl
+    while grid.len <= r:
+      grid.addLine()
+
     for atom in row.atoms:
       if atom of BlockContext:
         let ctx = BlockContext(atom)
diff --git a/src/types/url.nim b/src/types/url.nim
index c8c7085e..d4c8b91c 100644
--- a/src/types/url.nim
+++ b/src/types/url.nim
@@ -42,7 +42,7 @@ type
     port: Option[uint16]
     host: Option[Host]
     path*: UrlPath
-    query: Option[string]
+    query*: Option[string]
     fragment: Option[string]
     blob: Option[BlobUrlEntry]
 
diff --git a/src/utils/twtstr.nim b/src/utils/twtstr.nim
index 428276f1..6f324f49 100644
--- a/src/utils/twtstr.nim
+++ b/src/utils/twtstr.nim
@@ -27,16 +27,6 @@ func ansiReset*(str: string): string =
   result &= str
   result &= ansiResetCode
 
-func maxString*(str: string, max: int): string =
-  if max < str.runeLen():
-    return str.runeSubstr(0, max - 2) & "$"
-  return str
-
-func fitValueToSize*(str: string, size: int): string =
-  if str.runeLen < size:
-    return str & ' '.repeat(size - str.runeLen)
-  return str.maxString(size)
-
 func isWhitespace*(c: char): bool {.inline.} =
   return c in {' ', '\n', '\r', '\t', '\f'}
 
@@ -419,14 +409,19 @@ const QueryPercentEncodeSet* = (ControlPercentEncodeSet + {' ', '"', '#', '<', '
 const SpecialQueryPercentEncodeSet* = (QueryPercentEncodeSet + {'\''})
 const PathPercentEncodeSet* = (QueryPercentEncodeSet + {'?', '`', '{', '}'})
 const UserInfoPercentEncodeSet* = (PathPercentEncodeSet + {'/', ':', ';', '=', '@', '['..'^', '|'})
-proc percentEncode*(append: var string, c: char, set: set[char]) {.inline.} =
-  if c notin set:
+const ComponentPercentEncodeSet* = (UserInfoPercentEncodeSet + {'$'..'&', '+', ','})
+const ApplicationXWWWFormUrlEncodedSet* = (ComponentPercentEncodeSet + {'!', '\''..')', '~'})
+
+proc percentEncode*(append: var string, c: char, set: set[char], spaceAsPlus = false) {.inline.} =
+  if spaceAsPlus and c == ' ':
+    append &= c
+  elif c notin set:
     append &= c
   else:
     append &= '%'
     append &= c.toHex()
 
-proc percentEncode*(append: var string, s: string, set: set[char]) {.inline.} =
+proc percentEncode*(append: var string, s: string, set: set[char], spaceAsPlus = false) {.inline.} =
   for c in s:
     append.percentEncode(c, set)
 
@@ -853,6 +848,23 @@ func width*(s: seq[Rune], min: int): int =
 func breaksWord*(r: Rune): bool =
   return not (r.isDigitAscii() or r.width() == 0 or r.isAlpha())
 
+func padToWidth*(str: string, size: int, schar = '$'): string =
+  if str.width() < size:
+    return str & ' '.repeat(size - str.width())
+  else:
+    let size = size - 1
+    result = newStringOfCap(str.len)
+
+    var w = 0
+    var i = 0
+    while i < str.len:
+      var r: Rune
+      fastRuneAt(str, i, r)
+      if w + r.width <= size:
+        result &= r
+        w += r.width
+    result &= schar
+
 const CanHaveDakuten = "かきくけこさしすせそたちつてとはひふへほカキクケコサシスセソタチツテトハヒフヘホ".toRunes()
 
 const CanHaveHandakuten = "はひふへほハヒフヘホ".toRunes()