diff --git a/src/roadmapper/alignment.py b/src/roadmapper/alignment.py new file mode 100644 index 0000000..1afa774 --- /dev/null +++ b/src/roadmapper/alignment.py @@ -0,0 +1,126 @@ +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 + + def __post_init__(self): + self.validate() + + @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, + default_offset_type=default_offset_type, + default_offset=default_offset, + ) + 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, + default_offset_type: Optional[OffsetType] = None, + default_offset: Optional[float] = None, + ) -> "Alignment": + new = cls() + new.update_from_alignment_string(alignment) + if new.direction != AlignmentDirection.CENTER: + new.offset_type = new.offset_type or default_offset_type + new.offset = new.offset or default_offset + new.validate() + 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 validate(self) -> None: + if self.direction == AlignmentDirection.CENTER and self.offset: + raise ValueError( + "An offset amount cannot be specified when the direction is set to 'center'. {self}" + ) + + 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}" diff --git a/src/roadmapper/milestone.py b/src/roadmapper/milestone.py index b139952..56184a0 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) @@ -44,7 +47,6 @@ class Milestone: text_x: int = field(init=False, default=0) text_y: int = field(init=False, default=0) - def draw(self, painter: Painter) -> None: """Draw milestone @@ -59,6 +61,15 @@ def draw(self, painter: Painter) -> None: self.diamond_height, self.fill_colour, ) + + alignment = Alignment.from_value( + alignment=self.text_alignment, + default_offset_type=OffsetType.PERCENT, + default_offset=0.5, + ) + + self.apply_offset(alignment=alignment, painter=painter) + if (self.text_x != 0) and (self.text_y != 0): painter.draw_text( self.text_x, @@ -68,3 +79,19 @@ 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 and offset: + self.text_x += offset + elif direction == AlignmentDirection.LEFT and offset: + self.text_x -= offset diff --git a/src/tests/compare_generated_roadmaps_test.py b/src/tests/compare_generated_roadmaps_test.py index 4f4d5c2..206e0ee 100644 --- a/src/tests/compare_generated_roadmaps_test.py +++ b/src/tests/compare_generated_roadmaps_test.py @@ -7,6 +7,7 @@ from src.tests.roadmap_generators import roadmap_generator from src.tests.roadmap_generators.colour_theme import ColourTheme from src.tests.roadmap_generators.colour_theme_extensive import ColourThemeExtensive +from src.tests.roadmap_generators.milestone_text_alignment import MilestoneTextAlignment from src.tests.roadmap_generators.roadmap_abc import RoadmapABC dir_for_examples = "example_roadmaps" @@ -80,16 +81,16 @@ def generate_and_compare_roadmap_for_specific_platform(operating_system, roadmap class TestCompareGeneratedRoadmaps: @pytest.mark.ubuntu - @pytest.mark.parametrize("roadmap_class_to_test", [ColourThemeExtensive, ColourTheme]) + @pytest.mark.parametrize("roadmap_class_to_test", [ColourThemeExtensive, ColourTheme, MilestoneTextAlignment]) def test_compare_generated_roadmaps_on_ubuntu(self, roadmap_class_to_test, operating_system_ubuntu): generate_and_compare_roadmap_for_specific_platform(operating_system_ubuntu, roadmap_class_to_test) @pytest.mark.macos - @pytest.mark.parametrize("roadmap_class_to_test", [ColourThemeExtensive, ColourTheme]) + @pytest.mark.parametrize("roadmap_class_to_test", [ColourThemeExtensive, ColourTheme, MilestoneTextAlignment]) def test_compare_generated_roadmaps_on_macos(self, roadmap_class_to_test, operating_system_macos): generate_and_compare_roadmap_for_specific_platform(operating_system_macos, roadmap_class_to_test) @pytest.mark.windows - @pytest.mark.parametrize("roadmap_class_to_test", [ColourThemeExtensive, ColourTheme]) + @pytest.mark.parametrize("roadmap_class_to_test", [ColourThemeExtensive, ColourTheme, MilestoneTextAlignment]) def test_compare_generated_roadmaps_on_windows(self, roadmap_class_to_test, operating_system_windows): generate_and_compare_roadmap_for_specific_platform(operating_system_windows, roadmap_class_to_test) diff --git a/src/tests/example_roadmaps/MilestoneTextAlignmentMacosExample.png b/src/tests/example_roadmaps/MilestoneTextAlignmentMacosExample.png new file mode 100644 index 0000000..432c071 Binary files /dev/null and b/src/tests/example_roadmaps/MilestoneTextAlignmentMacosExample.png differ diff --git a/src/tests/example_roadmaps/MilestoneTextAlignmentUbuntuExample.png b/src/tests/example_roadmaps/MilestoneTextAlignmentUbuntuExample.png new file mode 100644 index 0000000..da0cb9d Binary files /dev/null and b/src/tests/example_roadmaps/MilestoneTextAlignmentUbuntuExample.png differ diff --git a/src/tests/example_roadmaps/MilestoneTextAlignmentWindowsExample.png b/src/tests/example_roadmaps/MilestoneTextAlignmentWindowsExample.png new file mode 100644 index 0000000..163f934 Binary files /dev/null and b/src/tests/example_roadmaps/MilestoneTextAlignmentWindowsExample.png differ diff --git a/src/tests/roadmap_generators/milestone_text_alignment.py b/src/tests/roadmap_generators/milestone_text_alignment.py new file mode 100644 index 0000000..a3d2595 --- /dev/null +++ b/src/tests/roadmap_generators/milestone_text_alignment.py @@ -0,0 +1,94 @@ +import sys + +from src.roadmapper.roadmap import Roadmap +from src.roadmapper.timelinemode import TimelineMode +from src.tests.roadmap_generators.roadmap_abc import RoadmapABC + + +class MilestoneTextAlignment(RoadmapABC): + + def generate_and_save_as( + self, + file_name: str = "example.png", + ) -> None: + roadmap: Roadmap = self.generate() + roadmap.draw() + roadmap.save(file_name) + + def generate(self) -> Roadmap: + width = 1200 + height = 1000 + auto_height = True + mode = TimelineMode.MONTHLY + start_date = "2023-01-01" + number_of_items = 12 + show_generic_dates = False + + roadmap = Roadmap(width, height, auto_height=auto_height, show_marker=False) + + roadmap.set_title("2023 Milestone Alignment") + roadmap.set_subtitle("GodZone Corporation") + + roadmap.set_timeline( + mode=mode, + start=start_date, + number_of_items=number_of_items, + show_generic_dates=show_generic_dates, + ) + + group = roadmap.add_group("Milestone Alignment", text_alignment="left") + # Group containing each version of left align milsetone text + left_task = group.add_task( + "Left align", start_date, "2023-12-31" + ) + left_task.add_milestone( + "default left", "2023-05-01", text_alignment="left" + ) + left_task.add_milestone( + "percent left", "2023-07-01", text_alignment="left:75%" + ) + left_task.add_milestone( + "units left", "2023-11-01", text_alignment="left:10" + ) + + # Group containing each version of right align milestone text + right_task = group.add_task( + "Right align", start_date, "2023-12-31" + ) + right_task.add_milestone( + "default right", "2023-02-01", text_alignment="right" + ) + right_task.add_milestone( + "percent right", "2023-05-01", text_alignment="right:75%" + ) + right_task.add_milestone( + "units right", "2023-08-01", text_alignment="right:10" + ) + + # Group containing each version of none align milestone text + centre_task = group.add_task( + "Centre align", start_date, "2023-12-31" + ) + centre_task.add_milestone( + "not specified (centre)", "2023-02-15" + ) + centre_task.add_milestone( + "default centre", "2023-08-01", text_alignment="centre" + ) + + + roadmap.set_footer("Generated by Roadmapper") + + return roadmap + + +def is_arg_given(): + return len(sys.argv) > 1 + + +if __name__ == '__main__': + if is_arg_given(): + example_name = sys.argv[1] + MilestoneTextAlignment().generate_and_save_as(file_name=example_name) + else: + MilestoneTextAlignment.generate_and_save_as() diff --git a/src/tests/roadmap_generators/roadmap_generator.py b/src/tests/roadmap_generators/roadmap_generator.py index f7be419..e8039c6 100644 --- a/src/tests/roadmap_generators/roadmap_generator.py +++ b/src/tests/roadmap_generators/roadmap_generator.py @@ -4,11 +4,12 @@ from src.tests.roadmap_generators.colour_theme import ColourTheme from src.tests.roadmap_generators.colour_theme_extensive import ColourThemeExtensive +from src.tests.roadmap_generators.milestone_text_alignment import MilestoneTextAlignment from src.tests.roadmap_generators.roadmap_abc import RoadmapABC file_ending = ".png" file_directory = "" -all_roadmaps_to_generate: [RoadmapABC] = [ColourThemeExtensive, ColourTheme] +all_roadmaps_to_generate: [RoadmapABC] = [ColourThemeExtensive, ColourTheme, MilestoneTextAlignment] def append_trailing_slash_if_necessary(directory) -> str: diff --git a/src/tests/test_alignment.py b/src/tests/test_alignment.py new file mode 100644 index 0000000..93b1401 --- /dev/null +++ b/src/tests/test_alignment.py @@ -0,0 +1,122 @@ +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_invalid_center_with_offset(): + with pytest.raises(ValueError): + Alignment(direction=AlignmentDirection.CENTER, offset=100) + +@pytest.mark.unit +def test_invalid_center_with_offset_from_string(): + with pytest.raises(ValueError): + Alignment.from_string("center:80%") + + +@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 new file mode 100644 index 0000000..bdb2848 --- /dev/null +++ b/src/tests/test_milestone.py @@ -0,0 +1,102 @@ +from datetime import datetime + +import pytest + +from src.roadmapper.milestone import Milestone + + +class MockPainter: + def __init__(self): + self.drawn_diamond = [] + self.drawn_text = [] + + def draw_diamond(self, x, y, width, height, colour): + self.drawn_diamond.append(("diamond", x, y, width, height, colour)) + + @property + def diamond_count(self): + return len(self.drawn_diamond) + + @property + def last_diamond(self): + return self.drawn_diamond[-1] + + def draw_text(self, x, y, text, font, font_size, font_colour): + self.drawn_text.append(("text", x, y, text, font, font_size, font_colour)) + + @property + def text_count(self): + return len(self.drawn_text) + + @property + def last_text(self): + return self.drawn_text[-1] + + def get_text_dimension(self, text, font, font_size): + # Return a mock dimension. + return (100, 100) + + +@pytest.mark.unit +@pytest.fixture(scope="function") +def milestone(): + milestone = Milestone( + text="Test", + date=datetime.now(), + font="Arial", + font_size=12, + font_colour="black", + fill_colour="white", + text_alignment="centre", + ) + milestone.diamond_x = 200 + milestone.diamond_y = 200 + milestone.text_x = 200 + milestone.text_y = 200 + return milestone + + +@pytest.mark.unit +@pytest.fixture(scope="function") +def painter(): + return MockPainter() + + +@pytest.mark.parametrize("text_alignment", [None, "centre"]) +def test_draw_milestone_centre_alignment(milestone, painter, text_alignment): + milestone.text_alignment = text_alignment + milestone.draw(painter) + assert painter.text_count > 0 + assert painter.last_text == ("text", 200, 200, "Test", "Arial", 12, "black") + + +@pytest.mark.unit +@pytest.mark.parametrize( + "text_alignment, expected_x", + [("left", 150), ("Left:199", 1), ("left:90%", 110)], +) +def test_draw_milestone_left_alignment(milestone, painter, text_alignment, expected_x): + milestone.text_alignment = text_alignment + milestone.draw(painter) + assert painter.text_count > 0 + assert painter.last_text == ("text", expected_x, 200, "Test", "Arial", 12, "black") + + +@pytest.mark.unit +@pytest.mark.parametrize( + "text_alignment, expected_x", + [("right", 250), ("Right:1", 201), ("right:90%", 290)], +) +def test_draw_milestone_right_alignment(milestone, painter, text_alignment, expected_x): + milestone.text_alignment = text_alignment + milestone.draw(painter) + assert painter.text_count > 0 + assert painter.last_text == ("text", expected_x, 200, "Test", "Arial", 12, "black") + + +@pytest.mark.unit +@pytest.mark.parametrize("invalid_alignment", ["top", "bottom", "diagonal"]) +def test_invalid_text_alignment(milestone, invalid_alignment): + milestone.text_alignment = invalid_alignment + with pytest.raises(ValueError): + milestone.draw(MockPainter())