Skip to content

Commit

Permalink
Absolute and Fixed positioning (#90)
Browse files Browse the repository at this point in the history
  • Loading branch information
JoshKarpel authored Jan 7, 2024
1 parent ccc3550 commit 7af6574
Show file tree
Hide file tree
Showing 21 changed files with 2,446 additions and 462 deletions.
30 changes: 21 additions & 9 deletions counterweight/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from counterweight.components import Component
from counterweight.elements import AnyElement
from counterweight.geometry import Edge, Rect
from counterweight.styles.styles import Absolute, BorderEdge
from counterweight.styles.styles import BorderEdge
from counterweight.types import ForbidExtras

logger = get_logger()
Expand Down Expand Up @@ -136,7 +136,9 @@ def first_pass(self) -> None:
if style.span.height != "auto": # i.e., if it's a fixed height
self.dims.content.height = style.span.height

num_gaps = max(sum(1 for child in self.children if child.element.style.layout.position == "relative") - 1, 0)
num_gaps = max(
sum(1 for child in self.children if child.element.style.layout.position.type == "relative") - 1, 0
)

# grow to fit children with fixed sizes
if style.span.width == "auto":
Expand All @@ -152,7 +154,7 @@ def first_pass(self) -> None:
# child_element.type == "text" and child_style.typography.wrap == "none"
# ):
if child_box.dims.content.width != 0: # i.e., it has been set
if child_layout.position == "relative":
if child_layout.position.type == "relative":
if layout.direction == "row":
# We are growing the box to the right
self.dims.content.width += child_box.dims.width()
Expand All @@ -173,7 +175,7 @@ def first_pass(self) -> None:
# child_element.type == "text" and child_style.typography.wrap == "none"
# ):
if child_box.dims.content.height != 0: # i.e., it has been set
if child_layout.position == "relative":
if child_layout.position.type == "relative":
if layout.direction == "column":
# We are growing the box downward
self.dims.content.height += child_box.dims.height()
Expand All @@ -187,7 +189,7 @@ def second_pass(self) -> None:
parent = self.parent

# handle align self
if layout.position == "relative" and parent:
if layout.position.type == "relative" and parent:
if parent.element.style.layout.direction == "row":
match layout.align_self:
case "center":
Expand All @@ -212,12 +214,14 @@ def second_pass(self) -> None:
available_width = self.dims.content.width
available_height = self.dims.content.height

relative_children = [child for child in self.children if child.element.style.layout.position == "relative"]
relative_children = [child for child in self.children if child.element.style.layout.position.type == "relative"]
relative_children_with_weights = [
child for child in relative_children if child.element.style.layout.weight is not None
]
num_relative_children = len(relative_children)
num_gaps = max(sum(1 for child in self.children if child.element.style.layout.position == "relative") - 1, 0)
num_gaps = max(
sum(1 for child in self.children if child.element.style.layout.position.type == "relative") - 1, 0
)
total_gap = num_gaps * layout.gap_children

# subtract off fixed-width/height children from what's available to flex
Expand Down Expand Up @@ -277,8 +281,16 @@ def second_pass(self) -> None:

# determine positions

# For absolute position, override anything that the parent tried to set for us
if isinstance(layout.position, Absolute):
if layout.position.type == "relative":
# For relative position, shift anything the parent set for us by the given offsets
self.dims.content.x += layout.position.x
self.dims.content.y += layout.position.y
elif layout.position.type == "absolute" and parent:
# For absolute position, start from the parent's content box's top-left corner, then shift by the offsets
self.dims.content.x = parent.dims.content.x + layout.position.x
self.dims.content.y = parent.dims.content.y + layout.position.y
elif layout.position.type == "fixed":
# For fixed position, override anything that the parent tried to set for us
self.dims.content.x = layout.position.x
self.dims.content.y = layout.position.y

Expand Down
55 changes: 24 additions & 31 deletions counterweight/paint.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from collections import defaultdict
from itertools import groupby
from textwrap import dedent
from typing import Literal, assert_never
from xml.etree.ElementTree import Element, ElementTree, SubElement
Expand Down Expand Up @@ -280,46 +281,38 @@ def svg(paint: Paint) -> ElementTree:
},
)

