Skip to content

Commit

Permalink
Add Alignment class, refactor milestone to use it.
Browse files Browse the repository at this point in the history
  • Loading branch information
kajuberdut committed Nov 13, 2023
1 parent a6b01ff commit b924733
Show file tree
Hide file tree
Showing 4 changed files with 263 additions and 32 deletions.
121 changes: 121 additions & 0 deletions src/roadmapper/alignment.py
Original file line number Diff line number Diff line change
@@ -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")
57 changes: 27 additions & 30 deletions src/roadmapper/milestone.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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)
Expand All @@ -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(
Expand All @@ -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
112 changes: 112 additions & 0 deletions src/tests/test_alignment.py
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 3 additions & 2 deletions src/tests/test_milestone.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import pytest
from datetime import datetime

import pytest

from src.roadmapper.milestone import Milestone


Expand Down Expand Up @@ -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
Expand Down

0 comments on commit b924733

Please sign in to comment.