Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Layout Resolver enhancement #295

Merged
merged 17 commits into from
Feb 21, 2022
Merged
73 changes: 41 additions & 32 deletions src/textual/_layout_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import sys
from fractions import Fraction
from typing import cast, List, Optional, Sequence
from typing import cast, Sequence

if sys.version_info >= (3, 8):
from typing import Protocol
Expand All @@ -13,12 +13,15 @@
class Edge(Protocol):
"""Any object that defines an edge (such as Layout)."""

size: Optional[int] = None
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
fraction: int = 1
min_size: int = 1
# Size of edge in cells, or None for no fixed size
size: int | None
# Portion of flexible space to use if size is None
fraction: int
# Minimim size for edge, in cells
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
min_size: int


def layout_resolve(total: int, edges: Sequence[Edge]) -> List[int]:
def layout_resolve(total: int, edges: Sequence[Edge]) -> list[int]:
"""Divide total space to satisfy size, fraction, and min_size, constraints.

The returned list of integers should add up to total in most cases, unless it is
Expand All @@ -29,52 +32,58 @@ def layout_resolve(total: int, edges: Sequence[Edge]) -> List[int]:

Args:
total (int): Total number of characters.
edges (List[Edge]): Edges within total space.
edges (Sequence[Edge]): Edges within total space.

Returns:
List[int]: Number of characters for each edge.
list[int]: Number of characters for each edge.
"""
# Size of edge or None for yet to be determined
sizes = [(edge.size or None) for edge in edges]

_Fraction = Fraction
if None not in sizes:
# No flexible edges
return cast("list[int]", sizes)

# While any edges haven't been calculated
while None in sizes:
# Get flexible edges and index to map these back on to sizes list
flexible_edges = [
(index, edge)
for index, (size, edge) in enumerate(zip(sizes, edges))
if size is None
# Get flexible edges and index to map these back on to sizes list
flexible_edges = [
(index, edge)
for index, (size, edge) in enumerate(zip(sizes, edges))
if size is None
]
# Remaining space in total
remaining = total - sum([size or 0 for size in sizes])
if remaining <= 0:
# No room for flexible edges
return [
((edge.min_size or 1) if size is None else size)
for size, edge in zip(sizes, edges)
]
# Remaining space in total
remaining = total - sum(size or 0 for size in sizes)
if remaining <= 0:
# No room for flexible edges
return [
((edge.min_size or 1) if size is None else size)
for size, edge in zip(sizes, edges)
]

# Get the total fraction value for all flexible edges
total_flexible = sum([(edge.fraction or 1) for _, edge in flexible_edges])
while flexible_edges:
# Calculate number of characters in a ratio portion
portion = _Fraction(
remaining, sum((edge.fraction or 1) for _, edge in flexible_edges)
)
portion = Fraction(remaining, total_flexible)

# If any edges will be less than their minimum, replace size with the minimum
for index, edge in flexible_edges:
if portion * edge.fraction <= edge.min_size:
for flexible_index, (index, edge) in enumerate(flexible_edges):
if portion * edge.fraction < edge.min_size:
# This flexible edge will be smaller than its minimum size
# We need to fix the size and redistribute the outstanding space
sizes[index] = edge.min_size
remaining -= edge.min_size
total_flexible -= edge.fraction or 1
del flexible_edges[flexible_index]
# New fixed size will invalidate calculations, so we need to repeat the process
break
else:
# Distribute flexible space and compensate for rounding error
# Since edge sizes can only be integers we need to add the remainder
# to the following line
remainder = _Fraction(0)
remainder = Fraction(0)
for index, edge in flexible_edges:
size, remainder = divmod(portion * edge.fraction + remainder, 1)
sizes[index] = size
sizes[index], remainder = divmod(portion * edge.fraction + remainder, 1)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like how this loop turned out..

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Me too :)

break

# Sizes now contains integers only
return cast(List[int], sizes)
return cast("list[int]", sizes)
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
100 changes: 100 additions & 0 deletions tests/test_layout_resolve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from __future__ import annotations

from typing import NamedTuple

import pytest
from textual._layout_resolve import layout_resolve


class Edge(NamedTuple):
size: int | None = None
fraction: int = 1
min_size: int = 1


def test_empty():
assert layout_resolve(10, []) == []


def test_total_zero():
assert layout_resolve(0, [Edge(10)]) == [10]


def test_single():
# One edge fixed size
assert layout_resolve(100, [Edge(10)]) == [10]
# One edge fraction of 1
assert layout_resolve(100, [Edge(None, 1)]) == [100]
# One edge fraction 3
assert layout_resolve(100, [Edge(None, 2)]) == [100]
# One edge, fraction1, min size 20
assert layout_resolve(100, [Edge(None, 1, 20)]) == [100]
# One edge fraction 1, min size 120
assert layout_resolve(100, [Edge(None, 1, 120)]) == [120]


def test_two():
# Two edges fixed size
assert layout_resolve(100, [Edge(10), Edge(20)]) == [10, 20]
# Two edges, fixed size of one exceeds total
assert layout_resolve(100, [Edge(120), Edge(None, 1)]) == [120, 1]
# Two edges, fraction 1 each
assert layout_resolve(100, [Edge(None, 1), Edge(None, 1)]) == [50, 50]
# Two edges, one with fraction 2, one with fraction 1
# Note first value is rounded down, second is rounded up
assert layout_resolve(100, [Edge(None, 2), Edge(None, 1)]) == [66, 34]
# Two edges, both with fraction 2
assert layout_resolve(100, [Edge(None, 2), Edge(None, 2)]) == [50, 50]
# Two edges, one with fraction 3, one with fraction 1
assert layout_resolve(100, [Edge(None, 3), Edge(None, 1)]) == [75, 25]
# Two edges, one with fraction 3, one with fraction 1, second with min size of 30
assert layout_resolve(100, [Edge(None, 3), Edge(None, 1, 30)]) == [70, 30]
# Two edges, one with fraction 1 and min size 30, one with fraction 3
assert layout_resolve(100, [Edge(None, 1, 30), Edge(None, 3)]) == [30, 70]


@pytest.mark.parametrize(
"size, edges, result",
[
(10, [Edge(8), Edge(None, 0, 2), Edge(4)], [8, 2, 4]),
(10, [Edge(None, 1), Edge(None, 1), Edge(None, 1)], [3, 3, 4]),
(10, [Edge(5), Edge(None, 1), Edge(None, 1)], [5, 2, 3]),
(10, [Edge(None, 2), Edge(None, 1), Edge(None, 1)], [5, 2, 3]),
(10, [Edge(None, 2), Edge(3), Edge(None, 1)], [4, 3, 3]),
(
10,
[Edge(None, 2), Edge(None, 1), Edge(None, 1), Edge(None, 1)],
[4, 2, 2, 2],
),
(
10,
[Edge(None, 4), Edge(None, 1), Edge(None, 1), Edge(None, 1)],
[5, 2, 1, 2],
),
(2, [Edge(None, 1), Edge(None, 1), Edge(None, 1)], [1, 1, 1]),
(
2,
[
Edge(None, 1, min_size=5),
Edge(None, 1, min_size=4),
Edge(None, 1, min_size=3),
],
[5, 4, 3],
),
(
18,
[
Edge(None, 1, min_size=1),
Edge(3),
Edge(None, 1, min_size=1),
Edge(4),
Edge(None, 1, min_size=1),
Edge(5),
Edge(None, 1, min_size=1),
],
[1, 3, 2, 4, 1, 5, 2],
),
],
)
def test_multiple(size, edges, result):
assert layout_resolve(size, edges) == result