# Optimization: write out long horizontal rectangles of the same background color as rectangles instead of individual cell-sized rectangles
current_bg_color = Color.from_name("black").hex
current_bg_start = 0
current_bg_width = 0
bg_spans = []

for x, cell in cells:
# black is the default background color, so don't write it (optimization)
if cell.style.background.hex == current_bg_color:
current_bg_width += 1
else:
if current_bg_color != Color.from_name("black").hex:
bg_spans.append((current_bg_start, current_bg_width, current_bg_color))
current_bg_start, current_bg_width, current_bg_color = x, 1, cell.style.background.hex

if cell.char == " ": # optimization: don't write spaces
continue

ts = SubElement(
row_tspan_root,
"tspan",
{
"x": f"{x * x_mul:{fmt}}{unit}",
},
)
if cell.style.foreground != Color.from_name("white"): # optimization: don't write white, it's the default
ts.attrib["fill"] = cell.style.foreground.hex
ts.text = cell.char
for bg_color, x_cells_group in groupby(cells, key=lambda x_cell: x_cell[1].style.background.hex):
# Optimization: write out long horizontal rectangles of the same background color as rectangles instead of individual cell-sized rectangles
x_cells = tuple(x_cells_group)
first_x, first_cell = x_cells[0]

for bg_start, bg_width, bg_color in bg_spans:
SubElement(
background_root,
"rect",
{
"x": f"{bg_start * x_mul:{fmt}}{unit}",
"x": f"{first_x * x_mul:{fmt}}{unit}",
"y": f"{y * y_mul:{fmt}}{unit}",
"width": f"{bg_width * x_mul:{fmt}}{unit}",
"width": f"{len(x_cells) * x_mul:{fmt}}{unit}",
"height": f"{1 * y_mul:{fmt}}{unit}",
"fill": bg_color,
},
)

for x, cell in x_cells:
if cell.char == " ": # optimization: don't write spaces
continue

ts = SubElement(
row_tspan_root,
"tspan",
{
"x": f"{x * x_mul:{fmt}}{unit}",
},
)
if cell.style.foreground != Color.from_name(
"white"
): # optimization: don't write white, it's the default
ts.attrib["fill"] = cell.style.foreground.hex
ts.text = cell.char

return ElementTree(element=root)
16 changes: 12 additions & 4 deletions counterweight/styles/__init__.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
from counterweight.styles.styles import (
Absolute,
Border,
BorderEdge,
BorderKind,
CellStyle,
Color,
Fixed,
Flex,
Margin,
Padding,
Relative,
Span,
Style,
Typography,
)

__all__ = [
"Style",
"Flex",
"Absolute",
"Border",
"BorderEdge",
"BorderKind",
"CellStyle",
"Color",
"Fixed",
"Flex",
"Margin",
"Padding",
"Relative",
"Span",
"Style",
"Typography",
"Color",
"CellStyle",
]
80 changes: 64 additions & 16 deletions counterweight/styles/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@

from enum import Enum
from functools import cached_property, lru_cache
from typing import TYPE_CHECKING, Literal, NamedTuple, TypeVar
from typing import Literal, NamedTuple, TypeVar

from cachetools import LRUCache
from pydantic import Field, NonNegativeInt, PositiveInt

from counterweight.types import FrozenForbidExtras

if TYPE_CHECKING:
pass

S = TypeVar("S", bound="StyleFragment")


Expand Down Expand Up @@ -62,21 +59,38 @@ def __or__(self: S, other: S | None) -> S:
STYLE_MERGE_CACHE[key] = merged
return merged

def mergeable_dump(self) -> dict[str, object]:
d = super().model_dump(exclude_unset=True)

# Always include the "type" field if present,
# even if it was not set (important for style merging).
if "type" in self.__dict__:
d["type"] = self.__dict__["type"]

return d

