about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2023-07-05 17:54:22 +0200
committerbptato <nincsnevem662@gmail.com>2023-07-05 18:01:00 +0200
commit4511c956bf62983f1d3f0252805fadaac040123e (patch)
tree9a04a74fda5ff70316948c775c7e133d5112b426
parent76cb8dee5e2066a7409f53edf74b2d3275ff4a7e (diff)
downloadchawan-4511c956bf62983f1d3f0252805fadaac040123e.tar.gz
Add popup menu for select element
Replaces the weird CSS implementation we have had until now with a
searchable popup menu similar to that of w3m. (The previous
implementation broke on websites that do not expect <select> to
expand on click, had no separate search, and was ugly.)
-rw-r--r--res/ua.css17
-rw-r--r--src/buffer/buffer.nim241
-rw-r--r--src/buffer/container.nim197
-rw-r--r--src/buffer/select.nim308
-rw-r--r--src/display/pager.nim66
-rw-r--r--src/render/renderdocument.nim2
-rw-r--r--src/utils/twtstr.nim5
-rw-r--r--todo5
8 files changed, 639 insertions, 202 deletions
diff --git a/res/ua.css b/res/ua.css
index 2d611737..ea5cc449 100644
--- a/res/ua.css
+++ b/res/ua.css
@@ -251,6 +251,10 @@ select {
 	display: inline-block;
 }
 
