about summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config/config.nim4
-rw-r--r--src/css/cascade.nim6
-rw-r--r--src/html/dom.nim2
-rw-r--r--src/img/png.nim67
-rw-r--r--src/io/bufreader.nim5
-rw-r--r--src/io/bufwriter.nim8
-rw-r--r--src/layout/box.nim5
-rw-r--r--src/layout/engine.nim26
-rw-r--r--src/layout/renderdocument.nim39
-rw-r--r--src/local/container.nim9
-rw-r--r--src/local/pager.nim5
-rw-r--r--src/local/term.nim97
-rw-r--r--src/server/buffer.nim22
13 files changed, 233 insertions, 62 deletions
diff --git a/src/config/config.nim b/src/config/config.nim
index 1ac0393e..6611b4ef 100644
--- a/src/config/config.nim
+++ b/src/config/config.nim
@@ -33,6 +33,9 @@ type
 
   FormatMode* = set[FormatFlags]
 
+  ImageMode* = enum
+    imNone = "none", imSixel = "sixel", imKitty = "kitty"
+
   ChaPathResolved* = distinct string
 
   ActionMap = object
@@ -104,6 +107,7 @@ type
     color_mode* {.jsgetset.}: Option[ColorMode]
     format_mode* {.jsgetset.}: Option[FormatMode]
     no_format_mode* {.jsgetset.}: FormatMode
+    image_mode* {.jsgetset.}: Option[ImageMode]
     emulate_overline* {.jsgetset.}: bool
     alt_screen* {.jsgetset.}: Option[bool]
     highlight_color* {.jsgetset.}: RGBAColor
diff --git a/src/css/cascade.nim b/src/css/cascade.nim
index 9c3e5803..b2d007f2 100644
--- a/src/css/cascade.nim
+++ b/src/css/cascade.nim
@@ -380,7 +380,11 @@ proc applyRulesFrameInvalid(frame: CascadeFrame; ua, user: CSSStylesheet;
         styledParent.children.add(styledText)
     of peImage:
       let src = Element(styledParent.node).attr(satSrc)
-      let content = CSSContent(t: ContentImage, s: src)
+      let content = CSSContent(
+        t: ContentImage,
+        s: src,
+        bmp: HTMLImageElement(styledParent.node).bitmap
+      )
       let styledText = styledParent.newStyledReplacement(content)
       styledText.pseudo = pseudo
       styledParent.children.add(styledText)
diff --git a/src/html/dom.nim b/src/html/dom.nim
index 32e98824..76ed9b70 100644
--- a/src/html/dom.nim
+++ b/src/html/dom.nim
@@ -2849,7 +2849,7 @@ proc loadResource(window: Window; image: HTMLImageElement) =
           return
         let pngData = pngData.get
         let buffer = cast[ptr UncheckedArray[uint8]](pngData.buffer)
-        let high = int(pngData.size - 1)
+        let high = int(pngData.size) - 1
         image.bitmap = fromPNG(toOpenArray(buffer, 0, high))
       )
     window.loadingResourcePromises.add(p)
diff --git a/src/img/png.nim b/src/img/png.nim
index 7f4d8493..bd7e6206 100644
--- a/src/img/png.nim
+++ b/src/img/png.nim
@@ -132,8 +132,7 @@ func spp(reader: PNGReader): int =
   of pcTrueColorWithAlpha: return 4
 
 func scanlen(reader: PNGReader): int {.inline.} =
-  let w = reader.width + 1
-  return (w * reader.spp * int(reader.bitDepth) + 7) div 8
+  return 1 + (reader.width * reader.spp * int(reader.bitDepth) + 7) div 8
 
 proc handleError(reader: var PNGReader; msg: string) =
   #TODO proper error handling?
@@ -258,28 +257,55 @@ proc readtRNS(reader: var PNGReader) =
     for i in 0 ..< reader.palette.len:
       reader.palette[i].a = reader.readU8()
 
