Repository: Textualize/textual

Issue:
Overlapping region resolver
We need a container class which manages overlapping regions. Internally it will act like a container of Region objects (probably a simple list).

Additionally there will be a method which generates a sequence of Region objects which cover the same area, but with no overlapping.

Discuss with @willmcgugan as this likely needs further explanation.


Discussion:

Resolving patch:
diff --git a/src/textual/_region_group.py b/src/textual/_region_group.py
new file mode 100644
--- /dev/null
+++ b/src/textual/_region_group.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from collections import defaultdict
+from operator import attrgetter
+from typing import NamedTuple, Iterable
+
+from .geometry import Region
+
+
+class InlineRange(NamedTuple):
+    """Represents a region on a single line."""
+
+    line_index: int
+    start: int
+    end: int
+
+
+def regions_to_ranges(regions: Iterable[Region]) -> Iterable[InlineRange]:
+    """Converts the regions to non-overlapping horizontal strips, where each strip
+    represents the region on a single line. Combining the resulting strips therefore
+    results in a shape identical to the combined original regions.
+
+    Args:
+        regions (Iterable[Region]): An iterable of Regions.
+
+    Returns:
+        Iterable[InlineRange]: Yields InlineRange objects representing the content on
+            a single line, with overlaps removed.
+    """
+    inline_ranges: dict[int, list[InlineRange]] = defaultdict(list)
+    for region_x, region_y, width, height in regions:
+        for y in range(region_y, region_y + height):
+            inline_ranges[y].append(
+                InlineRange(line_index=y, start=region_x, end=region_x + width - 1)
+            )
+
+    get_start = attrgetter("start")
+    for line_index, ranges in inline_ranges.items():
+        sorted_ranges = iter(sorted(ranges, key=get_start))
+        _, start, end = next(sorted_ranges)
+        for next_line_index, next_start, next_end in sorted_ranges:
+            if next_start <= end + 1:
+                end = max(end, next_end)
+            else:
+                yield InlineRange(line_index, start, end)
+                start = next_start
+                end = next_end
+        yield InlineRange(line_index, start, end)


Test patch:
diff --git a/tests/test_region_group.py b/tests/test_region_group.py
new file mode 100644
--- /dev/null
+++ b/tests/test_region_group.py
@@ -0,0 +1,44 @@
+from textual._region_group import regions_to_ranges, InlineRange
+from textual.geometry import Region
+
+
+def test_regions_to_ranges_no_regions():
+    assert list(regions_to_ranges([])) == []
+
+
+def test_regions_to_ranges_single_region():
+    regions = [Region(0, 0, 3, 2)]
+    assert list(regions_to_ranges(regions)) == [InlineRange(0, 0, 2), InlineRange(1, 0, 2)]
+
+
+def test_regions_to_ranges_partially_overlapping_regions():
+    regions = [Region(0, 0, 2, 2), Region(1, 1, 2, 2)]
+    assert list(regions_to_ranges(regions)) == [
+        InlineRange(0, 0, 1), InlineRange(1, 0, 2), InlineRange(2, 1, 2),
+    ]
+
+
+def test_regions_to_ranges_fully_overlapping_regions():
+    regions = [Region(1, 1, 3, 3), Region(2, 2, 1, 1), Region(0, 2, 3, 1)]
+    assert list(regions_to_ranges(regions)) == [
+        InlineRange(1, 1, 3), InlineRange(2, 0, 3), InlineRange(3, 1, 3)
+    ]
+
+
+def test_regions_to_ranges_disjoint_regions_different_lines():
+    regions = [Region(0, 0, 2, 1), Region(2, 2, 2, 1)]
+    assert list(regions_to_ranges(regions)) == [InlineRange(0, 0, 1), InlineRange(2, 2, 3)]
+
+
+def test_regions_to_ranges_disjoint_regions_same_line():
+    regions = [Region(0, 0, 1, 2), Region(2, 0, 1, 1)]
+    assert list(regions_to_ranges(regions)) == [
+        InlineRange(0, 0, 0), InlineRange(0, 2, 2), InlineRange(1, 0, 0)
+    ]
+
+
+def test_regions_to_ranges_directly_adjacent_ranges_merged():
+    regions = [Region(0, 0, 1, 2), Region(1, 0, 1, 2)]
+    assert list(regions_to_ranges(regions)) == [
+        InlineRange(0, 0, 1), InlineRange(1, 0, 1)
+    ]