+select:focus {
+	visibility: hidden;
+}
+
 select::before {
 	content: '[';
 	color: initial;
@@ -272,17 +276,16 @@ select > :is(option, optgroup > option):checked {
 	color: red;
 }
 
-select:focus > :is(option, optgroup > option) {
-	display: list-item;
-	color: white;
+select[multiple] > :is(option, optgroup > option):first-child {
+	display: inline-block;
 }
 
-select:focus > :is(option, optgroup > option):checked {
-	color: pink;
+select[multiple] > :is(option, optgroup > option):not(:checked):first-child::after {
+	content: '(...)';
 }
 
-select:focus > :is(option, optgroup > option):hover {
-	color: red;
+select[multiple] > :is(option, optgroup > option):not(:first-child):checked::before {
+	content: ',';
 }
 
 center {
diff --git a/src/buffer/buffer.nim b/src/buffer/buffer.nim
index 330c7fcf..d9060d4c 100644
--- a/src/buffer/buffer.nim
+++ b/src/buffer/buffer.nim
@@ -57,7 +57,7 @@ type
     LOAD, RENDER, WINDOW_CHANGE, FIND_ANCHOR, READ_SUCCESS, READ_CANCELED,
     CLICK, FIND_NEXT_LINK, FIND_PREV_LINK, FIND_NEXT_MATCH, FIND_PREV_MATCH,
     GET_SOURCE, GET_LINES, UPDATE_HOVER, PASS_FD, CONNECT, GOTO_ANCHOR, CANCEL,
-    GET_TITLE
+    GET_TITLE, SELECT
 
   # LOADING_PAGE: istream open
   # LOADING_RESOURCES: istream closed, resources open
@@ -963,106 +963,146 @@ type ReadLineResult* = object
   hide*: bool
   area*: bool
 
-type ClickResult* = object
-  open*: Option[Request]
-  readline*: Option[ReadLineResult]
-  repaint*: bool
+type
+  SelectResult* = object
+    multiple*: bool
+    options*: seq[string]
+    selected*: seq[int]
+
+  ClickResult* = object
+    open*: Option[Request]
+    readline*: Option[ReadLineResult]
+    repaint*: bool
+    select*: Option[SelectResult]
+
+proc click(buffer: Buffer, clickable: Element): ClickResult
+
+proc click(buffer: Buffer, label: HTMLLabelElement): ClickResult =
+  let control = label.control
+  if control != nil:
+    return buffer.click(control)
+
+proc click(buffer: Buffer, select: HTMLSelectElement): ClickResult =
+  let repaint = buffer.setFocus(select)
+  var options: seq[string]
+  var selected: seq[int]
+  var i = 0
+  for option in select.options:
+    options.add(option.textContent.stripAndCollapse())
+    if option.selected:
+      selected.add(i)
+    inc i
+  let select = SelectResult(
+    multiple: select.attrb("multiple"),
+    options: options,
+    selected: selected
+  )
+  return ClickResult(
+    repaint: repaint,
+    select: some(select)
+  )
+
+proc click(buffer: Buffer, anchor: HTMLAnchorElement): ClickResult =
+  let repaint = buffer.restoreFocus()
+  let url = parseURL(anchor.href, some(anchor.document.baseURL))
+  if url.isSome:
+    return ClickResult(
+      repaint: repaint,
+      open: some(newRequest(url.get, HTTP_GET))
+    )
+  return ClickResult(
+    repaint: repaint
+  )
+
+proc click(buffer: Buffer, option: HTMLOptionElement): ClickResult =
+  let select = option.select
+  if select != nil:
+    return buffer.click(select)
+
+proc click(buffer: Buffer, button: HTMLButtonElement): ClickResult =
+  if button.form != nil:
+    case button.ctype
+    of BUTTON_SUBMIT: result.open = submitForm(button.form, button)
+    of BUTTON_RESET:
+      button.form.reset()
+      buffer.do_reshape()
+      return ClickResult(repaint: true)
+    of BUTTON_BUTTON: discard
+    result.repaint = buffer.setFocus(button)
+
+proc click(buffer: Buffer, textarea: HTMLTextAreaElement): ClickResult =
+  let readline = ReadLineResult(
+    value: textarea.value,
+    area: true
+  )
+  return ClickResult(readline: some(readline))
+
+proc click(buffer: Buffer, input: HTMLInputElement): ClickResult =
+  result.repaint = buffer.restoreFocus()
+  case input.inputType
+  of INPUT_SEARCH:
+    result.repaint = buffer.setFocus(input)
+    result.readline = some(ReadLineResult(
+      prompt: "SEARCH: ",
+      value: input.value
+    ))
+  of INPUT_TEXT, INPUT_PASSWORD:
+    result.repaint = buffer.setFocus(input)
+    result.readline = some(ReadLineResult(
+      prompt: "TEXT: ",
+      value: input.value,
+      hide: input.inputType == INPUT_PASSWORD
+    ))
+  of INPUT_FILE:
+    result.repaint = buffer.setFocus(input)
+    var path = if input.file.issome:
+      input.file.get.path.serialize_unicode()
+    else:
+      ""
+    result.readline = some(ReadLineResult(
+      prompt: "Filename: ",
+      value: path
+    ))
+  of INPUT_CHECKBOX:
+    input.checked = not input.checked
+    input.invalid = true
+    result.repaint = true
+    buffer.do_reshape()
+  of INPUT_RADIO:
+    for radio in input.radiogroup:
+      radio.checked = false
+      radio.invalid = true
+    input.checked = true
+    input.invalid = true
+    result.repaint = true
+    buffer.do_reshape()
+  of INPUT_RESET:
+    if input.form != nil:
+      input.form.reset()
+      result.repaint = true
+      buffer.do_reshape()
+  of INPUT_SUBMIT, INPUT_BUTTON:
+    if input.form != nil:
+      result.open = submitForm(input.form, input)
+  else:
+    result.repaint = buffer.restoreFocus()
 
 proc click(buffer: Buffer, clickable: Element): ClickResult =
   case clickable.tagType
   of TAG_LABEL:
-    let label = HTMLLabelElement(clickable)
-    let control = label.control
-    if control != nil:
-      return buffer.click(control)
+    return buffer.click(HTMLLabelElement(clickable))
   of TAG_SELECT:
-    result.repaint = buffer.setFocus(clickable)
+    return buffer.click(HTMLSelectElement(clickable))
   of TAG_A:
-    result.repaint = buffer.restoreFocus()
-    let url = parseURL(HTMLAnchorElement(clickable).href, clickable.document.baseURL.some)
-    if url.issome:
-      result.open = some(newRequest(url.get, HTTP_GET))
+    return buffer.click(HTMLAnchorElement(clickable))
   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
-        result.repaint = buffer.restoreFocus()
-      else:
-        # focus on select
-        result.repaint = buffer.setFocus(select)
+    return buffer.click(HTMLOptionElement(clickable))
   of TAG_BUTTON:
-    let button = HTMLButtonElement(clickable)
-    if button.form != nil:
-      case button.ctype
-      of BUTTON_SUBMIT: result.open = submitForm(button.form, button)
-      of BUTTON_RESET:
-        button.form.reset()
-        result.repaint = true
-        buffer.do_reshape()
-      of BUTTON_BUTTON: discard
+    return buffer.click(HTMLButtonElement(clickable))
   of TAG_TEXTAREA:
-    result.repaint = buffer.setFocus(clickable)
-    let textarea = HTMLTextAreaElement(clickable)
-    result.readline = some(ReadLineResult(
-      value: textarea.value,
-      area: true
-    ))
+    return buffer.click(HTMLTextAreaElement(clickable))
   of TAG_INPUT:
-    result.repaint = buffer.restoreFocus()
-    let input = HTMLInputElement(clickable)
-    case input.inputType
-    of INPUT_SEARCH:
-      result.repaint = buffer.setFocus(input)
-      result.readline = some(ReadLineResult(
-        prompt: "SEARCH: ",
-        value: input.value
-      ))
-    of INPUT_TEXT, INPUT_PASSWORD:
-      result.repaint = buffer.setFocus(input)
-      result.readline = some(ReadLineResult(
-        prompt: "TEXT: ",
-        value: input.value,
-        hide: input.inputType == INPUT_PASSWORD
-      ))
-    of INPUT_FILE:
-      result.repaint = buffer.setFocus(input)
-      var path = if input.file.issome:
-        input.file.get.path.serialize_unicode()
-      else:
-        ""
-      result.readline = some(ReadLineResult(
-        prompt: "Filename: ",
-        value: path
-      ))
-    of INPUT_CHECKBOX:
-      input.checked = not input.checked
-      input.invalid = true
-      result.repaint = true
-      buffer.do_reshape()
-    of INPUT_RADIO:
-      for radio in input.radiogroup:
-        radio.checked = false
-        radio.invalid = true
-      input.checked = true
-      input.invalid = true
-      result.repaint = true
-      buffer.do_reshape()
-    of INPUT_RESET:
-      if input.form != nil:
-        input.form.reset()
-        result.repaint = true
-        buffer.do_reshape()
-    of INPUT_SUBMIT, INPUT_BUTTON:
-      if input.form != nil:
-        result.open = submitForm(input.form, input)
-    else:
-      result.repaint = buffer.restoreFocus()
+    return buffer.click(HTMLInputElement(clickable))
   else:
     result.repaint = buffer.restoreFocus()
 
@@ -1072,6 +1112,25 @@ proc click*(buffer: Buffer, cursorx, cursory: int): ClickResult {.proxy.} =
   if clickable != nil:
     return buffer.click(clickable)
 
+proc select*(buffer: Buffer, selected: seq[int]): ClickResult {.proxy.} =
+  if buffer.document.focus != nil and
+      buffer.document.focus.tagType == TAG_SELECT:
+    let select = HTMLSelectElement(buffer.document.focus)
+    var i = 0
+    var j = 0
+    var repaint = false
+    for option in select.options:
+      var wasSelected = option.selected
+      if i < selected.len and selected[i] == j:
+        option.selected = true
+        inc i
+      else:
+        option.selected = false
+      if not repaint:
+        repaint = wasSelected != option.selected
+      inc j
+    return ClickResult(repaint: buffer.restoreFocus())
+
 proc readCanceled*(buffer: Buffer): bool {.proxy.} =
   return buffer.restoreFocus()
 
diff --git a/src/buffer/container.nim b/src/buffer/container.nim
index 21b11af6..ab5bc6f7 100644
--- a/src/buffer/container.nim
+++ b/src/buffer/container.nim
@@ -10,6 +10,7 @@ when defined(posix):
 
 import buffer/buffer
 import buffer/cell
+import buffer/select
 import config/config
 import io/promise
 import io/request
@@ -20,6 +21,7 @@ import ips/socketstream
 import js/javascript
 import js/regex
 import types/buffersource
+import types/color
 import types/cookie
 import types/dispatcher
 import types/url
@@ -96,6 +98,7 @@ type
     startpos: Option[CursorPosition]
     hasstart: bool
     redirectdepth*: int
+    select*: Select
 
 jsDestructor(Container)
 
@@ -407,16 +410,28 @@ proc setCursorXY(container: Container, x, y: int) {.jsfunc.} =
     container.centerLine()
 
 proc cursorDown(container: Container) {.jsfunc.} =
-  container.setCursorY(container.cursory + 1)
+  if container.select.open:
+    container.select.cursorDown()
+  else:
+    container.setCursorY(container.cursory + 1)
 
 proc cursorUp(container: Container) {.jsfunc.} =
-  container.setCursorY(container.cursory - 1)
+  if container.select.open:
+    container.select.cursorUp()
+  else:
+    container.setCursorY(container.cursory - 1)
 
 proc cursorLeft(container: Container) {.jsfunc.} =
-  container.setCursorX(container.cursorFirstX() - 1)
+  if container.select.open:
+    container.select.cursorLeft()
+  else:
+    container.setCursorX(container.cursorFirstX() - 1)
 
 proc cursorRight(container: Container) {.jsfunc.} =
-  container.setCursorX(container.cursorLastX() + 1)
+  if container.select.open:
+    container.select.cursorRight()
+  else:
+    container.setCursorX(container.cursorLastX() + 1)
 
 proc cursorLineBegin(container: Container) {.jsfunc.} =
   container.setCursorX(0)
@@ -512,10 +527,16 @@ proc halfPageDown(container: Container) {.jsfunc.} =
   container.restoreCursorX()
 
 proc cursorFirstLine(container: Container) {.jsfunc.} =
-  container.setCursorY(0)
+  if container.select.open:
+    container.select.cursorFirstLine()
+  else:
+    container.setCursorY(0)
 
 proc cursorLastLine*(container: Container) {.jsfunc.} =
-  container.setCursorY(container.numLines - 1)
+  if container.select.open:
+    container.select.cursorLastLine()
+  else:
+    container.setCursorY(container.numLines - 1)
 
 proc cursorTop(container: Container) {.jsfunc.} =
   container.setCursorY(container.fromy)
@@ -592,14 +613,20 @@ proc gotoLine*[T: string|int](container: Container, s: T) =
     container.setCursorY(s)
 
 proc pushCursorPos*(container: Container) =
-  container.bpos.add(container.pos)
+  if container.select.open:
+    container.select.pushCursorPos()
+  else:
+    container.bpos.add(container.pos)
 
 proc popCursorPos*(container: Container, nojump = false) =
-  container.pos = container.bpos.pop()
-  if not nojump:
-    container.updateCursor()
-    container.sendCursorPosition()
-    container.needslines = true
+  if container.select.open:
+    container.select.popCursorPos(nojump)
+  else:
+    container.pos = container.bpos.pop()
+    if not nojump:
+      container.updateCursor()
+      container.sendCursorPosition()
+      container.needslines = true
 
 proc copyCursorPos*(container, c2: Container) =
   container.startpos = some(c2.pos)
@@ -629,7 +656,7 @@ proc onMatch(container: Container, res: BufferMatch) =
     container.setCursorXY(res.x, res.y)
     if container.hlon:
       container.clearSearchHighlights()
-      let ex = res.str.twidth(res.x) - 1
+      let ex = res.x + res.str.twidth(res.x) - 1
       let hl = Highlight(x: res.x, y: res.y, endx: ex, endy: res.y, clear: true)
       container.highlights.add(hl)
       container.triggerEvent(UPDATE)
@@ -641,16 +668,22 @@ proc onMatch(container: Container, res: BufferMatch) =
     container.hlon = false
 
 proc cursorNextMatch*(container: Container, regex: Regex, wrap: bool) =
-  container.iface
-    .findNextMatch(regex, container.cursorx, container.cursory, wrap)
-    .then(proc(res: BufferMatch) =
-      container.onMatch(res))
+  if container.select.open:
+    container.select.cursorNextMatch(regex, wrap)
+  else:
+    container.iface
+      .findNextMatch(regex, container.cursorx, container.cursory, wrap)
+      .then(proc(res: BufferMatch) =
+        container.onMatch(res))
 
 proc cursorPrevMatch*(container: Container, regex: Regex, wrap: bool) =
-  container.iface
-    .findPrevMatch(regex, container.cursorx, container.cursory, wrap)
-    .then(proc(res: BufferMatch) =
-      container.onMatch(res))
+  if container.select.open:
+    container.select.cursorPrevMatch(regex, wrap)
+  else:
+    container.iface
+      .findPrevMatch(regex, container.cursorx, container.cursory, wrap)
+      .then(proc(res: BufferMatch) =
+        container.onMatch(res))
 
 proc setLoadInfo(container: Container, msg: string) =
   container.loadinfo = msg
@@ -723,8 +756,11 @@ proc load(container: Container) =
         container.onload(res))
 
 proc cancel*(container: Container) {.jsfunc.} =
-  container.canceled = true
-  container.alert("Canceled loading")
+  if container.select.open:
+    container.select.cancel()
+  else:
+    container.canceled = true
+    container.alert("Canceled loading")
 
 proc findAnchor*(container: Container, anchor: string) =
   container.iface.findAnchor(anchor).then(proc(found: bool) =
@@ -764,28 +800,45 @@ proc dupeBuffer*(dispatcher: Dispatcher, container: Container, config: Config, l
       container.pipeto = nil)
   return container.pipeto
 
+proc onclick(container: Container, res: ClickResult)
+
+proc displaySelect(container: Container, selectResult: SelectResult) =
+  let submitSelect = proc(selected: seq[int]) =
+    container.iface.select(selected).then(proc(res: ClickResult) =
+      container.onclick(res))
+  container.select.initSelect(selectResult, container.acursorx,
+    container.acursory, container.height, submitSelect)
+  container.triggerEvent(UPDATE)
+
+proc onclick(container: Container, res: ClickResult) =
+  if res.repaint:
+    container.needslines = true
+  if res.open.isSome:
+    container.triggerEvent(ContainerEvent(t: OPEN, request: res.open.get))
+  if res.select.isSome:
+    container.displaySelect(res.select.get)
+  if res.readline.isSome:
+    let rl = res.readline.get
+    let event = if rl.area:
+      ContainerEvent(
+        t: READ_AREA,
+        tvalue: rl.value
+      )
+    else:
+      ContainerEvent(
+        t: READ_LINE,
+        prompt: rl.prompt,
+        value: rl.value,
+        password: rl.hide
+      )
+    container.triggerEvent(event)
+
 proc click(container: Container) {.jsfunc.} =
-  container.iface.click(container.cursorx, container.cursory).then(proc(res: ClickResult) =
-    if res.repaint:
-      container.needslines = true
-    if res.open.isSome:
-      container.triggerEvent(ContainerEvent(t: OPEN, request: res.open.get))
-    if res.readline.isSome:
-      let rl = res.readline.get
-      if rl.area:
-        container.triggerEvent(
-          ContainerEvent(
-            t: READ_AREA,
-            tvalue: rl.value
-          ))
-      else:
-        container.triggerEvent(
-          ContainerEvent(
-            t: READ_LINE,
-            prompt: rl.prompt,
-            value: rl.value,
-            password: rl.hide
-          )))
+  if container.select.open:
+    container.select.click()
+  else:
+    container.iface.click(container.cursorx, container.cursory)
+      .then(proc(res: ClickResult) = container.onclick(res))
 
 proc windowChange*(container: Container, attrs: WindowAttributes) =
   container.attrs = attrs
@@ -861,6 +914,62 @@ proc readLines*(container: Container, handle: (proc(line: SimpleFlexibleLine)))
       # fulfill all promises
       container.handleCommand()
 
+proc drawLines*(container: Container, display: var FixedGrid,
+    hlcolor: CellColor) =
+  var r: Rune
+  var by = 0
+  let endy = min(container.fromy + display.height, container.numLines)
+  for line in container.ilines(container.fromy ..< endy):
+    var w = 0 # width of the row so far
+    var i = 0 # byte in line.str
+    # Skip cells till fromx.
+    while w < container.fromx and i < line.str.len:
+      fastRuneAt(line.str, i, r)
+      w += r.twidth(w)
+    let dls = by * display.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:
+        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)
+      let rw = r.twidth(w)
+      w += rw
+      if w > container.fromx + display.width:
+        break # die on exceeding the width limit
+      if nf.pos != -1 and nf.pos <= pw:
+        cf = nf
+        nf = line.findNextFormat(pw)
+      if cf.pos != -1:
+        display[dls + k].format = cf.format
+      if r == Rune('\t'):
+        # Needs to be replaced with spaces, otherwise bgcolor isn't displayed.
+        let tk = k + rw
+        while k < tk:
+          display[dls + k].str &= ' '
+          inc k
+      else:
+        display[dls + k].str &= r
+        k += rw
+    # Finally, override cell formatting for highlighted cells.
+    let hls = container.findHighlights(container.fromy + by)
+    let aw = container.width - (startw - container.fromx) # actual width
+    for hl in hls:
+      let area = hl.colorArea(container.fromy + by, startw .. startw + aw)
+      for i in area:
+        var hlformat = display[dls + i - startw].format
+        hlformat.bgcolor = hlcolor
+        display[dls + i - startw].format = hlformat
+    inc by
+
 proc handleEvent*(container: Container) =
   container.handleCommand()
   if container.needslines:
diff --git a/src/buffer/select.nim b/src/buffer/select.nim
new file mode 100644
index 00000000..1b1993c5
--- /dev/null
+++ b/src/buffer/select.nim
@@ -0,0 +1,308 @@
+import unicode
+
+import buffer/buffer
+import buffer/cell
+import js/regex
+import utils/twtstr
+
+type
+  SubmitSelect* = proc(selected: seq[int])
+  CloseSelect* = proc()
+
+  Select* = object
+    open*: bool
+    options: seq[string]
+    multiple: bool
+    # old selection
+    oselected*: seq[int]
+    # new selection
+    selected*: seq[int]
+    # cursor distance from y
+    cursor: int
+    # widest option
+    maxw: int
+    # maximum height on screen (yes the naming is dumb)
+    maxh: int
+    # first index to display
+    si: int
+    # location on screen
+    x: int
+    y: int
+    redraw*: bool
+    submitFun: SubmitSelect
+    bpos: seq[int]
+
+proc windowChange*(select: var Select, height: int) =
+  select.maxh = height - 2
+  if select.y + select.options.len >= select.maxh:
+    select.y = height - select.options.len
+    if select.y < 0:
+      select.si = -select.y
+      select.y = 0
+  if select.selected.len > 0:
+    let i = select.selected[0]
+    if select.si > i:
+      select.si = i
+    elif select.si + select.maxh < i:
+      select.si = max(i - select.maxh, 0)
+  select.redraw = true
+
+proc initSelect*(select: var Select, selectResult: SelectResult,
+    x, y, height: int, submitFun: SubmitSelect) =
+  select.open = true
+  select.multiple = selectResult.multiple
+  select.options = selectResult.options
+  select.oselected = selectResult.selected
+  select.selected = selectResult.selected
+  select.submitFun = submitFun
+  for opt in select.options.mitems:
+    opt.mnormalize()
+    select.maxw = max(select.maxw, opt.width())
+  select.x = x
+  select.y = y
+  select.windowChange(height)
+
+# index of option currently under cursor
+func hover(select: Select): int =
+  return select.cursor + select.si
+
+func dispheight(select: Select): int =
+  return select.maxh - select.y
+
+proc `hover=`(select: var Select, i: int) =
+  let i = clamp(i, 0, select.options.high)
+  if i >= select.si + select.dispheight:
+    select.si = i - select.dispheight + 1
+    select.cursor = select.dispheight - 1
+  elif i < select.si:
+    select.si = i
+    select.cursor = 0
+  else:
+    select.cursor = i - select.si
+
+proc cursorDown*(select: var Select) =
+  if select.hover < select.options.high and
+      select.cursor + select.y < select.maxh - 1:
+    inc select.cursor
+    select.redraw = true
+  elif select.si < select.options.len - select.maxh:
+    inc select.si
+    select.redraw = true
+
+proc cursorUp*(select: var Select) =
+  if select.cursor > 0:
+    dec select.cursor
+    select.redraw = true
+  elif select.si > 0:
+    dec select.si
+    select.redraw = true
+  elif select.multiple and select.cursor > -1:
+    select.cursor = -1
+
+proc close(select: var Select) =
+  select = Select()
+
+proc cancel*(select: var Select) =
+  select.submitFun(select.oselected)
+  select.close()
+
+proc submit(select: var Select) =
+  select.submitFun(select.selected)
+  select.close()
+
+proc click*(select: var Select) =
+  if not select.multiple:
+    select.selected = @[select.hover]
+    select.submit()
+  elif select.cursor == -1:
+    select.submit()
+  else:
+    var k = select.selected.len
+    let i = select.hover
+    for j in 0 ..< select.selected.len:
+      if select.selected[j] >= i:
+        k = j
+        break
+    if k < select.selected.len and select.selected[k] == i:
+      select.selected.delete(k)
+    else:
+      select.selected.insert(i, k)
+    select.redraw = true
+
+proc cursorLeft*(select: var Select) =
+  select.submit()
+
+proc cursorRight*(select: var Select) =
+  select.click()
+
+proc getCursorX*(select: var Select): int =
+  if select.cursor == -1:
+    return select.x
+  return select.x + 1
+
+proc getCursorY*(select: var Select): int =
+  return select.y + 1 + select.cursor
+
+proc cursorFirstLine*(select: var Select) =
+  if select.cursor != 0 or select.si != 0:
+    select.cursor = 0
+    select.si = 0
+    select.redraw = true
+
+proc cursorLastLine*(select: var Select) =
+  if select.hover < select.options.len:
+    select.cursor = select.dispheight - 1
+    select.si = max(select.options.len - select.maxh, 0)
+    select.redraw = true
+
+proc cursorNextMatch*(select: var Select, regex: Regex, wrap: bool) =
+  var j = -1
+  for i in select.hover + 1 ..< select.options.len:
+    if regex.exec(select.options[i]).success:
+      j = i
+      break
+  if j != -1:
+    select.hover = j
+    select.redraw = true
+  elif wrap:
+    for i in 0 ..< select.hover:
+      if regex.exec(select.options[i]).success:
+        j = i
+        break
+    if j != -1:
+      select.hover = j
+      select.redraw = true
+
+proc cursorPrevMatch*(select: var Select, regex: Regex, wrap: bool) =
+  var j = -1
+  for i in countdown(select.hover - 1, 0):
+    if regex.exec(select.options[i]).success:
+      j = i
+      break
+  if j != -1:
+    select.hover = j
+    select.redraw = true
+  elif wrap:
+    for i in countdown(select.options.high, select.hover):
+      if regex.exec(select.options[i]).success:
+        j = i
+        break
+    if j != -1:
+      select.hover = j
+      select.redraw = true
+
+proc pushCursorPos*(select: var Select) =
+  select.bpos.add(select.hover)
+
+proc popCursorPos*(select: var Select, nojump = false) =
+  let was = select.hover
+  let got = select.bpos.pop()
+  select.hover = got
+  if not nojump:
+    select.redraw = true
+
+const HorizontalBar = $Rune(0x2500)
+const VerticalBar = $Rune(0x2502)
+const CornerTopLeft = $Rune(0x250C)
+const CornerTopRight = $Rune(0x2510)
+const CornerBottomLeft = $Rune(0x2514)
+const CornerBottomRight = $Rune(0x2518)
+
+proc drawBorders(display: var FixedGrid, sx, ex, sy, ey: int,
+    upmore, downmore: bool) =
+  for y in sy .. ey:
+    var x = 0
+    while x < sx:
+      if display[y * display.width + x].str == "":
+        display[y * display.width + x].str = " "
+        inc x
+      else:
+        #x = display[y * display.width + x].str.twidth(x)
+        inc x
+  # Draw corners.
+  let tl = if upmore: VerticalBar else: CornerTopLeft
+  let tr = if upmore: VerticalBar else: CornerTopRight
+  let bl = if downmore: VerticalBar else: CornerBottomLeft
+  let br = if downmore: VerticalBar else: CornerBottomRight
+  const fmt = newFormat()
+  display[sy * display.width + sx].str = tl
+  display[sy * display.width + ex].str = tr
+  display[ey * display.width + sx].str = bl
+  display[ey * display.width + ex].str = br
+  display[sy * display.width + sx].format = fmt
+  display[sy * display.width + ex].format = fmt
+  display[ey * display.width + sx].format = fmt
+  display[ey * display.width + ex].format = fmt
+  # Draw top, bottom borders.
+  let ups = if upmore: " " else: HorizontalBar
+  let downs = if downmore: " " else: HorizontalBar
+  for x in sx + 1 .. ex - 1:
+    display[sy * display.width + x].str = ups
+    display[ey * display.width + x].str = downs
+    display[sy * display.width + x].format = fmt
+    display[ey * display.width + x].format = fmt
+  if upmore:
+    display[sy * display.width + sx + (ex - sx) div 2].str = ":"
+  if downmore:
+    display[ey * display.width + sx + (ex - sx) div 2].str = ":"
+  # Draw left, right borders.
+  for y in sy + 1 .. ey - 1:
+    display[y * display.width + sx].str = VerticalBar
+    display[y * display.width + ex].str = VerticalBar
+    display[y * display.width + sx].format = fmt
+    display[y * display.width + ex].format = fmt
+
+proc drawSelect*(select: Select, display: var FixedGrid) =
+  if display.width < 2 or display.height < 2:
+    return # border does not fit...
+  # Max width, height with one row/column on the sides.
+  let mw = display.width - 2
+  let mh = display.height - 2
+  var sy = select.y
+  let si = select.si
+  var ey = min(sy + select.options.len, mh) + 1
+  var sx = select.x
+  if sx + select.maxw >= mw:
+    sx = display.width - select.maxw
+    if sx < 0:
+      # This means the widest option is wider than the available screen.
+      # w3m simply cuts off the part that doesn't fit, and we do that too,
+      # but I feel like this may not be the best solution.
+      sx = 0
+  var ex = min(sx + select.maxw, mw) + 1
+  let upmore = select.si > 0
+  let downmore = select.si + mh < select.options.len
+  drawBorders(display, sx, ex, sy, ey, upmore, downmore)
+  if select.multiple and not upmore:
+    display[sy * display.width + sx].str = "X"
+  # move inside border
+  inc sy
+  inc sx
+  var r: Rune
+  var k = 0
+  var format = newFormat()
+  while k < select.selected.len and select.selected[k] < si:
+    inc k
+  for y in sy ..< ey:
+    let i = y - sy + si
+    var j = 0
+    var x = sx
+    let dls = y * display.width
+    if k < select.selected.len and select.selected[k] == i:
+      format.reverse = true
+      inc k
+    else:
+      format.reverse = false
+    while j < select.options[i].len:
+      fastRuneAt(select.options[i], j, r)
+      let rw = r.twidth(x)
+      let ox = x
+      x += rw
+      if x > ex:
+        break
+      display[dls + ox].str = $r
+      display[dls + ox].format = format
+    while x < ex:
+      display[dls + x].str = " "
+      display[dls + x].format = format
+      inc x
diff --git a/src/display/pager.nim b/src/display/pager.nim
index 2698550c..0bbdd98f 100644
--- a/src/display/pager.nim
+++ b/src/display/pager.nim
@@ -11,6 +11,7 @@ when defined(posix):
 
 import buffer/cell
 import buffer/container
+import buffer/select
 import config/config
 import data/charset
 import display/term
@@ -205,59 +206,9 @@ proc clearDisplay(pager: Pager) =
 proc buffer(pager: Pager): Container {.jsfget, inline.} = pager.container
 
 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.display.height, container.numLines)):
-    var w = 0 # width of the row so far
-    var i = 0 # byte in line.str
-    # Skip cells till fromx.
-    while w < container.fromx and i < line.str.len:
-      fastRuneAt(line.str, i, r)
-      w += r.twidth(w)
-    let dls = by * pager.display.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)
-      let rw = r.twidth(w)
-      w += rw
-      if w > container.fromx + pager.display.width:
-        break # die on exceeding the width limit
-      if nf.pos != -1 and nf.pos <= pw:
-        cf = nf
-        nf = line.findNextFormat(pw)
-      if cf.pos != -1:
-        pager.display[dls + k].format = cf.format
-      if r == Rune('\t'):
-        # Needs to be replaced with spaces, otherwise bgcolor isn't displayed.
-        let tk = k + rw
-        while k < tk:
-          pager.display[dls + k].str &= ' '
-          inc k
-      else:
-        pager.display[dls + k].str &= r
-        k += rw
-    # Finally, override cell formatting for highlighted cells.
-    let hls = container.findHighlights(container.fromy + by)
-    let aw = container.width - (startw - container.fromx) # actual width
-    for hl in hls:
-      let area = hl.colorArea(container.fromy + by, startw .. startw + aw)
-      for i in area:
-        var hlformat = pager.display[dls + i - startw].format
-        hlformat.bgcolor = pager.config.display.highlight_color.cellColor()
-        pager.display[dls + i - startw].format = hlformat
-    inc by
+  container.drawLines(pager.display,
+    cellColor(pager.config.display.highlight_color))
 
 # Note: this function doesn't work if start < i of last written char
 proc writeStatusMessage(pager: Pager, str: string,
@@ -356,11 +307,15 @@ proc redraw(pager: Pager) {.jsfunc.} =
   pager.term.clearCanvas()
 
 proc draw*(pager: Pager) =
-  if pager.container == nil: return
+  let container = pager.container
+  if container == nil: return
   pager.term.hideCursor()
   if pager.redraw:
     pager.refreshDisplay()
     pager.term.writeGrid(pager.display)
+  if container.select.open and container.select.redraw:
+    container.select.drawSelect(pager.display)
+    pager.term.writeGrid(pager.display)
   if pager.askpromise != nil:
     discard
   elif pager.lineedit.isSome:
@@ -383,6 +338,9 @@ proc draw*(pager: Pager) =
       pager.lineedit.get.fullRedraw()
       pager.lineedit.get.isnew = false
     pager.term.setCursor(pager.lineedit.get.getCursorX(), pager.attrs.height - 1)
+  elif container.select.open:
+    pager.term.setCursor(container.select.getCursorX(),
+      container.select.getCursorY())
   else:
     pager.term.setCursor(pager.container.acursorx, pager.container.acursory)
   pager.term.showCursor()
@@ -735,13 +693,13 @@ proc updateReadLineISearch(pager: Pager, linemode: LineMode) =
     let x = $lineedit.news
     if x != "": pager.iregex = compileSearchRegex(x)
     pager.container.popCursorPos(true)
+    pager.container.pushCursorPos()
     if pager.iregex.isSome:
       pager.container.hlon = true
       if linemode == ISEARCH_F:
         pager.container.cursorNextMatch(pager.iregex.get, pager.config.search.wrap)
       else:
         pager.container.cursorPrevMatch(pager.iregex.get, pager.config.search.wrap)
-    pager.container.pushCursorPos()
   of FINISH:
     pager.regex = pager.checkRegex(pager.iregex)
     pager.reverseSearch = linemode == ISEARCH_B
diff --git a/src/render/renderdocument.nim b/src/render/renderdocument.nim
index afde059b..6f8b2a80 100644
--- a/src/render/renderdocument.nim
+++ b/src/render/renderdocument.nim
@@ -59,7 +59,7 @@ proc setText(lines: var FlexibleGrid, linestr: string, cformat: ComputedFormat,
     lines[y].str &= ' '.repeat(padwidth)
 
   lines[y].str &= linestr
-  let linestrwidth = linestr.twidth(x) - x
+  let linestrwidth = linestr.twidth(x)
 
   i = 0
   var nx = x # last x of new string
diff --git a/src/utils/twtstr.nim b/src/utils/twtstr.nim
index f735252c..60b85cbc 100644
--- a/src/utils/twtstr.nim
+++ b/src/utils/twtstr.nim
@@ -1068,9 +1068,10 @@ func width*(s: seq[Rune], min: int): int =
     inc i
 
 func twidth*(s: string, w: int): int =
-  result = w
+  var i = 0
   for r in s.runes():
-    result += r.twidth(result)
+    i += r.twidth(result)
+  return i
 
 func breaksWord*(r: Rune): bool =
   return not (r.isDigitAscii() or r.width() == 0 or r.isAlpha())
diff --git a/todo b/todo
index c23e689c..c4e6e260 100644
--- a/todo
+++ b/todo
@@ -74,6 +74,7 @@ javascript:
 	* alternative idea: separate console for each buffer
 - implement JS access to non-ref object members (with a ptr opaque + GC_ref
   on parent object or something)
+- buffer selection
 layout engine:
 - important: floats (way too many websites look very ugly without them)
 - make background-color a property of inline boxes, not words. (this would
@@ -83,9 +84,7 @@ layout engine:
 - overflow
 - incremental layout & layout caching
 	* first for tree generation, then for layout.
-- more replaced elements: iframe, select
-	* for select we should have a built-in dropdown list, like w3m. then
-	  we could use that for buffer selection too
+- iframe
 - writing-mode, flexbox, grid, ruby, ... (i.e. cool new stuff)
 images:
 - sixel encoding (eventually also kitty)