+func paethPredictor(a, b, c: uint16): uint16 =
+  let pa0 = int(b) - int(c)
+  let pb0 = int(a) - int(c)
+  let pa = abs(pa0)
+  let pb = abs(pb0)
+  let pc = abs(pa0 + pb0)
+  return if pa <= pb and pa <= pc: a elif pb <= pc: b else: c
+
 proc unfilter(reader: var PNGReader; irow: openArray[uint8]; bpp: int) =
   # none, sub, up -> replace uprow directly
   # average, paeth -> copy to temp array, then replace uprow
-  let fil = irow[0]
-  let w = reader.width
-  case fil
+  case irow[0]
   of 0u8: # none
-    copyMem(addr reader.uprow[0], unsafeAddr irow[1], w)
+    copyMem(addr reader.uprow[0], unsafeAddr irow[1], irow.len)
   of 1u8: # sub
     for i in 1 ..< irow.len:
       let j = i - 1 # skip filter byte
-      reader.uprow[j] = irow[i]
-      if j - bpp >= 0:
-        reader.uprow[j] += irow[j - bpp]
+      let aidx = j - bpp
+      let x = uint16(irow[i])
+      let a = if aidx >= 0: uint16(reader.uprow[aidx]) else: 0u16
+      reader.uprow[j] = uint8((x + a) and 0xFF)
   of 2u8: # up
     for i in 1 ..< irow.len:
       let j = i - 1 # skip filter byte
-      reader.uprow[j] += irow[i]
+      let x = uint16(irow[i])
+      let b = uint16(reader.uprow[j])
+      reader.uprow[j] = uint8((x + b) and 0xFF)
   of 3u8: # average
-    reader.err "average not implemented yet"
+    for i in 1 ..< irow.len:
+      let j = i - 1 # skip filter byte
+      let aidx = j - bpp
+      let x = uint16(irow[i])
+      let a = if aidx >= 0: uint16(reader.uprow[aidx]) else: 0u16
+      let b = uint16(reader.uprow[j])
+      reader.uprow[j] = uint8((x + (a + b) div 2) and 0xFF)
   of 4u8: # paeth
-    reader.err "paeth not implemented yet"
+    var cmap: array[8, uint16] # max bpp is 16 bit with true color = 4 * 2
+    var k = 0
+    for i in 1 ..< irow.len:
+      let j = i - 1 # skip filter byte
+      let aidx = j - bpp
+      let x = uint16(irow[i])
+      let a = if aidx >= 0: uint16(reader.uprow[aidx]) else: 0u16
+      let b = uint16(reader.uprow[j])
+      let kk = k mod bpp
+      let c = cmap[kk]
+      cmap[kk] = b
+      reader.uprow[j] = uint8((x + paethPredictor(a, b, c)) and 0xFF)
+      inc k
   else:
     reader.err "got invalid filter"
 
@@ -340,11 +366,16 @@ proc writepxs(reader: var PNGReader; crow: var openArray[RGBAColor]) =
       crow[x] = rgba(n, n, n, a)
   of pcTrueColorWithAlpha:
     let step = int(reader.bitDepth) div 8
+    var i = 0
     for x in 0 ..< crow.len:
-      let r = reader.uprow[x * step]
-      let g = reader.uprow[(x + 1) * step]
-      let b = reader.uprow[(x + 2) * step]
-      let a = reader.uprow[(x + 3) * step]
+      let r = reader.uprow[i]
+      i += step
+      let g = reader.uprow[i]
+      i += step
+      let b = reader.uprow[i]
+      i += step
+      let a = reader.uprow[i]
+      i += step
       crow[x] = rgba(r, g, b, a)
 
 proc readPLTE(reader: var PNGReader) =
@@ -404,10 +435,10 @@ proc readIDAT(reader: var PNGReader) =
   for y in reader.atline ..< maxline:
     let yi = y * sl
     assert yi + sl - 1 < reader.idatAt
-    reader.unfilter(toOpenArray(reader.idatBuf, yi, yi + sl - 1), bpp)
+    reader.unfilter(reader.idatBuf.toOpenArray(yi, yi + sl - 1), bpp)
     if unlikely(reader.bmp == nil): return
     let yj = y * reader.width
