about summary refs log tree commit diff stats
path: root/src/css
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2025-02-18 18:51:57 +0100
committerbptato <nincsnevem662@gmail.com>2025-02-18 19:26:40 +0100
commit4f785b483c92ef9755258e49de4a40a14ae4faf6 (patch)
tree96fc33c571455ef0822428188ab286167c9b8c3e /src/css
parentb823867d6d1a557cd38c06cc77116d3cde090a20 (diff)
downloadchawan-4f785b483c92ef9755258e49de4a40a14ae4faf6.tar.gz
layout: implement negative z-index
Ugly, but works.  I think.
Diffstat (limited to 'src/css')
-rw-r--r--src/css/box.nim34
-rw-r--r--src/css/cssvalues.nim27
-rw-r--r--src/css/layout.nim107
-rw-r--r--src/css/render.nim187
4 files changed, 228 insertions, 127 deletions
diff --git a/src/css/box.nim b/src/css/box.nim
index 2400c9da..1b696b58 100644
--- a/src/css/box.nim
+++ b/src/css/box.nim
@@ -48,8 +48,21 @@ type
 
   RelativeRect* = array[DimensionType, Span]
 
+  StackItem* = ref object
+    box*: CSSBox
+    index*: int32
+    children*: seq[StackItem]
+
+  ClipBox* = object
+    start*: Offset
+    send*: Offset
+
   BoxRenderState* = object
+    # Whether the following two variables have been initialized.
+    #TODO find a better name that doesn't conflict with box.positioned
+    positioned*: bool
     offset*: Offset
+    clipBox*: ClipBox
 
   # min-content: box width is longest word's width
   # max-content: box width is content width without wrapping
@@ -81,6 +94,7 @@ type
     parent* {.cursor.}: CSSBox
     firstChild*: CSSBox
     next*: CSSBox
+    positioned*: bool # set if we participate in positioned layout
     render*: BoxRenderState # render output
     computed*: CSSValues
     element*: Element
@@ -105,6 +119,9 @@ type
   InlineBlockBox* = ref object of InlineBox
     # InlineBlockBox always has one block child.
 
+  LayoutResult* = ref object
+    stack*: StackItem
+
 func offset*(x, y: LUnit): Offset =
   return [dtHorizontal: x, dtVertical: y]
 
@@ -180,12 +197,29 @@ proc `+=`*(span: var Span; u: LUnit) =
   span.start += u
   span.send += u
 
+func `<`*(a, b: Offset): bool =
+  return a.x < b.x and a.y < b.y
+
 iterator children*(box: CSSBox): CSSBox =
   var it {.cursor.} = box.firstChild
   while it != nil:
     yield it
     it = it.next
 
+proc resetState(box: CSSBox) =
+  box.positioned = false
+  box.render = BoxRenderState()
+
+proc resetState*(ibox: InlineBox) =
+  CSSBox(ibox).resetState()
+  ibox.state = InlineBoxState()
+
+proc resetState*(box: BlockBox) =
+  CSSBox(box).resetState()
+  box.state = BoxLayoutState()
+
+const DefaultClipBox* = ClipBox(send: offset(LUnit.high, LUnit.high))
+
 when defined(debug):
   proc computedTree*(box: CSSBox): string =
     result = "<"
diff --git a/src/css/cssvalues.nim b/src/css/cssvalues.nim
index f536cefd..51d3fd1e 100644
--- a/src/css/cssvalues.nim
+++ b/src/css/cssvalues.nim
@@ -166,6 +166,7 @@ type
     cvtFlexWrap = "flexWrap"
     cvtNumber = "number"
     cvtOverflow = "overflow"
+    cvtZIndex = "zIndex"
 
   CSSGlobalType* = enum
     cgtInitial = "initial"
@@ -382,6 +383,10 @@ type
     a*: CSSLength
     b*: CSSLength
 
+  CSSZIndex* = object
+    `auto`*: bool
+    num*: int32
+
   CSSValueBit* {.union.} = object
     dummy*: uint8
     bgcolorIsCanvas*: bool
@@ -412,6 +417,7 @@ type
     length*: CSSLength
     number*: float32
     verticalAlign*: CSSVerticalAlign
+    zIndex*: CSSZIndex
 
   CSSValue* = ref object
     case v*: CSSValueType
