From b9247330348f8b64687b1b26753dd9d3c9a5ac04 Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sun, 12 Nov 2023 22:41:56 -0700 Subject: [PATCH] Add Alignment class, refactor milestone to use it. --- src/roadmapper/alignment.py | 121 ++++++++++++++++++++++++++++++++++++ src/roadmapper/milestone.py | 57 ++++++++--------- src/tests/test_alignment.py | 112 +++++++++++++++++++++++++++++++++ src/tests/test_milestone.py | 5 +- 4 files changed, 263 insertions(+), 32 deletions(-) create mode 100644 src/roadmapper/alignment.py create mode 100644 src/tests/test_alignment.py diff --git a/src/roadmapper/alignment.py b/src/roadmapper/alignment.py new file mode 100644 index 0000000..e42a299 --- /dev/null +++ b/src/roadmapper/alignment.py @@ -0,0 +1,121 @@ +from dataclasses import asdict, astuple, dataclass +from enum import Enum, EnumMeta +from typing import Optional, Tuple, Union + +StrOrAlignment = Union[str, "Alignment"] + + +class CaseInsensitiveEnumMeta(EnumMeta): + def __getitem__(self, key): + if isinstance(key, str): + key = key.upper() + return super().__getitem__(key) + + +class AlignmentDirection(Enum, metaclass=CaseInsensitiveEnumMeta): + CENTER = 1 + CENTRE = 1 + LEFT = 2 + RIGHT = 3 + + +class OffsetType(Enum, metaclass=CaseInsensitiveEnumMeta): + UNIT = 1 + PERCENT = 2 + + +@dataclass(kw_only=True) +class Alignment: + direction: AlignmentDirection = (AlignmentDirection.CENTER,) + offset_type: Optional[OffsetType] = None + offset: Optional[Union[int, float]] = None + + @classmethod + def from_value( + cls, + alignment: Optional[StrOrAlignment], + default_offset_type: Optional[OffsetType] = None, + default_offset: Optional[float] = None, + ) -> "Alignment": + if alignment is None: + return cls(offset_type=default_offset_type, offset=default_offset) + if isinstance(alignment, Alignment): + return cls.from_alignment(alignment) + if isinstance(alignment, str): + return cls.from_string(alignment) + else: + raise ValueError( + 'Invalid argument "alignment": expected None, str, or Alignment instance,' + f" got {type(alignment).__name__}." + ) + + @classmethod + def from_alignment(cls, alignment: "Alignment") -> "Alignment": + kwargs = asdict(alignment) + new = cls(**kwargs) + return new + + @classmethod + def from_string(cls, alignment: str) -> "Alignment": + new = cls() + new.update_from_alignment_string(alignment) + return new + + @staticmethod + def parse_offset(offset: str) -> Tuple[Union[int, float], OffsetType]: + if offset.endswith("%"): + return (float(offset[:-1]) / 100, OffsetType.PERCENT) + else: + return (int(offset), OffsetType.UNIT) + + def update_from_alignment_string(self, alignment: str) -> None: + parts = alignment.split(":") + + try: + self.direction = AlignmentDirection[parts[0]] + except KeyError as e: + raise ValueError( + f'Invalid alignment direction "{parts[0]}".' + f" Valid alignment directions are {[d.name for d in AlignmentDirection]}" + ) from e + + if len(parts) == 2: + self.offset, self.offset_type = self.parse_offset(parts[1]) + + def as_tuple( + self, + ) -> Tuple[AlignmentDirection, Optional[OffsetType], Optional[Union[int, float]]]: + return astuple(self) + + def percent_of(self, whole: Union[int, float]) -> float: + if self.offset_type != OffsetType.PERCENT: + raise ValueError("Cannot return percent_of when offset_type != 'PERCENT'") + return whole * self.offset + + def __str__(self): + offset_str = "" + if self.offset is not None: + offset_str = ":" + offset_str += ( + f"{self.offset * 100}%" + if self.offset_type == OffsetType.PERCENT + else str(self.offset) + ) + return f"{self.direction.name.lower()}{offset_str}" + + +if __name__ == "__main__": + result1 = Alignment.from_value("left:50%") + print(result1) + + result2 = Alignment.from_value(result1) + print(result2.as_tuple()) + + result3 = Alignment.from_value("Center") + print(result3) + + # expect ValueError about type of argument. + Alignment.from_value(1) + + # Expect ValueError about alignment direction. + Alignment.from_value("widdershins:50") diff --git a/src/roadmapper/milestone.py b/src/roadmapper/milestone.py index 5dc7e6b..1827d11 100644 --- a/src/roadmapper/milestone.py +++ b/src/roadmapper/milestone.py @@ -20,8 +20,11 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from datetime import datetime from dataclasses import dataclass, field +from datetime import datetime +from typing import Union + +from .alignment import Alignment, AlignmentDirection, OffsetType from .painter import Painter @@ -35,7 +38,7 @@ class Milestone: font_size: int = field(init=True, default=None) font_colour: str = field(init=True, default=None) fill_colour: str = field(init=True, default=None) - text_alignment: str = field(init=True, default=None) + text_alignment: Union[str, Alignment] = field(init=True, default=None) diamond_x: int = field(init=False, default=0) diamond_y: int = field(init=False, default=0) @@ -59,36 +62,13 @@ def draw(self, painter: Painter) -> None: self.fill_colour, ) - alignment = ( - self.text_alignment.lower() if self.text_alignment is not None else None + alignment = Alignment.from_value( + alignment=self.text_alignment, + default_offset_type=OffsetType.PERCENT, + default_offset=0.5, ) - if alignment is None or alignment == "centre": - pass # Text is already "centre" if we change nothing - elif "left" in alignment or "right" in alignment: # Handle left/right - text_width, _ = painter.get_text_dimension( - text=self.text, font=self.font, font_size=self.font_size - ) - - if ":" in alignment: # Split if ":" in str - alignment, modifier = alignment.split(":") - - if "%" in modifier: # If "%" treat as percent of text_width - offset = (int(modifier.strip("%")) / 100) * text_width - else: # Treat as units - offset = int(modifier) - else: # If no ":", default to half of text width - offset = 0.5 * text_width - - if alignment == "right": - self.text_x += offset - elif alignment == "left": - self.text_x -= offset - else: - raise ValueError( - 'text_alignment must be: "centre", "left", "right" or None.' - f"\n\tGot {self.text_alignment=}" - ) + self.apply_offset(alignment=alignment, painter=painter) if (self.text_x != 0) and (self.text_y != 0): painter.draw_text( @@ -99,3 +79,20 @@ def draw(self, painter: Painter) -> None: self.font_size, self.font_colour, ) + + def apply_offset(self, alignment: Alignment, painter: Painter) -> None: + direction, offset_type, offset = alignment.as_tuple() + + if direction is None or direction == AlignmentDirection.CENTER: + return # Center does not require an offset + + if offset_type == OffsetType.PERCENT: + text_width, _ = painter.get_text_dimension( + text=self.text, font=self.font, font_size=self.font_size + ) + offset = alignment.percent_of(text_width) + + if direction == AlignmentDirection.RIGHT: + self.text_x += offset + elif alignment == AlignmentDirection.LEFT: + self.text_x -= offset diff --git a/src/tests/test_alignment.py b/src/tests/test_alignment.py new file mode 100644 index 0000000..aec2a8e --- /dev/null +++ b/src/tests/test_alignment.py @@ -0,0 +1,112 @@ +import pytest + +from src.roadmapper.alignment import Alignment, AlignmentDirection, OffsetType + + +@pytest.mark.unit +def test_case_insensitivity(): + assert AlignmentDirection["center"] == AlignmentDirection.CENTER + assert AlignmentDirection["LEFT"] == AlignmentDirection.LEFT + assert OffsetType["unit"] == OffsetType.UNIT + + +@pytest.mark.unit +def test_enum_direction_synonyms(): + assert AlignmentDirection.CENTRE == AlignmentDirection.CENTER + + +@pytest.mark.unit +def test_alignment_from_string(): + alignment = Alignment.from_value("left:50%") + assert alignment.direction == AlignmentDirection.LEFT + assert alignment.offset_type == OffsetType.PERCENT + assert alignment.offset == 0.5 + + +@pytest.mark.unit +def test_from_string(): + alignment = Alignment.from_string("right:30%") + assert alignment.direction == AlignmentDirection.RIGHT + assert alignment.offset_type == OffsetType.PERCENT + assert alignment.offset == 0.3 + + +@pytest.mark.unit +def test_from_alignment(): + alignment = Alignment(direction=AlignmentDirection.LEFT) + new_alignment = Alignment.from_alignment(alignment) + assert new_alignment.direction == AlignmentDirection.LEFT + + +@pytest.mark.unit +def test_parse_offset(): + offset, offset_type = Alignment.parse_offset("50%") + assert offset == 0.5 + assert offset_type == OffsetType.PERCENT + + offset, offset_type = Alignment.parse_offset("30") + assert offset == 30 + assert offset_type == OffsetType.UNIT + + +@pytest.mark.unit +def test_update_from_alignment_string(): + alignment = Alignment() + alignment.update_from_alignment_string("centre:20%") + assert alignment.direction == AlignmentDirection.CENTRE + assert alignment.offset_type == OffsetType.PERCENT + assert alignment.offset == 0.2 + + +@pytest.mark.unit +def test_alignment_from_alignment_object(): + original_alignment = Alignment( + direction=AlignmentDirection.RIGHT, offset_type=OffsetType.UNIT, offset=10 + ) + new_alignment = Alignment.from_value(original_alignment) + assert new_alignment.direction == AlignmentDirection.RIGHT + assert new_alignment.offset_type == OffsetType.UNIT + assert new_alignment.offset == 10 + + +@pytest.mark.unit +def test_as_tuple(): + alignment = Alignment( + direction=AlignmentDirection.RIGHT, offset_type=OffsetType.UNIT, offset=15 + ) + assert alignment.as_tuple() == (AlignmentDirection.RIGHT, OffsetType.UNIT, 15) + + +@pytest.mark.unit +def test_percent_of(): + alignment = Alignment(offset_type=OffsetType.PERCENT, offset=0.5) + assert alignment.percent_of(100) == 50 + + alignment = Alignment(offset_type=OffsetType.UNIT, offset=30) + with pytest.raises(ValueError): + alignment.percent_of(100) + + +@pytest.mark.unit +def test_invalid_direction(): + with pytest.raises(ValueError): + Alignment.from_value("widdershins:50") + + +@pytest.mark.unit +def test_invalid_type(): + with pytest.raises(ValueError): + Alignment.from_value(1) + + +@pytest.mark.unit +def test_str_method(): + alignment = Alignment( + direction=AlignmentDirection.LEFT, offset_type=OffsetType.PERCENT, offset=0.25 + ) + assert str(alignment) == "left:25.0%" + + alignment = Alignment( + direction=AlignmentDirection.RIGHT, offset_type=OffsetType.UNIT, offset=10 + ) + assert str(alignment) == "right:10" diff --git a/src/tests/test_milestone.py b/src/tests/test_milestone.py index 7bedaa7..bdb2848 100644 --- a/src/tests/test_milestone.py +++ b/src/tests/test_milestone.py @@ -1,5 +1,7 @@ -import pytest from datetime import datetime + +import pytest + from src.roadmapper.milestone import Milestone @@ -60,7 +62,6 @@ def painter(): return MockPainter() -# Text test cases @pytest.mark.parametrize("text_alignment", [None, "centre"]) def test_draw_milestone_centre_alignment(milestone, painter, text_alignment): milestone.text_alignment = text_alignment