-    reader.writepxs(toOpenArray(bmp.px, yj, yj + reader.width - 1))
+    reader.writepxs(bmp.px.toOpenArray(yj, yj + reader.width - 1))
 
 proc readIEND(reader: var PNGReader) =
   if reader.i < reader.limit:
diff --git a/src/io/bufreader.nim b/src/io/bufreader.nim
index 6de269ac..3ee36208 100644
--- a/src/io/bufreader.nim
+++ b/src/io/bufreader.nim
@@ -6,6 +6,7 @@ import std/tables
 
 import io/dynstream
 import types/blob
+import types/color
 import types/formdata
 import types/opt
 import types/url
@@ -53,6 +54,7 @@ proc sread*(reader: var BufferedReader; part: var FormDataEntry)
 proc sread*(reader: var BufferedReader; blob: var Blob)
 proc sread*[T](reader: var BufferedReader; o: var Option[T])
 proc sread*[T, E](reader: var BufferedReader; o: var Result[T, E])
+proc sread*(reader: var BufferedReader; c: var RGBAColor) {.inline.}
 
 proc readData(reader: var BufferedReader; buffer: pointer; len: int) =
   assert reader.bufIdx + len <= reader.buffer.len
@@ -198,3 +200,6 @@ proc sread*[T, E](reader: var BufferedReader; o: var Result[T, E]) =
       o.err(e)
     else:
       o.err()
+
+proc sread*(reader: var BufferedReader; c: var RGBAColor) =
+  reader.sread(uint32(c))
diff --git a/src/io/bufwriter.nim b/src/io/bufwriter.nim
index 75da4190..0957d3e8 100644
--- a/src/io/bufwriter.nim
+++ b/src/io/bufwriter.nim
@@ -6,11 +6,11 @@ import std/sets
 import std/tables
 
 import io/dynstream
-
 import types/blob
+import types/color
 import types/formdata
-import types/url
 import types/opt
+import types/url
 
 type BufferedWriter* = object
   stream: DynStream
@@ -77,6 +77,7 @@ proc swrite*(writer: var BufferedWriter; part: FormDataEntry)
 proc swrite*(writer: var BufferedWriter; blob: Blob)
 proc swrite*[T](writer: var BufferedWriter; o: Option[T])
 proc swrite*[T, E](writer: var BufferedWriter; o: Result[T, E])
+proc swrite*(writer: var BufferedWriter; c: RGBAColor) {.inline.}
 
 proc writeData(writer: var BufferedWriter; buffer: pointer; len: int) =
   let targetLen = writer.bufLen + len
@@ -181,3 +182,6 @@ proc swrite*[T, E](writer: var BufferedWriter; o: Result[T, E]) =
   else:
     when not (E is void):
       writer.swrite(o.error)
+
+proc swrite*(writer: var BufferedWriter; c: RGBAColor) =
+  writer.swrite(uint32(c))
diff --git a/src/layout/box.nim b/src/layout/box.nim
index 79f927ef..40628204 100644
--- a/src/layout/box.nim
+++ b/src/layout/box.nim
@@ -1,5 +1,6 @@
 import css/stylednode
 import css/values
+import img/bitmap
 import layout/layoutunit
 
 type
@@ -12,7 +13,7 @@ type
     h*: LayoutUnit
 
   InlineAtomType* = enum
-    iatSpacing, iatWord, iatInlineBlock
+    iatSpacing, iatWord, iatInlineBlock, iatImage
 
   InlineAtom* = ref object
     offset*: Offset
@@ -24,6 +25,8 @@ type
       str*: string
     of iatInlineBlock:
       innerbox*: BlockBox
+    of iatImage:
+      bmp*: Bitmap
 
   RootInlineFragment* = ref object
     # offset relative to parent
diff --git a/src/layout/engine.nim b/src/layout/engine.nim
index 99d205f5..30afb066 100644
--- a/src/layout/engine.nim
+++ b/src/layout/engine.nim
@@ -5,6 +5,7 @@ import std/unicode
 
 import css/stylednode
 import css/values
+import img/bitmap
 import layout/box
 import layout/layoutunit
 import types/winattrs
@@ -68,6 +69,7 @@ type
     text: seq[string]
     newline: bool
     splitType: set[SplitType]
