diff --git a/docs/authoring.md b/docs/authoring.md index c8e3be6..b11050d 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -104,7 +104,7 @@ Container: Per child (set on the child dict, under a `layout:` sub-dict to avoid clashing with the element's own keys): -- `layout: { grow: 1 }` — take a share of leftover main-axis space (push siblings apart) +- `layout: { grow: 1 }` — take a share of leftover main-axis space (push siblings apart); a card (a nested stack with `background`/`outline` and no `width`/`height` on the main axis) grows its box to fill its share - `layout: { align: center }` — override the container's cross-axis alignment for this child - `layout: { margin: 4 }` or `margin_x` / `margin_top` / ... — extra space around this child (may be negative to nudge) diff --git a/docs/reference.md b/docs/reference.md index 9eabc3f..4e804b5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -26,7 +26,7 @@ See [`elements.md`](elements.md) for a rendered YAML example of every element an | `dither` | bool \| 0/1 \| method name | | Per-element palette mapping: `true`/`false` (also `1`/`0` or a template string such as `"False"`) or a dither method name; overrides the render-wide setting for this element only. `null` = no override | | `class` | string | | Tailwind-like layout classes (`gap-2 items-center grow -ml-1 ...`), read by an enclosing stack/row/column | | `layout` | object | | Explicit per-child layout hints for an enclosing stack (same keys as the `class` shorthand) | -| `layout.grow` | number | `0` | Share of leftover main-axis space this child takes (flex-grow) | +| `layout.grow` | number | `0` | Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it | | `layout.align` | `start` \| `end` \| `center` \| `stretch` | | Cross-axis alignment for this child only | | `layout.self` | `start` \| `end` \| `center` \| `stretch` | | Alias of `align` | | `layout.margin` | number | | All four margins (px) | diff --git a/schema/elements.json b/schema/elements.json index b6038fb..439e774 100644 --- a/schema/elements.json +++ b/schema/elements.json @@ -522,7 +522,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -730,7 +730,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -982,7 +982,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -1232,7 +1232,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -1468,7 +1468,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -1703,7 +1703,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -1951,7 +1951,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -2388,7 +2388,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -2618,7 +2618,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -2880,7 +2880,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -3085,7 +3085,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -3288,7 +3288,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -3495,7 +3495,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -3855,7 +3855,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -4211,7 +4211,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -4423,7 +4423,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -4651,7 +4651,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -4865,7 +4865,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -5077,7 +5077,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -5310,7 +5310,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -5503,7 +5503,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -5700,7 +5700,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -5913,7 +5913,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -6099,7 +6099,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -6351,7 +6351,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -6630,7 +6630,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -6856,7 +6856,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -7088,7 +7088,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -7452,7 +7452,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -7728,7 +7728,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -7980,7 +7980,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -8281,7 +8281,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -8580,7 +8580,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -8855,7 +8855,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -9069,7 +9069,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { @@ -9349,7 +9349,7 @@ "number", "null" ], - "description": "Share of leftover main-axis space this child takes (flex-grow)", + "description": "Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with `background`/`outline` and no main-axis size) grows its box to fill it", "default": 0 }, "align": { diff --git a/src/imagespec/elements/layout.py b/src/imagespec/elements/layout.py index 7878ac4..c1e3be3 100644 --- a/src/imagespec/elements/layout.py +++ b/src/imagespec/elements/layout.py @@ -148,6 +148,18 @@ def _cross_extent(tile: dict, horizontal: bool) -> int: return (tile["h"] if horizontal else tile["w"]) + _cross_margins(tile, horizontal) +def _fills_slot(child: dict, main_key: str) -> bool: + """A card (nested stack with ``background``/``outline``) with no explicit main-axis size: `grow` makes + its box fill the slot. Unstyled stacks draw nothing for their empty space, so there is nothing to fill.""" + styled = child.get("background") is not None or child.get("outline") is not None + if not (styled and child.get("type") in ("stack", "row", "column") and child.get(main_key) is None): + return False + try: # a 90/270 rotation swaps the axes, so width/height would grow the wrong way + return int(float(child.get("rotate") or 0)) % 180 == 0 + except (TypeError, ValueError, OverflowError): + return False + + # Stretchable elements that can be rendered without a cross size (their width/height are optional). _MEASURABLE = frozenset({"group", "stack", "row", "column"}) @@ -351,10 +363,18 @@ def positioned(child): return child return {**child, **{k: 0 for k in ("x", "y") if child.get(k) is None}} - def render_child(idx, child, lay, stretch_size): + main_key = "width" if horizontal else "height" + + def render_child(idx, child, lay, stretch_size, main_size=None): """Render ``child`` on its own layer and crop it to its drawn extent: ``lay`` + img/w/h.""" eff = positioned(child) sub_w, sub_h = max(1, inner_w), max(1, inner_h) + if main_size is not None: # a growing card: its box is the whole slot along the main axis + eff = {**eff, main_key: main_size, ("x" if horizontal else "y"): 0} # own main coordinate is ignored + if horizontal: + sub_w = max(sub_w, main_size) + else: + sub_h = max(sub_h, main_size) if stretch_size is not None: # the child's own cross coordinate is ignored (like any stack child); a negative margin # can make the slot larger than the stack, so give the layer room for it @@ -383,7 +403,7 @@ def render_child(idx, child, lay, stretch_size): bbox = (bbox[0], 0, bbox[2], stretch_size) if horizontal else (0, bbox[1], stretch_size, bbox[3]) tile = rendered.crop(bbox) if bbox else None tw, th = tile.size if tile else (0, 0) - return {**lay, "img": tile, "w": tw, "h": th} + return {**lay, "img": tile, "w": tw, "h": th, "stretch": stretch_size} lays = [_child_layout(c) for c in children] # `align: stretch`: size-aware children without an explicit cross size fill the cross axis. @@ -464,6 +484,14 @@ def m_cross_trail(t): if last is not None: grow_extra[last] += int(free) - handed # rounding remainder free = 0 + # A growing card (styled nested stack without its own main size) fills its slot: its box, + # not just the empty space after it, takes the extra. + for i, (c, t) in enumerate(zip(children, tiles, strict=True)): + if grow_extra[i] > 0 and _fills_slot(c, main_key): + target = main_of(t) + grow_extra[i] + tiles[i] = render_child(i, c, t, t["stretch"], target) + # whatever the box did not take (e.g. an invisible fill) stays as slot space + grow_extra[i] = max(0, target - main_of(tiles[i])) leading, spacing = _justify_offsets(justify, free, n, gap) diff --git a/src/imagespec/spec.py b/src/imagespec/spec.py index 6d65e33..41f2557 100644 --- a/src/imagespec/spec.py +++ b/src/imagespec/spec.py @@ -180,7 +180,12 @@ def elements( # Read by an enclosing stack/row/column from each child (see layout._child_layout). LAYOUT_FIELDS: tuple[Field, ...] = ( - num("grow", 0, doc="Share of leftover main-axis space this child takes (flex-grow)"), + num( + "grow", + 0, + doc="Share of leftover main-axis space this child takes (flex-grow); a card (a nested stack with " + "`background`/`outline` and no main-axis size) grows its box to fill it", + ), enum("align", ("start", "end", "center", "stretch"), doc="Cross-axis alignment for this child only"), enum("self", ("start", "end", "center", "stretch"), doc="Alias of `align`"), num("margin", doc="All four margins (px)"), diff --git a/tests/test_layout.py b/tests/test_layout.py index 66ae677..ff573b4 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -674,3 +674,133 @@ def test_card_of_only_size_required_stretched_children_falls_back_to_the_availab img = render([card], 60, 50, context=ctx) ys = [y for y in range(50) if img.getpixel((6, y)) == BLACK] assert min(ys) == 5 and max(ys) == 49 + + +# --------------------------------------------------------------------------- # +# grow on cards +# --------------------------------------------------------------------------- # + + +def _xs(img, color, y): + return [x for x in range(img.width) if img.getpixel((x, y)) == color] + + +def test_growing_card_fills_the_free_space(ctx): + row = { + "type": "row", + "width": 120, + "height": 30, + "gap": 6, + "elements": [_card("A", "yellow"), _card("B", "red", layout={"grow": 1})], + } + img = render([row], 120, 30, context=ctx) + red = _xs(img, RED, 2) + assert max(red) == 119 # the red card runs to the end of the row + yellow = _xs(img, YELLOW, 2) + assert min(red) - max(yellow) - 1 == 6 # the gap is untouched + + +def test_growing_cards_share_the_free_space_by_weight(ctx): + def widths(grows): + els = [_card("A", "yellow", layout={"grow": grows[0]}), _card("B", "red", layout={"grow": grows[1]})] + img = render([{"type": "row", "width": 150, "height": 30, "elements": els}], 150, 30, context=ctx) + return len(_xs(img, YELLOW, 2)), len(_xs(img, RED, 2)), img + + natural_y, natural_r, _ = widths((0, 0)) + y, r, img = widths((1, 2)) + extra_y, extra_r = y - natural_y, r - natural_r + assert extra_y > 0 and abs(extra_r - 2 * extra_y) <= 2 # the free space is split 1:2 + assert max(_xs(img, RED, 2)) == 149 # and all of it is used + + +def test_growing_card_in_a_column_fills_the_height(ctx): + col = { + "type": "column", + "width": 60, + "height": 90, + "gap": 4, + "elements": [_card("A", "yellow"), _card("B", "red", layout={"grow": 1})], + } + img = render([col], 60, 90, context=ctx) + red_rows = [y for y in range(90) if img.getpixel((2, y)) == RED] + assert max(red_rows) == 89 + + +def test_growing_card_with_an_explicit_size_keeps_it(ctx): + row = {"type": "row", "width": 120, "height": 30, "elements": [_card("A", "red", width=40, layout={"grow": 1})]} + img = render([row], 120, 30, context=ctx) + assert max(_xs(img, RED, 2)) == 39 + + +def test_unstyled_growing_stack_is_unchanged(ctx): + inner = {"type": "row", "elements": [{"type": "text", "value": "A", "size": 12}], "layout": {"grow": 1}} + row = {"type": "row", "width": 100, "height": 20, "elements": [inner, {"type": "text", "value": "Z", "size": 12}]} + plain = {**row, "elements": [{k: v for k, v in inner.items() if k != "layout"}, row["elements"][1]]} + a = render([row], 100, 20, context=ctx) + b = render([plain], 100, 20, context=ctx) + xs = lambda im: [x for x in range(100) if any(im.getpixel((x, y)) == BLACK for y in range(20))] # noqa: E731 + assert max(xs(a)) > max(xs(b)) # grow still pushes the sibling to the end, as before + + +def test_growing_card_respects_margins(ctx): + row = { + "type": "row", + "width": 100, + "height": 30, + "elements": [_card("B", "red", layout={"grow": 1, "margin_x": 5})], + } + img = render([row], 100, 30, context=ctx) + red = _xs(img, RED, 2) + assert min(red) == 5 and max(red) == 94 + + +def test_growing_card_rotated_90_is_left_alone(ctx): + row = { + "type": "row", + "width": 120, + "height": 60, + "elements": [_card("R", "red", rotate=90, layout={"grow": 1}), _card("Z", "yellow")], + } + base = {**row, "elements": [_card("R", "red", rotate=90), _card("Z", "yellow")]} + img, ref = render([row], 120, 60, context=ctx), render([base], 120, 60, context=ctx) + assert len(_xs(img, RED, 2)) == len(_xs(ref, RED, 2)) # unchanged size + assert min(_xs(img, YELLOW, 2)) > min(_xs(ref, YELLOW, 2)) # grow still pushes the sibling to the end + + +@pytest.mark.parametrize("pos", [{"x": 30}, {"x": -10}, {"y": 20}]) +def test_growing_card_ignores_its_own_coordinates(ctx, pos): + row = { + "type": "row", + "width": 120, + "height": 30, + "gap": 6, + "elements": [_card("A", "yellow"), _card("B", "red", layout={"grow": 1}, **pos)], + } + img = render([row], 120, 30, context=ctx) + assert max(_xs(img, RED, 2)) == 119 + + +def test_growing_card_with_an_invisible_box_still_pushes_its_sibling(ctx): + ghost = { + "type": "row", + "outline": "black", + "width_outline": 0, + "elements": [{"type": "text", "value": "g", "size": 12}], + "layout": {"grow": 1}, + } + row = {"type": "row", "width": 100, "height": 20, "elements": [ghost, _card("Z", "yellow")]} + img = render([row], 100, 20, context=ctx) + assert max(_xs(img, YELLOW, 2)) >= 98 # the slot was kept, so the sibling sits at the end + + +@pytest.mark.parametrize("y", [20, -10]) +def test_growing_card_in_a_column_ignores_its_own_main_coordinate(ctx, y): + col = { + "type": "column", + "width": 60, + "height": 90, + "gap": 4, + "elements": [_card("A", "yellow"), _card("B", "red", y=y, layout={"grow": 1})], + } + img = render([col], 60, 90, context=ctx) + assert max(y for y in range(90) if img.getpixel((2, y)) == RED) == 89