@@ -527,7 +533,7 @@ const ValueTypes = [
   cptTop: cvtLength,
   cptVerticalAlign: cvtVerticalAlign,
   cptWidth: cvtLength,
-  cptZIndex: cvtInteger,
+  cptZIndex: cvtZIndex,
 
   # pointers
   cptBackgroundImage: cvtImage,
@@ -634,6 +640,11 @@ func `$`(counterreset: seq[CSSCounterSet]): string =
     result &= ' '
     result &= $it.num
 
+func `$`(zIndex: CSSZIndex): string =
+  if zIndex.auto:
+    return $auto
+  return $zIndex.num
+
 func serialize(val: CSSValue): string =
   case val.v
   of cvtImage: return $val.image
@@ -658,6 +669,7 @@ func serialize(val: CSSValueWord; t: CSSValueType): string =
   of cvtLength: return $val.length
   of cvtNumber: return $val.number
   of cvtVerticalAlign: return $val.verticalAlign
+  of cvtZIndex: return $val.zIndex
   else: assert false
 
 func serialize(val: CSSValueBit; t: CSSValueType): string =
@@ -1494,6 +1506,14 @@ func parseInteger(cval: CSSComponentValue; range: Slice[int32]): Opt[int32] =
         return ok(int32(tok.nvalue))
   return err()
 
+func parseZIndex(cval: CSSComponentValue): Opt[CSSZIndex] =
+  if cval of CSSToken:
+    let tok = CSSToken(cval)
+    if tok.t == cttIdent and tok.value == "auto":
+      return ok(CSSZIndex(auto: true))
+    return ok(CSSZIndex(num: ?parseInteger(cval, -65534i32 .. 65534i32)))
+  return err()
+
 func parseNumber(cval: CSSComponentValue; range: Slice[float32]): Opt[float32] =
   if cval of CSSToken:
     let tok = CSSToken(cval)
@@ -1590,8 +1610,8 @@ proc parseValue(cvals: openArray[CSSComponentValue]; t: CSSPropertyType;
     of cptFontWeight: set_word integer, ?parseFontWeight(cval)
     of cptChaColspan: set_word integer, ?parseInteger(cval, 1i32 .. 1000i32)
     of cptChaRowspan: set_word integer, ?parseInteger(cval, 0i32 .. 65534i32)
-    of cptZIndex: set_word integer, ?parseInteger(cval, -65534i32 .. 65534i32)
     else: assert false
+  of cvtZIndex: set_word zIndex, ?parseZIndex(cval)
   of cvtTextDecoration: set_bit textDecoration, ?cssTextDecoration(cvals)
   of cvtVerticalAlign: set_word verticalAlign, ?cssVerticalAlign(cval, attrs)
   of cvtTextAlign: set_bit textAlign, ?parseIdent[CSSTextAlign](cval)
@@ -1675,6 +1695,7 @@ proc getDefaultWord(t: CSSPropertyType): CSSValueWord =
   of cvtInteger: return CSSValueWord(integer: getInitialInteger(t))
   of cvtLength: return CSSValueWord(length: getInitialLength(t))
   of cvtNumber: return CSSValueWord(number: getInitialNumber(t))
+  of cvtZIndex: return CSSValueWord(zIndex: CSSZIndex(auto: true))
   else: return CSSValueWord(dummy: 0)
 
 func lengthShorthand(cvals: openArray[CSSComponentValue];
@@ -1931,7 +1952,7 @@ func splitTable*(computed: CSSValues): tuple[outer, innner: CSSValues] =
     cptPaddingLeft, cptPaddingRight, cptPaddingTop, cptPaddingBottom,
     cptWidth, cptHeight, cptBoxSizing,
     # no clue why this isn't included in the standard
-    cptClear
+    cptClear, cptPosition
   }
   for t in CSSPropertyType:
     if t in props:
diff --git a/src/css/layout.nim b/src/css/layout.nim
index 6ea110a6..ddf4847c 100644
--- a/src/css/layout.nim
+++ b/src/css/layout.nim
@@ -27,7 +27,9 @@ type
     offset: Offset
     child: BlockBox
 
-  PositionedItem = seq[QueuedAbsolute]
+  PositionedItem = object
+    stack: StackItem # stacking context to append children to
+    queue: seq[QueuedAbsolute]
 
   LayoutContext = ref object
     attrsp: ptr WindowAttributes
@@ -1389,8 +1391,16 @@ proc applyIntr(box: BlockBox; sizes: ResolvedSizes; intr: Size) =
       box.state.intr[dim] = max(intr[dim], sizes.bounds.mi[dim].start)
       box.state.size[dim] = max(box.state.size[dim], intr[dim])
 
-proc pushPositioned(lctx: LayoutContext) =
-  lctx.positioned.add(@[])
+proc pushPositioned(lctx: LayoutContext; box: CSSBox) =
+  let index = box.computed{"z-index"}
+  let stack = StackItem(box: box, index: index.num)
+  lctx.positioned[^1].stack.children.add(stack)
+  let nextStack = if index.auto:
+    lctx.positioned[^1].stack
+  else:
+    stack
+  box.positioned = true
+  lctx.positioned.add(PositionedItem(stack: nextStack))
 
 # Offset the child by the offset of its actual parent to its nearest
 # positioned ancestor (`parent').
@@ -1409,7 +1419,7 @@ proc realignAbsolutePosition(child: BlockBox; parent: CSSBox;
     if dtVertical in dims:
       child.state.offset.y -= offset.y
     it = it.parent
-  if parent of InlineBox:
+  if parent != nil and parent of InlineBox:
     # The renderer does not adjust position for inline parents, so we
     # must do it here.
     let offset = InlineBox(parent).state.startOffset
@@ -1418,10 +1428,11 @@ proc realignAbsolutePosition(child: BlockBox; parent: CSSBox;
     if dtVertical in dims:
       child.state.offset.y += offset.y
 
-# size is the parent's size
-proc popPositioned(lctx: LayoutContext; parent: CSSBox; size: Size;
-    skipStatic = true) =
-  for it in lctx.positioned.pop():
+# size is the parent's size.
+# Note that parent may be nil.
+proc popPositioned(lctx: LayoutContext; parent: CSSBox; size: Size) =
+  let item = lctx.positioned[^1]
+  for it in item.queue:
     let child = it.child
     var size = size
     #TODO this is very ugly.
@@ -1461,13 +1472,18 @@ proc popPositioned(lctx: LayoutContext; parent: CSSBox; size: Size;
       child.state.offset.y += sizes.margin.top
     if dims != {}:
       child.realignAbsolutePosition(parent, dims)
+  discard lctx.positioned.pop()
+  let stack = item.stack
+  if stack.box == parent:
+    #TODO this sorts twice because of fixed...
+    stack.children.sort(proc(x, y: StackItem): int = cmp(x.index, y.index))
 
 proc queueAbsolute(lctx: LayoutContext; box: BlockBox; offset: Offset) =
   case box.computed{"position"}
   of PositionAbsolute:
-    lctx.positioned[^1].add(QueuedAbsolute(child: box, offset: offset))
+    lctx.positioned[^1].queue.add(QueuedAbsolute(child: box, offset: offset))
   of PositionFixed:
-    lctx.positioned[0].add(QueuedAbsolute(child: box, offset: offset))
+    lctx.positioned[0].queue.add(QueuedAbsolute(child: box, offset: offset))
   else: assert false
 
 proc positionRelative(lctx: LayoutContext; space: AvailableSpace;
@@ -1545,20 +1561,21 @@ proc positionFloat(bctx: var BlockContext; child: BlockBox;
 # its parent.
 # Returns the block's outer size.
 # Stores its resolved size data in `sizes'.
-proc layoutBlockChild(fstate: var FlowState; child: BlockBox;
+proc layoutBlockChild(fstate: var FlowState; box: BlockBox;
     sizes: out ResolvedSizes): Size =
   let lctx = fstate.lctx
-  sizes = lctx.resolveBlockSizes(fstate.space, child.computed)
+  sizes = lctx.resolveBlockSizes(fstate.space, box.computed)
   fstate.bctx.marginTodo.append(sizes.margin.top)
-  child.state = BoxLayoutState(offset: offset(x = sizes.margin.left, y = 0))
-  child.state.offset += fstate.offset
-  fstate.bctx.layout(child, sizes, canClear = true)
+  box.resetState()
+  box.state.offset = fstate.offset
+  box.state.offset.x += sizes.margin.left
+  fstate.bctx.layout(box, sizes, canClear = true)
   fstate.bctx.marginTodo.append(sizes.margin.bottom)
   return size(
-    w = child.outerSize(dtHorizontal, sizes),
+    w = box.outerSize(dtHorizontal, sizes),
     # delta y is difference between old and new offsets (margin-top),
     # plus height.
-    h = child.state.offset.y - fstate.offset.y + child.state.size.h
+    h = box.state.offset.y - fstate.offset.y + box.state.size.h
   )
 
 # Outer layout for block-level children that establish a BFC.
@@ -1840,7 +1857,7 @@ proc layoutImage(fstate: var FlowState; ibox: InlineImageBox; padding: LUnit) =
 proc layoutInline(fstate: var FlowState; ibox: InlineBox) =
   let lctx = fstate.lctx
   let computed = ibox.computed
-  ibox.state = InlineBoxState()
+  ibox.resetState()
   let padding = Span(
     start: computed{"padding-left"}.px(fstate.space.w),
     send: computed{"padding-right"}.px(fstate.space.w)
@@ -1885,7 +1902,7 @@ proc layoutInline(fstate: var FlowState; ibox: InlineBox) =
       fstate.initLine()
       fstate.lbstate.size.w += padding.start
     if computed{"position"} != PositionStatic:
-      lctx.pushPositioned()
+      lctx.pushPositioned(ibox)
     for child in ibox.children:
       if child of InlineBox:
         fstate.layoutInline(InlineBox(child))
@@ -1915,8 +1932,8 @@ proc layoutInline(fstate: var FlowState; ibox: InlineBox) =
       # and Gecko can't even layout it consistently (???)
       #
       # So I'm trying to follow Blink, though it's still not quite right,
-      # since it uses cellHeight instead of the actual line height for the
-      # last line.
+      # since this uses cellHeight instead of the actual line height
+      # for the last line.
       # Well, it seems good enough.
       lctx.popPositioned(ibox, size(
         w = 0,
@@ -2030,7 +2047,7 @@ proc initReLayout(fstate: var FlowState; bctx: var BlockContext; box: BlockBox;
 proc layoutFlow(bctx: var BlockContext; box: BlockBox; sizes: ResolvedSizes;
     canClear: bool) =
   if box.computed{"position"} != PositionStatic:
-    bctx.lctx.pushPositioned()
+    bctx.lctx.pushPositioned(box)
   if canClear and box.computed{"clear"} != ClearNone and
       box.computed{"position"} notin PositionAbsoluteFixed:
     bctx.flushMargins(box.state.offset.y)
@@ -2093,7 +2110,8 @@ proc layoutListItem(bctx: var BlockContext; box: BlockBox;
   of ListStylePositionOutside:
     let marker = BlockBox(box.firstChild)
     let content = BlockBox(marker.next)
-    content.state = BoxLayoutState(offset: box.state.offset)
+    content.resetState()
+    content.state.offset = box.state.offset
     bctx.layoutFlow(content, sizes, canClear = true)
     let markerSizes = ResolvedSizes(
       space: availableSpace(w = fitContent(sizes.space.w), h = sizes.space.h),
@@ -2104,6 +2122,7 @@ proc layoutListItem(bctx: var BlockContext; box: BlockBox;
     # instead
     marker.state.offset.x = -marker.state.size.w
     # take inner box min width etc.
+    box.resetState()
     box.state = content.state
     content.state.offset = offset(x = 0, y = 0)
   of ListStylePositionInside:
@@ -2165,7 +2184,7 @@ proc layoutTableCell(lctx: LayoutContext; box: BlockBox;
   )
   if sizes.space.w.isDefinite():
     sizes.space.w.u -= sizes.padding[dtHorizontal].sum()
-  box.state = BoxLayoutState()
+  box.resetState()
   var bctx = BlockContext(lctx: lctx)
   bctx.layoutFlow(box, sizes, canClear = false)
   assert bctx.unpositionedFloats.len == 0
@@ -2317,7 +2336,7 @@ proc alignTableCell(cell: BlockBox; availableHeight, baseline: LUnit) =
 
 proc layoutTableRow(tctx: TableContext; ctx: RowContext;
     parent, row: BlockBox) =
-  row.state = BoxLayoutState()
+  row.resetState()
   var x: LUnit = 0
   var n = 0
   var baseline: LUnit = 0
@@ -2586,7 +2605,7 @@ proc layoutInnerTable(tctx: var TableContext; table, parent: BlockBox;
 # block-level wrapper box.
 proc layoutTable(bctx: BlockContext; box: BlockBox; sizes: ResolvedSizes) =
   let table = BlockBox(box.firstChild)
-  table.state = BoxLayoutState()
+  table.resetState()
   var tctx = TableContext(lctx: bctx.lctx, space: sizes.space)
   tctx.layoutInnerTable(table, box, sizes)
   box.state.size = table.state.size
@@ -2633,6 +2652,7 @@ type
     firstBaseline: LUnit
     baseline: LUnit
     canWrap: bool
+    reverse: bool
     dim: DimensionType # main dimension
     firstBaselineSet: bool
 
@@ -2765,6 +2785,11 @@ proc flushMain(fctx: var FlexContext; mctx: var FlexMainContext;
       fctx.firstBaselineSet = true
       fctx.firstBaseline = baseline
     fctx.baseline = baseline
+  if fctx.reverse:
+    for it in mctx.pending:
+      let child = it.child
+      child.state.offset[dim] = offset[dim] - child.state.offset[dim] -
+        child.state.size[dim]
   fctx.totalMaxSize[dim] = max(fctx.totalMaxSize[dim], offset[dim])
   fctx.mains.add(mctx)
   fctx.intr[dim] = max(fctx.intr[dim], intr[dim])
@@ -2818,7 +2843,7 @@ proc layoutFlexIter(fctx: var FlexContext; mctx: var FlexMainContext;
 proc layoutFlex(bctx: var BlockContext; box: BlockBox; sizes: ResolvedSizes) =
   let lctx = bctx.lctx
   if box.computed{"position"} != PositionStatic:
-    lctx.pushPositioned()
+    lctx.pushPositioned(box)
   let flexDir = box.computed{"flex-direction"}
   let dim = if flexDir in FlexRow: dtHorizontal else: dtVertical
   let odim = dim.opposite()
@@ -2827,6 +2852,7 @@ proc layoutFlex(bctx: var BlockContext; box: BlockBox; sizes: ResolvedSizes) =
     offset: sizes.padding.topLeft,
     redistSpace: sizes.space[dim],
     canWrap: box.computed{"flex-wrap"} != FlexWrapNowrap,
+    reverse: box.computed{"flex-direction"} in FlexReverse,
     dim: dim
   )
   if fctx.redistSpace.t == scFitContent and sizes.bounds.a[dim].start > 0:
@@ -2834,17 +2860,9 @@ proc layoutFlex(bctx: var BlockContext; box: BlockBox; sizes: ResolvedSizes) =
   if fctx.redistSpace.isDefinite:
     fctx.redistSpace.u = fctx.redistSpace.u.minClamp(sizes.bounds.a[dim])
   var mctx = FlexMainContext()
-  if box.computed{"flex-direction"} notin FlexReverse:
-    for child in box.children:
-      let child = BlockBox(child)
-      fctx.layoutFlexIter(mctx, child, sizes)
-  else:
-    var children: seq[CSSBox] = @[]
-    for it in box.children:
-      children.add(it)
-    for i in countdown(children.high, 0):
-      let child = BlockBox(children[i])
-      fctx.layoutFlexIter(mctx, child, sizes)
+  for child in box.children:
+    let child = BlockBox(child)
+    fctx.layoutFlexIter(mctx, child, sizes)
   if mctx.pending.len > 0:
     fctx.flushMain(mctx, sizes)
   var size = fctx.totalMaxSize
@@ -2868,7 +2886,8 @@ proc layoutRootBlock(lctx: LayoutContext; box: BlockBox; offset: Offset;
     return
   box.sizes = sizes
   var bctx = BlockContext(lctx: lctx)
-  box.state = BoxLayoutState(offset: offset)
+  box.resetState()
+  box.state.offset = offset
   bctx.layout(box, sizes, canClear = false)
   assert bctx.unpositionedFloats.len == 0
   let marginBottom = bctx.marginTodo.sum()
@@ -2882,15 +2901,16 @@ proc layoutRootBlock(lctx: LayoutContext; box: BlockBox; offset: Offset;
   box.state.intr.h = max(box.state.intr.h, bctx.maxFloatHeight)
   box.state.marginBottom = marginBottom
 
-proc layout*(box: BlockBox; attrsp: ptr WindowAttributes) =
+proc layout*(box: BlockBox; attrsp: ptr WindowAttributes): StackItem =
   let space = availableSpace(
     w = stretch(attrsp[].widthPx),
     h = stretch(attrsp[].heightPx)
   )
+  let stack = StackItem(box: box)
   let lctx = LayoutContext(
     attrsp: attrsp,
     cellSize: size(w = attrsp.ppc, h = attrsp.ppl),
-    positioned: @[@[], @[]],
+    positioned: @[PositionedItem(stack: stack), PositionedItem(stack: stack)],
     luctx: LUContext()
   )
   let sizes = lctx.resolveBlockSizes(space, box.computed)
@@ -2898,7 +2918,7 @@ proc layout*(box: BlockBox; attrsp: ptr WindowAttributes) =
   lctx.layoutRootBlock(box, sizes.margin.topLeft, sizes)
   var size = size(w = attrsp[].widthPx, h = attrsp[].heightPx)
   # Last absolute layer.
-  lctx.popPositioned(box, size)
+  lctx.popPositioned(nil, size)
   # Fixed containing block.
   # The idea is to move fixed boxes to the real edges of the page,
   # so that they do not overlap with other boxes *and* we don't have
@@ -2907,4 +2927,5 @@ proc layout*(box: BlockBox; attrsp: ptr WindowAttributes) =
   # slow down the renderer to a crawl.)
   size.w = max(size.w, box.state.size.w)
   size.h = max(size.h, box.state.size.h)
-  lctx.popPositioned(box, size, skipStatic = false)
+  lctx.popPositioned(box, size)
+  return stack
diff --git a/src/css/render.nim b/src/css/render.nim
index 875cf5c0..565b7c9e 100644
--- a/src/css/render.nim
+++ b/src/css/render.nim
@@ -1,5 +1,3 @@
-import std/algorithm
-
 import css/box
 import css/cssvalues
 import css/lunit
@@ -38,21 +36,10 @@ type
     height*: int
     bmp*: NetworkBitmap
 
-  ClipBox = object
-    start: Offset
-    send: Offset
-
-  StackItem = object
-    box: CSSBox
-    clipBox: ClipBox
-    index: int
-
   RenderState = object
-    clipBoxes: seq[ClipBox]
     bgcolor: CellColor
     attrsp: ptr WindowAttributes
     images: seq[PosBitmap]
-    nstack: seq[StackItem]
     spaces: string # buffer filled with spaces for padding
 
 # Forward declarations
@@ -62,9 +49,6 @@ proc renderBlock(grid: var FlexibleGrid; state: var RenderState;
 template attrs(state: RenderState): WindowAttributes =
   state.attrsp[]
 
-template clipBox(state: RenderState): ClipBox =
-  state.clipBoxes[^1]
-
 func findFormatN*(line: FlexibleLine; pos: int): int =
   var i = 0
   while i < line.formats.len:
@@ -242,21 +226,21 @@ proc setText1(line: var FlexibleLine; s: openArray[char]; x, targetX: int;
   line.setTextFormat(x, cx, targetX, hadStr, format, node)
 
 proc setText(grid: var FlexibleGrid; state: var RenderState; s: string;
-    offset: Offset; format: Format; node: Element) =
-  if offset.y notin state.clipBox.start.y ..< state.clipBox.send.y:
+    offset: Offset; format: Format; node: Element; clipBox: ClipBox) =
+  if offset.y notin clipBox.start.y ..< clipBox.send.y:
     return
-  if offset.x > state.clipBox.send.x:
+  if offset.x > clipBox.send.x:
     return
   var x = (offset.x div state.attrs.ppc).toInt
   # Give room for rounding errors.
   #TODO I'm sure there is a better way to do this, but this seems OK for now.
-  let sx = max((state.clipBox.start.x - state.attrs.ppc) div state.attrs.ppc, 0)
+  let sx = max((clipBox.start.x - state.attrs.ppc) div state.attrs.ppc, 0)
   var i = 0
   while x < sx and i < s.len:
     x += s.nextUTF8(i).width()
   if x < sx: # highest x is outside the clipping box, no need to draw
     return
-  let ex = ((state.clipBox.send.x + state.attrs.ppc) div state.attrs.ppc).toInt
+  let ex = ((clipBox.send.x + state.attrs.ppc) div state.attrs.ppc).toInt
   var j = i
   var targetX = x
   while targetX < ex and j < s.len:
@@ -270,8 +254,7 @@ proc setText(grid: var FlexibleGrid; state: var RenderState; s: string;
 
 proc paintBackground(grid: var FlexibleGrid; state: var RenderState;
     color: CellColor; startx, starty, endx, endy: int; node: Element;
-    alpha: uint8) =
-  let clipBox = addr state.clipBox
+    alpha: uint8; clipBox: ClipBox) =
   var startx = startx
   var starty = starty
   var endx = endx
@@ -353,21 +336,20 @@ proc paintInlineBox(grid: var FlexibleGrid; state: var RenderState;
     let x2 = toInt(offset.x + area.offset.x + area.size.w)
     let y2 = toInt(offset.y + area.offset.y + area.size.h)
     grid.paintBackground(state, bgcolor, x1, y1, x2, y2, box.element,
-      alpha)
+      alpha, box.render.clipBox)
 
 proc renderInline(grid: var FlexibleGrid; state: var RenderState;
     ibox: InlineBox; offset: Offset; bgcolor0 = rgba(0, 0, 0, 0);
     pass2 = false) =
-  let position = ibox.computed{"position"}
-  #TODO handle negative z-index
-  let zindex = ibox.computed{"z-index"}
-  if position != PositionStatic and not pass2 and zindex >= 0:
-    state.nstack.add(StackItem(
-      box: ibox,
-      clipBox: state.clipBox,
-      index: zindex
-    ))
-    return
+  let clipBox = if ibox.parent != nil:
+    ibox.parent.render.clipBox
+  else:
+    DefaultClipBox
+  ibox.render = BoxRenderState(
+    offset: offset + ibox.state.startOffset,
+    clipBox: clipBox,
+    positioned: true
+  )
   let bgcolor = ibox.computed{"background-color"}
   var bgcolor0 = bgcolor0
   if bgcolor.isCell:
@@ -377,16 +359,15 @@ proc renderInline(grid: var FlexibleGrid; state: var RenderState;
   else:
     bgcolor0 = bgcolor0.blend(bgcolor.argb)
     if bgcolor0.a > 0:
-      grid.paintInlineBox(state, ibox, offset,
-        bgcolor0.rgb.cellColor(), bgcolor0.a)
-  ibox.render.offset = offset + ibox.state.startOffset
+      grid.paintInlineBox(state, ibox, offset, bgcolor0.rgb.cellColor(),
+        bgcolor0.a)
   if ibox of InlineTextBox:
     let ibox = InlineTextBox(ibox)
     let format = ibox.computed.toFormat()
     for run in ibox.runs:
       let offset = offset + run.offset
       if ibox.computed{"visibility"} == VisibilityVisible:
-        grid.setText(state, run.str, offset, format, ibox.element)
+        grid.setText(state, run.str, offset, format, ibox.element, clipBox)
   elif ibox of InlineImageBox:
     let ibox = InlineImageBox(ibox)
     if ibox.computed{"visibility"} != VisibilityVisible:
@@ -394,7 +375,6 @@ proc renderInline(grid: var FlexibleGrid; state: var RenderState;
     let offset = offset + ibox.imgstate.offset
     let x2p = offset.x + ibox.imgstate.size.w
     let y2p = offset.y + ibox.imgstate.size.h
-    let clipBox = addr state.clipBoxes[^1]
     #TODO implement proper image clipping
     if offset.x < clipBox.send.x and offset.y < clipBox.send.y and
         x2p >= clipBox.start.x and y2p >= clipBox.start.y:
@@ -404,7 +384,7 @@ proc renderInline(grid: var FlexibleGrid; state: var RenderState;
       let y2 = y2p.toInt
       # add Element to background (but don't actually color it)
       grid.paintBackground(state, defaultColor, x1, y1, x2, y2,
-        ibox.element, 0)
+        ibox.element, 0, ibox.render.clipBox)
       let x = (offset.x div state.attrs.ppc).toInt
       let y = (offset.y div state.attrs.ppl).toInt
       let offx = (offset.x - x.toLUnit * state.attrs.ppc).toInt
@@ -419,31 +399,26 @@ proc renderInline(grid: var FlexibleGrid; state: var RenderState;
         bmp: ibox.bmp
       ))
   else: # InlineNewLineBox does not have children, so we handle it here
+    # only check position here to avoid skipping leaves that use our
+    # computed values
+    if ibox.positioned and not pass2:
+      return
     for child in ibox.children:
       if child of InlineBox:
         grid.renderInline(state, InlineBox(child), offset, bgcolor0)
       else:
         grid.renderBlock(state, BlockBox(child), offset)
 
-proc renderBlock(grid: var FlexibleGrid; state: var RenderState;
-    box: BlockBox; offset: Offset; pass2 = false) =
-  let position = box.computed{"position"}
-  #TODO handle negative z-index
-  let zindex = box.computed{"z-index"}
-  if position != PositionStatic and not pass2 and zindex >= 0:
-    state.nstack.add(StackItem(
-      box: box,
-      clipBox: state.clipBox,
-      index: zindex
-    ))
+proc inheritClipBox(box: BlockBox; parent: CSSBox) =
+  if parent == nil:
+    box.render.clipBox = DefaultClipBox
     return
-  let offset = offset + box.state.offset
-  box.render.offset = offset
+  assert parent.render.positioned
+  var clipBox = parent.render.clipBox
   let overflowX = box.computed{"overflow-x"}
   let overflowY = box.computed{"overflow-y"}
-  let hasClipBox = overflowX != OverflowVisible or overflowY != OverflowVisible
-  if hasClipBox:
-    var clipBox = state.clipBox
+  if overflowX != OverflowVisible or overflowY != OverflowVisible:
+    let offset = box.render.offset
     if overflowX in OverflowHiddenLike:
       clipBox.start.x = max(offset.x, clipBox.start.x)
       clipBox.send.x = min(offset.x + box.state.size.w, clipBox.send.x)
@@ -453,7 +428,17 @@ proc renderBlock(grid: var FlexibleGrid; state: var RenderState;
     if overflowY in OverflowHiddenLike:
       clipBox.start.y = max(offset.y, clipBox.start.y)
       clipBox.send.y = min(offset.y + box.state.size.h, clipBox.send.y)
-    state.clipBoxes.add(clipBox)
+  box.render.clipBox = clipBox
+
+proc renderBlock(grid: var FlexibleGrid; state: var RenderState;
+    box: BlockBox; offset: Offset; pass2 = false) =
+  if box.positioned and not pass2:
+    return
+  let offset = offset + box.state.offset
+  box.render.offset = offset
+  box.render.positioned = true
+  if not pass2:
+    box.inheritClipBox(box.parent)
   let opacity = box.computed{"opacity"}
   if box.computed{"visibility"} == VisibilityVisible and opacity != 0:
     #TODO maybe blend with the terminal background?
@@ -471,7 +456,7 @@ proc renderBlock(grid: var FlexibleGrid; state: var RenderState;
       let iex = toInt(e.x)
       let iey = toInt(e.y)
       grid.paintBackground(state, bgcolor, ix, iy, iex, iey, box.element,
-        bgcolor0.a)
+        bgcolor0.a, box.render.clipBox)
     if box.computed{"background-image"} != nil:
       # ugly hack for background-image display... TODO actually display images
       const s = "[img]"
@@ -481,55 +466,95 @@ proc renderBlock(grid: var FlexibleGrid; state: var RenderState;
         # text is larger than image; center it to minimize error
         offset.x -= w div 2
         offset.x += box.state.size.w div 2
-      grid.setText(state, s, offset, box.computed.toFormat(), box.element)
+      grid.setText(state, s, offset, box.computed.toFormat(), box.element,
+        box.render.clipBox)
   if opacity != 0: #TODO this isn't right...
-    if state.clipBox.start.x < state.clipBox.send.x and
-        state.clipBox.start.y < state.clipBox.send.y:
+    if box.render.clipBox.start < box.render.clipBox.send:
       for child in box.children:
         if child of InlineBox:
           grid.renderInline(state, InlineBox(child), offset)
         else:
           grid.renderBlock(state, BlockBox(child), offset)
-  if hasClipBox:
-    discard state.clipBoxes.pop()
 
-func findBlockParent(box: CSSBox): BlockBox =
+# This function exists to support another insanity-inducing CSS
+# construct: negative z-index.
+# The issue here is that their position depends on their parent, but the
+# parent box is very often not positioned yet.  So we brute-force our
+# way out of the problem by resolving the parent box's position here.
+# The algorithm itself is mildly confusing because we must skip
+# InlineBox offsets in the process - this means that there may be inline
+# boxes after this pass with an unresolved position which contain block
+# boxes with a resolved position.
+proc resolveBlockParent(box: CSSBox): BlockBox =
   var it {.cursor.} = box.parent
   while it != nil:
     if it of BlockBox:
-      return BlockBox(it)
+      break
     it = it.parent
+  var toPosition: seq[BlockBox] = @[]
+  let findPositioned = box.computed{"position"} in PositionAbsoluteFixed
+  var it2 {.cursor.} = it
+  var parent {.cursor.}: CSSBox = nil
+  while it2 != nil:
+    if it2 of BlockBox:
+      let it2 = BlockBox(it2)
+      if it2.render.positioned and (not findPositioned or it2.positioned):
+        break
+      toPosition.add(it2)
+    it2 = it2.parent
+  var offset = if it2 != nil: it2.render.offset else: offset(0, 0)
+  for i in countdown(toPosition.high, 0):
+    let it = toPosition[i]
+    offset += it.state.offset
+    it.render = BoxRenderState(
+      offset: offset,
+      clipBox: DefaultClipBox,
+      positioned: true
+    )
+    it.inheritClipBox(parent)
+    parent = it
+  if box of BlockBox:
+    let box = BlockBox(box)
+    box.render.clipBox = DefaultClipBox
+    if findPositioned:
+      box.inheritClipBox(it2)
+    else:
+      box.inheritClipBox(it)
+  return BlockBox(it)
 
 proc renderPositioned(grid: var FlexibleGrid; state: var RenderState;
     box: CSSBox) =
-  let parent = box.findBlockParent()
+  let parent = box.resolveBlockParent()
   let offset = if parent != nil: parent.render.offset else: offset(0, 0)
   if box of BlockBox:
     grid.renderBlock(state, BlockBox(box), offset, pass2 = true)
   else:
     grid.renderInline(state, InlineBox(box), offset, pass2 = true)
 
-proc render*(grid: var FlexibleGrid; bgcolor: var CellColor; rootBox: BlockBox;
+proc renderStack(grid: var FlexibleGrid; state: var RenderState;
+    stack: StackItem) =
+  var i = 0
+  # negative z-index
+  while i < stack.children.len:
+    let it = stack.children[i]
+    if it.index >= 0:
+      break
+    grid.renderStack(state, it)
+    inc i
+  grid.renderPositioned(state, stack.box)
+  # z-index >= 0
+  for it in stack.children.toOpenArray(i, stack.children.high):
+    grid.renderStack(state, it)
+
+proc render*(grid: var FlexibleGrid; bgcolor: var CellColor; stack: StackItem;
     attrsp: ptr WindowAttributes; images: var seq[PosBitmap]) =
   grid.setLen(0)
-  if rootBox == nil:
-    # no HTML element when we run cascade; just clear all lines.
-    return
   var state = RenderState(
-    clipBoxes: @[ClipBox(send: offset(LUnit.high, LUnit.high))],
     attrsp: attrsp,
     bgcolor: defaultColor
   )
-  var stack = @[StackItem(box: rootBox, clipBox: state.clipBox)]
-  while stack.len > 0:
-    for it in stack:
-      state.clipBoxes.add(it.clipBox)
-      grid.renderPositioned(state, it.box)
-      discard state.clipBoxes.pop()
-    stack = move(state.nstack)
-    stack.sort(proc(x, y: StackItem): int = cmp(x.index, y.index))
-    state.nstack = @[]
+  grid.renderStack(state, stack)
   if grid.len == 0:
     grid.setLen(1)
   bgcolor = state.bgcolor
-  images = state.images
+  images = move(state.images)