+    bmp: Bitmap
 
   BlockBoxBuilder = ref object of BoxBuilder
     inlinelayout: bool
@@ -1500,8 +1502,20 @@ proc layoutInline(ictx: var InlineContext; box: InlineBoxBuilder):
   if ictx.firstTextFragment == nil:
     ictx.firstTextFragment = fragment
   ictx.lastTextFragment = fragment
-  ictx.layoutText(state, box.text)
-  ictx.layoutChildren(state, box.children)
+  if box.bmp != nil:
+    let iastate = InlineAtomState(
+      vertalign: state.computed{"vertical-align"},
+      baseline: ictx.cellheight
+    )
+    let atom = InlineAtom(
+      t: iatImage,
+      bmp: box.bmp,
+      size: Size(w: int(box.bmp.width), h: int(box.bmp.height)) #TODO overflow
+    )
+    discard ictx.addAtom(state, iastate, atom)
+  else:
+    ictx.layoutText(state, box.text)
+    ictx.layoutChildren(state, box.children)
   if stSplitEnd in box.splitType:
     let paddingRight = box.computed{"padding-right"}.px(lctx, ictx.space.w)
     ictx.currentLine.size.w += paddingRight
@@ -2856,10 +2870,10 @@ proc generateFromElem(ctx: var InnerBlockContext; styledNode: StyledNode) =
   of DisplayNone: discard
 
 proc generateAnonymousInlineText(ctx: var InnerBlockContext; text: string;
-    styledNode: StyledNode) =
+    styledNode: StyledNode; bmp: Bitmap = nil) =
   if ctx.iroot == nil:
     let computed = styledNode.computed.inheritProperties()
-    ctx.ibox = InlineBoxBuilder(computed: computed, node: styledNode)
+    ctx.ibox = InlineBoxBuilder(computed: computed, node: styledNode, bmp: bmp)
     if ctx.inlineStack.len > 0:
       let iparent = ctx.reconstructInlineParents()
       iparent.children.add(ctx.ibox)
