Skip to content

Commit

Permalink
Merge pull request #295 from Textualize/resolve
Browse files Browse the repository at this point in the history
Layout Resolver enhancement
  • Loading branch information
willmcgugan authored Feb 21, 2022
2 parents 9755a89 + 4638541 commit ab0c43b
Show file tree
Hide file tree
Showing 2 changed files with 144 additions and 32 deletions.
76 changes: 44 additions & 32 deletions src/textual/_layout_resolve.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

from dataclasses import dataclass
import sys
from fractions import Fraction
from typing import cast, List, Sequence, NamedTuple
from typing import cast, Sequence


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

# 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
# Minimum size for edge, in cells
min_size: int
fraction: int | None


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


def layout_resolve(total: int, edges: Sequence[EdgeProtocol]) -> List[int]:
Expand All @@ -35,52 +41,58 @@ def layout_resolve(total: int, edges: Sequence[EdgeProtocol]) -> List[int]:
Args:
total (int): Total number of characters.
edges (List[EdgeProtocol]): 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

# 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
if None not in sizes:
# No flexible edges
return cast("list[int]", 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
]
# 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)
break

# Sizes now contains integers only
return cast(List[int], sizes)
return cast("list[int]", sizes)
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

0 comments on commit ab0c43b

Please sign in to comment.