@cached_property
def _cached_hash(self) -> int:
"""This is safe because all style fragments are immutable."""
return hash(self)

def mergeable_dump(self) -> dict[str, object]:
diff: dict[str, object] = {}

for field_name, value in self:
field = self.model_fields[field_name]
field_default = field.default

if field_name not in self.model_fields_set:
continue

if isinstance(value, StyleFragment):
if isinstance(field_default, StyleFragment):
# Value and default are both fragments
sub_diff = value.mergeable_dump()
if isinstance(field.discriminator, str):
sub_diff[field.discriminator] = getattr(value, field.discriminator)
if sub_diff:
diff[field_name] = sub_diff
else:
# Value is a fragment, default is a primitive
diff[field_name] = value.mergeable_dump()
else:
# Not a fragment, so it's a primitive
diff[field_name] = value

return diff


class Color(NamedTuple):
red: int
Expand Down Expand Up @@ -390,6 +404,9 @@ class BorderKind(Enum):
right_bottom="*",
)

def __repr__(self) -> str:
return f"BorderKind.{self.name}"


class JoinedBorderParts(NamedTuple):
vertical: str
Expand Down Expand Up @@ -543,6 +560,9 @@ class JoinedBorderKind(Enum):
horizontal_vertical="╬",
)

def __repr__(self) -> str:
return f"JoinedBorderKind.{self.name}"


class BorderEdge(Enum):
Top = "top"
Expand All @@ -553,7 +573,7 @@ class BorderEdge(Enum):

class Border(StyleFragment):
kind: BorderKind = Field(default=BorderKind.Light)
style: CellStyle = Field(default_factory=CellStyle)
style: CellStyle = Field(default=CellStyle())
edges: frozenset[BorderEdge] = frozenset({BorderEdge.Top, BorderEdge.Bottom, BorderEdge.Left, BorderEdge.Right})
contract: int = Field(default=0)

Expand Down Expand Up @@ -585,15 +605,43 @@ class Typography(StyleFragment):
wrap: Literal["none", "paragraphs"] = "none"


class Relative(StyleFragment):
"""
Relative positioning is relative to the parent element's content box.
Elements occupy space and are laid out next to their siblings according
to the parent's layout direction.
"""

type: Literal["relative"] = "relative"
x: int = 0
y: int = 0


class Absolute(StyleFragment):
"""
Absolute positioning is relative to the parent element's content box,
but the element does not occupy space.
"""

type: Literal["absolute"] = "absolute"
x: int = 0
y: int = 0


class Fixed(StyleFragment):
"""
Fixed positioning is relative to the screen's top-left corner `(0, 0)`.
"""

type: Literal["fixed"] = "fixed"
x: int = 0
y: int = 0


class Flex(StyleFragment):
type: Literal["flex"] = "flex"
direction: Literal["row", "column"] = "row"
position: Literal["relative"] | Absolute = "relative"
position: Relative | Absolute | Fixed = Field(default=Relative(), discriminator="type")
weight: PositiveInt | None = 1
align_self: Literal["none", "start", "center", "end", "stretch"] = "none"
justify_children: Literal[
Expand Down
15 changes: 13 additions & 2 deletions counterweight/styles/utilities.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
from counterweight.styles import Margin, Style
from counterweight.styles.styles import (
from counterweight.styles import (
Absolute,
Border,
BorderEdge,
BorderKind,
CellStyle,
Color,
Fixed,
Flex,
Margin,
Padding,
Relative,
Style,
Typography,
)

Expand Down Expand Up @@ -1958,5 +1961,13 @@
# Stop generated


def relative(x: int = 0, y: int = 0) -> Style:
return Style(layout=Flex(position=Relative(x=x, y=y)))


def absolute(x: int = 0, y: int = 0) -> Style:
return Style(layout=Flex(position=Absolute(x=x, y=y)))


def fixed(x: int = 0, y: int = 0) -> Style:
return Style(layout=Flex(position=Fixed(x=x, y=y)))
Loading

0 comments on commit 7af6574

Please sign in to comment.