@@ -2900,7 +2914,7 @@ proc generateReplacement(ctx: var InnerBlockContext;
     ctx.generateAnonymousInlineText(child.content.s, parent)
   of ContentImage:
     #TODO idk
-    ctx.generateAnonymousInlineText("[img]", parent)
+    ctx.generateAnonymousInlineText("[img]", parent, child.content.bmp)
   of ContentVideo:
     ctx.generateAnonymousInlineText("[video]", parent)
   of ContentAudio:
@@ -3116,7 +3130,7 @@ proc generateTableBox(styledNode: StyledNode; lctx: LayoutState;
   box.generateTableChildWrappers()
   return box
 
-proc renderLayout*(root: StyledNode; attrsp: ptr WindowAttributes): BlockBox =
+proc layout*(root: StyledNode; attrsp: ptr WindowAttributes): BlockBox =
   let space = AvailableSpace(
     w: stretch(attrsp[].width_px),
     h: stretch(attrsp[].height_px)
diff --git a/src/layout/renderdocument.nim b/src/layout/renderdocument.nim
index fe7b92cb..e57a6d2c 100644
--- a/src/layout/renderdocument.nim
+++ b/src/layout/renderdocument.nim
@@ -3,12 +3,13 @@ import std/unicode
 
 import css/stylednode
 import css/values
+import img/bitmap
 import layout/box
 import layout/engine
 import layout/layoutunit
-import types/winattrs
 import types/cell
 import types/color
+import types/winattrs
 import utils/strwidth
 
 type
@@ -208,12 +209,19 @@ proc setText(grid: var FlexibleGrid; linestr: string; x, y: int; format: Format;
   assert grid[y].formats[fi].pos <= nx
   # That's it!
 
-type RenderState = object
-  # Position of the absolute positioning containing block:
-  # https://drafts.csswg.org/css-position/#absolute-positioning-containing-block
-  absolutePos: seq[Offset]
-  bgcolor: CellColor
-  attrsp: ptr WindowAttributes
+type
+  PosBitmap* = ref object
+    x*: int
+    y*: int
+    bmp*: Bitmap
+
+  RenderState = object
+    # Position of the absolute positioning containing block:
+    # https://drafts.csswg.org/css-position/#absolute-positioning-containing-block
+    absolutePos: seq[Offset]
+    bgcolor: CellColor
+    attrsp: ptr WindowAttributes
+    images: seq[PosBitmap]
 
 template attrs(state: RenderState): WindowAttributes =
   state.attrsp[]
@@ -361,6 +369,15 @@ proc renderInlineFragment(grid: var FlexibleGrid; state: var RenderState;
         grid.setRowWord(state, atom, offset, format, fragment.node)
       of iatSpacing:
         grid.setSpacing(state, atom, offset, format, fragment.node)
+      of iatImage:
+        state.images.add(PosBitmap(
+          x: (offset.x div state.attrs.ppc).toInt,
+          y: (offset.y div state.attrs.ppl).toInt,
+          bmp: atom.bmp
+        ))
+  else:
+    for child in fragment.children:
+      grid.renderInlineFragment(state, child, offset)
   if fragment.computed{"position"} != PositionStatic:
     if fragment.splitType != {stSplitStart, stSplitEnd}:
       if stSplitStart in fragment.splitType:
@@ -370,8 +387,6 @@ proc renderInlineFragment(grid: var FlexibleGrid; state: var RenderState;
         ))
       if stSplitEnd in fragment.splitType:
         discard state.absolutePos.pop()
-  for child in fragment.children:
-    grid.renderInlineFragment(state, child, offset)
 
 proc renderRootInlineFragment(grid: var FlexibleGrid; state: var RenderState;
     root: RootInlineFragment; offset: Offset) =
@@ -440,7 +455,8 @@ proc renderBlockBox(grid: var FlexibleGrid; state: var RenderState;
         stack.add((box.nested[i], offset))
 
 proc renderDocument*(grid: var FlexibleGrid; bgcolor: var CellColor;
-    styledRoot: StyledNode; attrsp: ptr WindowAttributes) =
+    styledRoot: StyledNode; attrsp: ptr WindowAttributes;
+    images: var seq[PosBitmap]) =
   grid.setLen(0)
   if styledRoot == nil:
     # no HTML element when we run cascade; just clear all lines.
@@ -449,8 +465,9 @@ proc renderDocument*(grid: var FlexibleGrid; bgcolor: var CellColor;
     absolutePos: @[Offset(x: 0, y: 0)],
     attrsp: attrsp
   )
-  let rootBox = renderLayout(styledRoot, attrsp)
+  let rootBox = styledRoot.layout(attrsp)
   grid.renderBlockBox(state, rootBox, Offset(x: 0, y: 0))
   if grid.len == 0:
     grid.addLines(1)
   bgcolor = state.bgcolor
+  images = state.images
diff --git a/src/local/container.nim b/src/local/container.nim
index b88161b8..3a0f1daf 100644
--- a/src/local/container.nim
+++ b/src/local/container.nim
@@ -14,6 +14,7 @@ import io/socketstream
 import js/javascript
 import js/jstypes
 import js/regex
+import layout/renderdocument
 import loader/headers
 import loader/loader
 import loader/request
@@ -25,7 +26,6 @@ import types/cookie
 import types/referrer
 import types/url
 import types/winattrs
-import utils/luwrap
 import utils/mimeguess
 import utils/strwidth
 import utils/twtstr
@@ -152,6 +152,7 @@ type
     cacheFile* {.jsget.}: string
     mainConfig*: Config
     flags*: set[ContainerFlag]
+    images*: seq[PosBitmap]
 
 jsDestructor(Highlight)
 jsDestructor(Container)
@@ -458,7 +459,6 @@ proc requestLines(container: Container): EmptyPromise {.discardable.} =
     container.lineshift = w.a
     for y in 0 ..< min(res.lines.len, w.len):
       container.lines[y] = res.lines[y]
-      container.lines[y].str.mnormalize()
     var isBgNew = container.bgcolor != res.bgcolor
     if isBgNew:
       container.bgcolor = res.bgcolor
@@ -474,6 +474,7 @@ proc requestLines(container: Container): EmptyPromise {.discardable.} =
     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 or isBgNew:
       container.triggerEvent(cetUpdate)
+    container.images = res.images
   )
 
 proc redraw(container: Container) {.jsfunc.} =
@@ -1387,9 +1388,9 @@ proc onload(container: Container; res: int) =
     container.triggerEvent(cetStatus)
     container.triggerEvent(cetLoaded)
     if cfHasStart notin container.flags and container.url.anchor != "":
-      container.requestLines().then(proc(): Promise[Opt[tuple[x, y: int]]] =
+      container.requestLines().then(proc(): Promise[GotoAnchorResult] =
         return container.iface.gotoAnchor()
-      ).then(proc(res: Opt[tuple[x, y: int]]) =
+      ).then(proc(res: GotoAnchorResult) =
         if res.isSome:
           let res = res.get
           container.setCursorXYCenter(res.x, res.y)
diff --git a/src/local/pager.nim b/src/local/pager.nim
index 28c1face..8e828190 100644
--- a/src/local/pager.nim
+++ b/src/local/pager.nim
@@ -449,6 +449,11 @@ proc draw*(pager: Pager) =
   else:
     pager.term.writeGrid(pager.statusgrid, 0, pager.attrs.height - 1)
   pager.term.outputGrid()
+  if container != nil:
+    pager.term.clearImages()
+    for image in container.images:
+      pager.term.outputImage(image.bmp, image.x - container.fromx,
+        image.y - container.fromy, pager.attrs.width, pager.attrs.height - 1)
   if pager.askpromise != nil:
     pager.term.setCursor(pager.askcursor, pager.attrs.height - 1)
   elif pager.lineedit.isSome:
diff --git a/src/local/term.nim b/src/local/term.nim
index eee53039..0da700f5 100644
--- a/src/local/term.nim
+++ b/src/local/term.nim
@@ -8,7 +8,9 @@ import std/unicode
 
 import bindings/termcap
 import config/config
+import img/bitmap
 import io/posixstream
+import js/base64
 import types/cell
 import types/color
 import types/opt
@@ -64,6 +66,7 @@ type
     attrs*: WindowAttributes
     colormode: ColorMode
     formatmode: FormatMode
+    imagemode: ImageMode
     smcup: bool
     tc: Termcap
     tname: string
@@ -128,6 +131,15 @@ const RMCUP = DECRST(1049)
 const SGRMOUSEBTNON = DECSET(1002, 1006)
 const SGRMOUSEBTNOFF = DECRST(1002, 1006)
 
+# application program command
+
+# This is only used in kitty images, and join()'ing kilobytes of base64
+# is rather inefficient so we don't use a template.
+const APC = "\e_"
+const ST = "\e\\"
+
+const KITTYQUERY = APC & "Gi=1,a=q;" & ST
+
 when not termcap_found:
   const CNORM = DECSET(25)
   const CIVIS = DECRST(25)
@@ -555,15 +567,13 @@ proc applyConfig(term: Terminal) =
   # colors, formatting
   if term.config.display.color_mode.isSome:
     term.colormode = term.config.display.color_mode.get
-  elif term.isatty():
-    let colorterm = getEnv("COLORTERM")
-    if colorterm in ["24bit", "truecolor"]:
-      term.colormode = cmTrueColor
   if term.config.display.format_mode.isSome:
     term.formatmode = term.config.display.format_mode.get
   for fm in FormatFlags:
     if fm in term.config.display.no_format_mode:
       term.formatmode.excl(fm)
+  if term.config.display.image_mode.isSome:
+    term.imagemode = term.config.display.image_mode.get
   if term.isatty():
     if term.config.display.alt_screen.isSome:
       term.smcup = term.config.display.alt_screen.get
@@ -603,6 +613,50 @@ proc outputGrid*(term: Terminal) =
   for i in 0 ..< term.canvas.cells.len:
     term.pcanvas[i] = term.canvas[i]
 
+proc clearImages*(term: Terminal) =
+  if term.imagemode == imKitty:
+    term.write(APC & "Ga=d" & ST)
+
+proc outputImage*(term: Terminal; bmp: Bitmap; x, y, maxw, maxh: int) =
+  case term.imagemode
+  of imNone: discard
+  of imSixel:
+    discard #TODO
+  of imKitty:
+    # max 4096 bytes, base encoded
+    const MaxPixels = ((4096 div 4) * 3) div 3
+    let offx = if x < 0: -(x * term.attrs.ppc) else: 0
+    let offy = if y < 0: -(y * term.attrs.ppl) else: 0
+    let w = int(bmp.width)
+    let h = int(bmp.height)
+    var dispw = w
+    if x + dispw div term.attrs.ppc > maxw:
+      dispw = (maxw - x) * term.attrs.ppc
+    var disph = h
+    if y + disph div term.attrs.ppl > maxh:
+      disph = (maxh - y) * term.attrs.ppl
+    var outs = term.cursorGoto(max(x, 0), max(y, 0))
+    outs &= APC & "Gf=24,m=1,a=T,C=1,s=" & $w & ",v=" & $h &
+      ",x=" & $offx & ",y=" & $offy & ",w=" & $dispw & ",h=" & $disph & ';'
+    var buf = newStringOfCap(MaxPixels * 4)
+    var i = 0
+    # transcode to RGB
+    while i < bmp.px.len: # max is 4096
+      if i > 0 and i mod MaxPixels == 0:
+        outs &= btoa(buf)
+        outs &= ST
+        term.write(outs)
+        buf.setLen(0)
+        outs = APC & "Gm=1;"
+      buf &= char(bmp.px[i].r)
+      buf &= char(bmp.px[i].g)
+      buf &= char(bmp.px[i].b)
+      inc i
+    outs = APC & "Gm=0;"
+    outs &= btoa(buf)
+    outs &= ST
+    term.write(outs)
+
 proc clearCanvas*(term: Terminal) =
   term.cleared = false
 
@@ -665,7 +719,7 @@ when termcap_found:
 
 type
   QueryAttrs = enum
-    qaAnsiColor, qaRGB, qaSixel
+    qaAnsiColor, qaRGB, qaSixel, qaKittyImage
 
   QueryResult = object
     success: bool
@@ -683,6 +737,7 @@ proc queryAttrs(term: Terminal; windowOnly: bool): QueryResult =
     const outs =
       XTGETFG &
       XTGETBG &
+      KITTYQUERY &
       GEOMPIXEL &
       GEOMCELL &
       XTGETTCAP("524742") &
@@ -764,12 +819,15 @@ proc queryAttrs(term: Terminal; windowOnly: bool): QueryResult =
       term.expect ';'
       if term.consume == 'r' and term.consume == 'g' and term.consume == 'b':
         term.expect ':'
-        template eat_color(tc: char): uint8 =
+        var was_esc = false
+        template eat_color(tc: set[char]): uint8 =
           var val = 0u8
           var i = 0
-          while (let c = term.consume; c != tc):
+          var c = char(0)
+          while (c = term.consume; c notin tc):
             let v0 = hexValue(c)
-            if i > 4 or v0 == -1: fail # wat
+            if i > 4 or v0 == -1:
+              fail # wat
             let v = uint8(v0)
             if i == 0: # 1st place
               val = (v shl 4) or v
@@ -777,10 +835,14 @@ proc queryAttrs(term: Terminal; windowOnly: bool): QueryResult =
               val = (val xor 0xF) or v
             # all other places are irrelevant
             inc i
+          was_esc = c == '\e'
           val
-        let r = eat_color '/'
-        let g = eat_color '/'
-        let b = eat_color '\a'
+        let r = eat_color {'/'}
+        let g = eat_color {'/'}
+        let b = eat_color {'\a', '\e'}
+        if was_esc:
+          # we got ST, not BEL; at least kitty does this
+          term.expect '\\'
         if c == '0':
           result.fgcolor = some(rgb(r, g, b))
         else:
@@ -805,7 +867,14 @@ proc queryAttrs(term: Terminal; windowOnly: bool): QueryResult =
         if id == tcapRGB:
           result.attrs.incl(qaRGB)
       else: # 0
-        term.expect '\e' # ST (1)
+        # pure insanity: kitty returns P0, but also +r524742 after. please
+        # make up your mind!
+        term.skip_until '\e' # ST (1)
+      term.expect '\\' # ST (2)
+    of '_': # APC
+      term.expect 'G'
+      result.attrs.incl(qaKittyImage)
+      term.skip_until '\e' # ST (1)
       term.expect '\\' # ST (2)
     else:
       fail
@@ -844,6 +913,10 @@ proc detectTermAttributes(term: Terminal; windowOnly: bool): TermStartResult =
         term.colormode = cmANSI
       if qaRGB in r.attrs:
         term.colormode = cmTrueColor
+      if qaSixel in r.attrs:
+        term.imagemode = imSixel
+      if qaKittyImage in r.attrs:
+        term.imagemode = imKitty
       # just assume the terminal doesn't choke on these.
       term.formatmode = {ffStrike, ffOverline}
       if r.bgcolor.isSome:
diff --git a/src/server/buffer.nim b/src/server/buffer.nim
index 10d1cce0..56ad946f 100644
--- a/src/server/buffer.nim
+++ b/src/server/buffer.nim
@@ -26,6 +26,7 @@ import html/enums
 import html/env
 import html/event
 import html/formdata as formdata_impl
+import img/bitmap
 import io/bufreader
 import io/bufstream
 import io/bufwriter
@@ -91,6 +92,7 @@ type
     ishtml: bool
     firstBufferRead: bool
     lines: FlexibleGrid
+    images: seq[PosBitmap]
     request: Request # source request
     attrs: WindowAttributes
     window: Window
@@ -648,7 +650,9 @@ proc findNextMatch*(buffer: Buffer; regex: Regex; cursorx, cursory: int;
       break
     inc y
 
-proc gotoAnchor*(buffer: Buffer): Opt[tuple[x, y: int]] {.proxy.} =
+type GotoAnchorResult* = Opt[tuple[x, y: int]]
+
+proc gotoAnchor*(buffer: Buffer): GotoAnchorResult {.proxy.} =
   if buffer.document == nil:
     return err()
   let anchor = buffer.document.findAnchor(buffer.url.anchor)
@@ -673,7 +677,8 @@ proc do_reshape(buffer: Buffer) =
     buffer.prevStyled = nil
   let styledRoot = buffer.document.applyStylesheets(uastyle,
     buffer.userstyle, buffer.prevStyled)
-  buffer.lines.renderDocument(buffer.bgcolor, styledRoot, addr buffer.attrs)
+  buffer.lines.renderDocument(buffer.bgcolor, styledRoot, addr buffer.attrs,
+    buffer.images)
   buffer.prevStyled = styledRoot
 
 proc processData0(buffer: Buffer; data: openArray[char]): bool =
@@ -1718,11 +1723,11 @@ proc readCanceled*(buffer: Buffer): bool {.proxy.} =
 proc findAnchor*(buffer: Buffer; anchor: string): bool {.proxy.} =
   return buffer.document != nil and buffer.document.findAnchor(anchor) != nil
 
-type GetLinesResult* = tuple[
-  numLines: int,
-  lines: seq[SimpleFlexibleLine],
+type GetLinesResult* = tuple
+  numLines: int
+  lines: seq[SimpleFlexibleLine]
   bgcolor: CellColor
-]
+  images: seq[PosBitmap]
 
 proc getLines*(buffer: Buffer; w: Slice[int]): GetLinesResult {.proxy.} =
   var w = w
@@ -1736,6 +1741,11 @@ proc getLines*(buffer: Buffer; w: Slice[int]): GetLinesResult {.proxy.} =
     result.lines.add(line)
   result.numLines = buffer.lines.len
   result.bgcolor = buffer.bgcolor
+  if buffer.config.images:
+    for image in buffer.images:
+      if image.y <= w.b and
+          image.y + int(image.bmp.height) div buffer.attrs.ppl >= w.a:
+        result.images.add(image)
 
 proc markURL*(buffer: Buffer; schemes: seq[string]) {.proxy.} =
   if buffer.document == nil or buffer.document.body == nil: