Repository: Textualize/textual
Pull request: Regions
Closes #315 

Problem statement:
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.

Hints from issue discussion:

Commit message: Ensure adjacent ranges are merged in RegionGroup

Patch to review:
diff --git a/.gitignore b/.gitignore
index bc0dfdb715..a205487359 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
 .pytype
 .DS_Store
 .vscode
+.idea
 mypy_report
 docs/build
 docs/source/_build
diff --git a/src/textual/_region_group.py b/src/textual/_region_group.py
new file mode 100644
index 0000000000..bb2e833284
--- /dev/null
+++ b/src/textual/_region_group.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+from collections import defaultdict
+from operator import attrgetter
+from typing import NamedTuple, Iterable
+
+from src.textual.geometry import Region
+
+
+class InlineRange(NamedTuple):
+    """Represents a region on a single line."""
+
+    line_index: int
+    start: int
+    end: int
+
+
+class RegionGroup:
+    """Container which wraps regions and offers utility operations over them.
+
+    Args:
+        regions (Iterable[Region]): An iterable of Regions.
+    """
+
+    def __init__(self, regions: Iterable[Region]) -> None:
+        self.regions = regions
+
+    def inline_ranges(self) -> 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.
+
+        Returns:
+            Iterable[InlineRange]: Yields InlineRange objects representing the content on
+                a single line, with overlaps removed.
+        """
+        if not self.regions:
+            return
+
+        inline_ranges: dict[int, list[InlineRange]] = defaultdict(list)
+        for region_x, region_y, width, height in self.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)
+                )
+
+        for line_index, ranges in inline_ranges.items():
+            sorted_ranges = sorted(ranges, key=attrgetter("start"))
+            _, start, end = sorted_ranges[0]
+            for next_line_index, next_start, next_end in sorted_ranges[1:]:
+                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)
diff --git a/tests/test_region_group.py b/tests/test_region_group.py
new file mode 100644
index 0000000000..bf1be99a02
--- /dev/null
+++ b/tests/test_region_group.py
@@ -0,0 +1,44 @@
+from textual._region_group import RegionGroup, InlineRange
+from textual.geometry import Region
+
+
+def test_inline_ranges_no_regions():
+    assert list(RegionGroup([]).inline_ranges()) == []
+
+
+def test_inline_ranges_single_region():
+    regions = [Region(0, 0, 3, 2)]
+    assert list(RegionGroup(regions).inline_ranges()) == [InlineRange(0, 0, 2), InlineRange(1, 0, 2)]
+
+
+def test_inline_ranges_partially_overlapping_regions():
+    regions = [Region(0, 0, 2, 2), Region(1, 1, 2, 2)]
+    assert list(RegionGroup(regions).inline_ranges()) == [
+        InlineRange(0, 0, 1), InlineRange(1, 0, 2), InlineRange(2, 1, 2),
+    ]
+
+
+def test_inline_ranges_fully_overlapping_regions():
+    regions = [Region(1, 1, 3, 3), Region(2, 2, 1, 1), Region(0, 2, 3, 1)]
+    assert list(RegionGroup(regions).inline_ranges()) == [
+        InlineRange(1, 1, 3), InlineRange(2, 0, 3), InlineRange(3, 1, 3)
+    ]
+
+
+def test_inline_ranges_disjoint_regions_different_lines():
+    regions = [Region(0, 0, 2, 1), Region(2, 2, 2, 1)]
+    assert list(RegionGroup(regions).inline_ranges()) == [InlineRange(0, 0, 1), InlineRange(2, 2, 3)]
+
+
+def test_inline_ranges_disjoint_regions_same_line():
+    regions = [Region(0, 0, 1, 2), Region(2, 0, 1, 1)]
+    assert list(RegionGroup(regions).inline_ranges()) == [
+        InlineRange(0, 0, 0), InlineRange(0, 2, 2), InlineRange(1, 0, 0)
+    ]
+
+
+def test_inline_ranges_directly_adjacent_ranges_merged():
+    regions = [Region(0, 0, 1, 2), Region(1, 0, 1, 2)]
+    assert list(RegionGroup(regions).inline_ranges()) == [
+        InlineRange(0, 0, 1), InlineRange(1, 0, 1)
+    ]


Code review comments:
diff_hunk: @@ -0,0 +1,57 @@
+from __future__ import annotations
+
+from collections import defaultdict
+from operator import attrgetter
+from typing import NamedTuple, Iterable
+
+from src.textual.geometry import Region
+
+
+class InlineRange(NamedTuple):
+    """Represents a region on a single line."""
+
+    line_index: int
+    start: int
+    end: int
+
+
+class RegionGroup:
path: src/textual/_region_group.py
text: @user1:
On reflection this probably doesn't need to be a class. All we're really interested in is that `inline_ranges` method, which we could extract in to a function that accepts an Iterable of Regions and returns InlineRanges.

We can probably rename it in that case. If it were pixels I'd be tempted to call it `rasterize_regions`. There may be a better term when it comes to characters.



@author:
Yeah I had the same thought, agreed!

@author:
Coming up empty for better names for the function - let me know if you have anything.

@user1:
`resolve_regions_to_ranges` ?