diff options
author | bptato <nincsnevem662@gmail.com> | 2024-12-18 20:54:43 +0100 |
---|---|---|
committer | bptato <nincsnevem662@gmail.com> | 2024-12-18 20:54:43 +0100 |
commit | e56ad3318cddf8c6df8a7af7682b2233229f3ad1 (patch) | |
tree | 7efbb0ce757e985903986f87080fe23f2c277fb2 /src | |
parent | 9d784a19b345085360f8b663ccc7450c2dab18f2 (diff) | |
download | chawan-e56ad3318cddf8c6df8a7af7682b2233229f3ad1.tar.gz |
layout, render: implement overflow property
Overflow pretty much requires scrollbars, but those wouldn't work in dump mode, plus of course they would be a pain to implement. So as a simple alternative: * overflow: hidden, clip works as per spec. * overflow: auto, overlay, scroll invert the intrinsic minimum size clamping logic instead of adding a scrollbar. What this concretely means, is that this <pre style="overflow: scroll; height: 1em"> test test test </pre> will, instead of creating a scroll container, just override the specified height. This hack works surprisingly well, because CSS pretty much requires setting height on scroll containers, so authors aren't incentivized to set height on the parent container too (because the contents are already sized appropriately). One issue left is how to deal with overflow: hidden ancestors. For now, I've made it so that it can spill for overflow-x, and always clips on overflow-y, because it's much less likely to bleed into other text horizontally than vertically. But there is definitely room for improvement, e.g. we could track space requested by scrolling children and expand parent boxes based on that.
Diffstat (limited to 'src')
-rw-r--r-- | src/css/cascade.nim | 5 | ||||
-rw-r--r-- | src/css/cssvalues.nim | 36 | ||||
-rw-r--r-- | src/css/layout.nim | 22 | ||||
-rw-r--r-- | src/css/render.nim | 244 |
4 files changed, 201 insertions, 106 deletions
diff --git a/src/css/cascade.nim b/src/css/cascade.nim index d0cba1de..6a2172d7 100644 --- a/src/css/cascade.nim +++ b/src/css/cascade.nim @@ -274,6 +274,11 @@ func buildComputedValues(rules: CSSValueEntryMap; let display = result{"display"}.blockify() if display != result{"display"}: result{"display"} = display + if (result{"overflow-x"} in {OverflowVisible, OverflowClip}) != + (result{"overflow-y"} in {OverflowVisible, OverflowClip}): + result{"overflow-x"} = result{"overflow-x"}.bfcify() + result{"overflow-y"} = result{"overflow-y"}.bfcify() + proc add(map: var CSSValueEntryObj; rules: seq[CSSRuleDef]) = for rule in rules: diff --git a/src/css/cssvalues.nim b/src/css/cssvalues.nim index d22fd414..96721e7c 100644 --- a/src/css/cssvalues.nim +++ b/src/css/cssvalues.nim @@ -25,6 +25,7 @@ type cstListStyle = "list-style" cstFlex = "flex" cstFlexFlow = "flex-flow" + cstOverflow = "overflow" CSSUnit* = enum cuAuto = "" @@ -65,7 +66,8 @@ type cptClear = "clear" cptTextTransform = "text-transform" cptFlexDirection = "flex-direction" - cptOverflow = "overflow" + cptOverflowX = "overflow-x" + cptOverflowY = "overflow-y" cptFlexWrap = "flex-wrap" cptBgcolorIsCanvas = "-cha-bgcolor-is-canvas" cptFontStyle = "font-style" @@ -309,6 +311,7 @@ type OverflowClip = "clip" OverflowScroll = "scroll" OverflowAuto = "auto" + OverflowOverlay = "overlay" type CSSLengthType* = enum @@ -425,7 +428,8 @@ const ValueTypes = [ cptClear: cvtClear, cptTextTransform: cvtTextTransform, cptFlexDirection: cvtFlexDirection, - cptOverflow: cvtOverflow, + cptOverflowX: cvtOverflow, + cptOverflowY: cvtOverflow, cptFlexWrap: cvtFlexWrap, cptBgcolorIsCanvas: cvtBgcolorIsCanvas, cptFontStyle: cvtFontStyle, @@ -477,6 +481,9 @@ const PositionStaticLike* = { PositionStatic, PositionSticky } +const OverflowScrollLike* = {OverflowScroll, OverflowAuto, OverflowOverlay} +const OverflowHiddenLike* = {OverflowHidden, OverflowClip} + func isBit*(t: CSSPropertyType): bool = return t <= cptFontStyle @@ -574,6 +581,13 @@ func blockify*(display: CSSDisplay): CSSDisplay = of DisplayInlineFlex: return DisplayFlex +func bfcify*(overflow: CSSOverflow): CSSOverflow = + if overflow == OverflowVisible: + return OverflowAuto + if overflow == OverflowClip: + return OverflowHidden + return overflow + const UpperAlphaMap = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".toPoints() const LowerAlphaMap = "abcdefghijklmnopqrstuvwxyz".toPoints() const LowerGreekMap = "αβγδεζηθικλμνξοπρστυφχψω".toPoints() @@ -1449,6 +1463,24 @@ proc parseComputedValues*(res: var seq[CSSComputedEntry]; name: string; else: res.add(makeEntry(cptFlexDirection, global)) res.add(makeEntry(cptFlexWrap, global)) + of cstOverflow: + if global == cgtNone: + var i = 0 + cvals.skipWhitespace(i) + if i >= cvals.len: + return err() + if (let xx = parseIdent[CSSOverflow](cvals[i]); xx.isSome): + var x = CSSValueBit(overflow: xx.get) + var y = x + inc i + cvals.skipWhitespace(i) + if i < cvals.len: + y.overflow = ?parseIdent[CSSOverflow](cvals[i]) + res.add(makeEntry(cptOverflowX, x)) + res.add(makeEntry(cptOverflowY, y)) + else: + res.add(makeEntry(cptOverflowX, global)) + res.add(makeEntry(cptOverflowY, global)) return ok() proc parseComputedValues*(name: string; value: seq[CSSComponentValue]; diff --git a/src/css/layout.nim b/src/css/layout.nim index 85d136c0..82f5b248 100644 --- a/src/css/layout.nim +++ b/src/css/layout.nim @@ -1191,7 +1191,7 @@ proc resolveBlockSizes(lctx: LayoutContext; space: AvailableSpace; if computed{"display"} == DisplayTableWrapper: sizes.space.w = fitContent(sizes.space.w) # height is max-content normally, but fit-content for clip. - sizes.space.h = if computed{"overflow"} != OverflowClip: + sizes.space.h = if computed{"overflow-y"} != OverflowClip: maxContent() else: fitContent(sizes.space.h) @@ -1228,8 +1228,22 @@ proc applySize(box: BlockBox; sizes: ResolvedSizes; box.state.size[dim] = minClamp(box.state.size[dim], sizes.bounds.a[dim]) proc clampIntr(box: BlockBox; sizes: ResolvedSizes) = - box.state.intr.w = min(box.state.intr.w, sizes.bounds.minClamp[dtHorizontal]) - box.state.intr.h = min(box.state.intr.h, sizes.bounds.minClamp[dtVertical]) + # We do not have a scroll bar, so do the next best thing: expand the + # box to the size its contents want. + #TODO this is far from perfect. For one, intrinsic minimum size isn't + # guaranteed to equal the desired scroll size. Also, it's possible + # that a parent box clamps the height of this box; in that case, + # the parent box's width/height should be clamped to the inner scroll + # width/height instead. + # Anyway, it is a pretty good approximation for now. + if box.computed{"overflow-x"} notin OverflowScrollLike: + box.state.intr.w = min(box.state.intr.w, sizes.bounds.minClamp[dtHorizontal]) + else: + box.state.size.w = max(box.state.size.w, box.state.intr.w) + if box.computed{"overflow-y"} notin OverflowScrollLike: + box.state.intr.h = min(box.state.intr.h, sizes.bounds.minClamp[dtVertical]) + else: + box.state.size.h = max(box.state.size.h, box.state.intr.h) proc applyWidth(box: BlockBox; sizes: ResolvedSizes; maxChildWidth: LayoutUnit; space: AvailableSpace) = @@ -2625,7 +2639,7 @@ func establishesBFC(computed: CSSValues): bool = return computed{"float"} != FloatNone or computed{"display"} in {DisplayFlowRoot, DisplayTable, DisplayTableWrapper, DisplayFlex} or - computed{"overflow"} notin {OverflowVisible, OverflowClip} + computed{"overflow-x"} notin {OverflowVisible, OverflowClip} #TODO contain, grid, multicol, column-span # Layout and place all children in the block box. diff --git a/src/css/render.nim b/src/css/render.nim index 06fa02a7..9f869ad9 100644 --- a/src/css/render.nim +++ b/src/css/render.nim @@ -29,6 +29,42 @@ type FlexibleGrid* = seq[FlexibleLine] + PosBitmap* = ref object + x*: int + y*: int + offx*: int + offy*: int + width*: int + height*: int + bmp*: NetworkBitmap + + ClipBox = object + start: Offset + send: Offset + + StackItem = ref object + box: BlockBox + offset: Offset + apos: Offset + clipBox: ClipBox + index: int + + RenderState = object + # Position of the absolute positioning containing block: + # https://drafts.csswg.org/css-position/#absolute-positioning-containing-block + absolutePos: seq[Offset] + clipBoxes: seq[ClipBox] + bgcolor: CellColor + attrsp: ptr WindowAttributes + images: seq[PosBitmap] + nstack: seq[StackItem] + +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: @@ -91,18 +127,18 @@ proc findFirstX(line: var FlexibleLine; x: int; outi: var int): int = outi = i return cx -proc setTextStr(line: var FlexibleLine; linestr, ostr: string; +proc setTextStr(line: var FlexibleLine; s, ostr: openArray[char]; i, x, cx, nx, targetX: int) = var i = i let padlen = i + x - cx var widthError = max(nx - targetX, 0) - let linestrTargetI = padlen + linestr.len - line.str.setLen(linestrTargetI + widthError + ostr.len) + let targeti = padlen + s.len + line.str.setLen(targeti + widthError + ostr.len) while i < padlen: # place before new string line.str[i] = ' ' inc i - copyMem(addr line.str[i], unsafeAddr linestr[0], linestr.len) - i = linestrTargetI + copyMem(addr line.str[i], unsafeAddr s[0], s.len) + i = targeti while widthError > 0: # we ate half of a double width char; pad it out with spaces. line.str[i] = ' ' @@ -195,90 +231,71 @@ proc setTextFormat(line: var FlexibleLine; x, cx, nx: int; ostr: string; assert line.formats[fi].pos <= nx # That's it! -proc setText(line: var FlexibleLine; linestr: string; x: int; format: Format; - node: StyledNode) = - assert x >= 0 and linestr.len != 0 - var targetX = x + linestr.width() +proc setText0(line: var FlexibleLine; s: openArray[char]; x, targetX: int; + format: Format; node: StyledNode) = + assert x >= 0 and s.len != 0 var i = 0 - var cx = line.findFirstX(x, i) # first x of new string (before padding) + let cx = line.findFirstX(x, i) # first x of new string (before padding) var j = i var nx = x # last x of new string while nx < targetX and j < line.str.len: nx += line.str.nextUTF8(j).width() let ostr = line.str.substr(j) - line.setTextStr(linestr, ostr, i, x, cx, nx, targetX) + line.setTextStr(s, ostr, i, x, cx, nx, targetX) line.setTextFormat(x, cx, nx, ostr, format, node) -proc setText(grid: var FlexibleGrid; linestr: string; x, y: int; format: Format; - node: StyledNode) = - var x = x - var i = 0 - while x < 0 and i < linestr.len: - x += linestr.nextUTF8(i).width() - if x < 0: - # highest x is outside the canvas, no need to draw +proc setText(grid: var FlexibleGrid; state: var RenderState; s: string; + offset: Offset; format: Format; node: StyledNode) = + if offset.y notin state.clipBox.start.y ..< state.clipBox.send.y: return - # make sure we have line y - if grid.high < y: - grid.addLines(y - grid.high) - if i == 0: - grid[y].setText(linestr, x, format, node) - elif i < linestr.len: - grid[y].setText(linestr.substr(i), x, format, node) - -type - PosBitmap* = ref object - x*: int - y*: int - offx*: int - offy*: int - width*: int - height*: int - bmp*: NetworkBitmap - - StackItem = ref object - box: BlockBox - offset: Offset - apos: Offset - index: int - - 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] - nstack: seq[StackItem] - -template attrs(state: RenderState): WindowAttributes = - state.attrsp[] - -proc setRowWord(grid: var FlexibleGrid; state: var RenderState; - word: InlineAtom; offset: Offset; format: Format; node: StyledNode) = - let y = toInt((offset.y + word.offset.y) div state.attrs.ppl) # y cell - if y < 0: - # y is outside the canvas, no need to draw + if offset.x > state.clipBox.send.x: return - var x = toInt((offset.x + word.offset.x) div state.attrs.ppc) # x cell - grid.setText(word.str, x, y, format, node) + 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) + 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 + #TODO starting to think lunit should just clamp on overflow + var ex = int.high + if state.clipBox.send.x < LayoutUnit.high - state.attrs.ppc: + let tmp = (state.clipBox.send.x + state.attrs.ppc) div state.attrs.ppc + ex = tmp.toInt + var j = i + var targetX = x + while targetX < ex and j < s.len: + targetX += s.nextUTF8(j).width() + if i < j: + let y = (offset.y div state.attrs.ppl).toInt + # make sure we have line y + if grid.high < y: + grid.addLines(y - grid.high) + grid[y].setText0(s.toOpenArray(i, j - 1), x, targetX, format, node) proc paintBackground(grid: var FlexibleGrid; state: var RenderState; color: CellColor; startx, starty, endx, endy: int; node: StyledNode; noPaint = false) = - var starty = max(starty div state.attrs.ppl, 0) - var endy = max(endy div state.attrs.ppl, 0) - var startx = max(startx div state.attrs.ppc, 0) - var endx = max(endx div state.attrs.ppc, 0) - if starty == endy or startx == endx: - return # size is 0, no need to paint + let clipBox = addr state.clipBox + var startx = startx + var starty = starty + var endx = endx + var endy = endy if starty > endy: swap(starty, endy) if startx > endx: swap(startx, endx) + starty = max(starty, clipBox.start.y.toInt) div state.attrs.ppl + endy = min(endy, clipBox.send.y.toInt) div state.attrs.ppl + startx = max(startx, clipBox.start.x.toInt) div state.attrs.ppc + endx = min(endx, clipBox.send.x.toInt) div state.attrs.ppc + if starty >= endy or startx >= endx: + return if grid.high < endy: # make sure we have line y grid.addLines(endy - grid.high) - for y in starty..<endy: + for y in starty ..< endy: # Make sure line.width() >= endx for i in grid[y].str.width() ..< endx: grid[y].str &= ' ' @@ -361,29 +378,36 @@ proc renderInlineFragment(grid: var FlexibleGrid; state: var RenderState; grid.renderBlockBox(state, atom.innerbox, offset + atom.offset) of iatWord: if fragment.computed{"visibility"} == VisibilityVisible: - grid.setRowWord(state, atom, offset, format, fragment.node) + grid.setText(state, atom.str, offset + atom.offset, format, + fragment.node) of iatImage: if fragment.computed{"visibility"} == VisibilityVisible: - let x1 = offset.x.toInt - let y1 = offset.y.toInt - let x2 = (offset.x + atom.size.w).toInt - let y2 = (offset.y + atom.size.h).toInt - # add StyledNode to background (but don't actually color it) - grid.paintBackground(state, defaultColor, x1, y1, x2, y2, - fragment.node, noPaint = true) - let x = (offset.x div state.attrs.ppc).toInt - let y = (offset.y div state.attrs.ppl).toInt - let offx = (offset.x - x.toLayoutUnit * state.attrs.ppc).toInt - let offy = (offset.y - y.toLayoutUnit * state.attrs.ppl).toInt - state.images.add(PosBitmap( - x: x, - y: y, - offx: offx, - offy: offy, - width: atom.size.w.toInt, - height: atom.size.h.toInt, - bmp: atom.bmp - )) + let x2p = offset.x + atom.size.w + let y2p = offset.y + atom.size.h + let clipBox = addr state.clipBoxes[^1] + #TODO implement proper image clipping + if offset.x < clipBox.send.y and offset.y < clipBox.send.y and + x2p >= clipBox.start.x and y2p >= clipBox.start.y: + let x1 = offset.x.toInt + let y1 = offset.y.toInt + let x2 = x2p.toInt + let y2 = y2p.toInt + # add StyledNode to background (but don't actually color it) + grid.paintBackground(state, defaultColor, x1, y1, x2, y2, + fragment.node, noPaint = true) + let x = (offset.x div state.attrs.ppc).toInt + let y = (offset.y div state.attrs.ppl).toInt + let offx = (offset.x - x.toLayoutUnit * state.attrs.ppc).toInt + let offy = (offset.y - y.toLayoutUnit * state.attrs.ppl).toInt + state.images.add(PosBitmap( + x: x, + y: y, + offx: offx, + offy: offy, + width: atom.size.w.toInt, + height: atom.size.h.toInt, + bmp: atom.bmp + )) if position notin PositionStaticLike and stSplitEnd in fragment.splitType: discard state.absolutePos.pop() @@ -397,6 +421,7 @@ proc renderBlockBox(grid: var FlexibleGrid; state: var RenderState; box: box, offset: offset, apos: state.absolutePos[^1], + clipBox: state.clipBox, index: zindex )) return @@ -410,6 +435,21 @@ proc renderBlockBox(grid: var FlexibleGrid; state: var RenderState; box.render.offset = offset if position notin PositionStaticLike: state.absolutePos.add(offset) + 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 in OverflowHiddenLike: + clipBox.start.x = max(offset.x, clipBox.start.x) + clipBox.send.x = min(offset.x + box.state.size.w, clipBox.send.x) + else: # scroll like + clipBox.start.x = min(offset.x, clipBox.start.x) + clipBox.send.x = max(offset.x + box.state.size.w, clipBox.start.x) + 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) if box.computed{"visibility"} == VisibilityVisible: #TODO maybe blend with the terminal background? let bgcolor = box.computed{"background-color"}.cellColor() @@ -427,24 +467,25 @@ proc renderBlockBox(grid: var FlexibleGrid; state: var RenderState; grid.paintBackground(state, bgcolor, ix, iy, iex, iey, box.node) if box.computed{"background-image"}.t == ContentImage: # ugly hack for background-image display... TODO actually display images - let s = "[img]" + const s = "[img]" let w = s.len * state.attrs.ppc - var ix = offset.x + var offset = offset if box.state.size.w < w: # text is larger than image; center it to minimize error - ix -= w div 2 - ix += box.state.size.w div 2 - let x = toInt(ix div state.attrs.ppc) - let y = toInt(offset.y div state.attrs.ppl) - if y >= 0 and x + w >= 0: - grid.setText(s, x, y, box.computed.toFormat(), box.node) + offset.x -= w div 2 + offset.x += box.state.size.w div 2 + grid.setText(state, s, offset, box.computed.toFormat(), box.node) if box.inline != nil: assert box.children.len == 0 - if box.computed{"visibility"} == VisibilityVisible: + if box.computed{"visibility"} == VisibilityVisible and + state.clipBox.start.x < state.clipBox.send.x and + state.clipBox.start.y < state.clipBox.send.y: grid.renderInlineFragment(state, box.inline, offset, rgba(0, 0, 0, 0)) else: for child in box.children: grid.renderBlockBox(state, child, offset) + if hasClipBox: + discard state.clipBoxes.pop() if position notin PositionStaticLike: discard state.absolutePos.pop() @@ -457,14 +498,17 @@ proc renderDocument*(grid: var FlexibleGrid; bgcolor: var CellColor; return var state = RenderState( absolutePos: @[offset(0, 0)], + clipBoxes: @[ClipBox(send: offset(LayoutUnit.high, LayoutUnit.high))], attrsp: attrsp, bgcolor: defaultColor ) - var stack = @[StackItem(box: rootBox)] + var stack = @[StackItem(box: rootBox, clipBox: state.clipBox)] while stack.len > 0: for it in stack: state.absolutePos.add(it.apos) + state.clipBoxes.add(it.clipBox) grid.renderBlockBox(state, it.box, it.offset, true) + discard state.clipBoxes.pop() discard state.absolutePos.pop() stack = move(state.nstack) stack.sort(proc(x, y: StackItem): int = cmp(x.index, y.index)) |