From 078fc6741d8ae462c6f44c9806839d6df9dc8f20 Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sun, 5 Nov 2023 14:07:48 -0700 Subject: [PATCH 01/18] Handle text_alignment in Milestone --- src/roadmapper/milestone.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/roadmapper/milestone.py b/src/roadmapper/milestone.py index b139952..35a0d59 100644 --- a/src/roadmapper/milestone.py +++ b/src/roadmapper/milestone.py @@ -59,6 +59,17 @@ def draw(self, painter: Painter) -> None: self.diamond_height, self.fill_colour, ) + + text_width, _ = painter.get_text_dimension(text=self.text, font=self.font, font_size=self.font_size) + width = text_width * 2 + + # Text is already "centered" if we change nothing + # For "left" and "right", move text_x by 1/2 text_width. + if self.text_alignment == "right": + self.text_x = self.text_x + (text_width/2) + elif self.text_alignment == "left": + self.text_x = self.text_x - (text_width/2) + if (self.text_x != 0) and (self.text_y != 0): painter.draw_text( self.text_x, From 4a326f19844070886408fe5ae4f9b691ce2d3f0a Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sun, 5 Nov 2023 15:47:15 -0700 Subject: [PATCH 02/18] Remove unused width variable --- src/roadmapper/milestone.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/roadmapper/milestone.py b/src/roadmapper/milestone.py index 35a0d59..a7dc25a 100644 --- a/src/roadmapper/milestone.py +++ b/src/roadmapper/milestone.py @@ -61,7 +61,6 @@ def draw(self, painter: Painter) -> None: ) text_width, _ = painter.get_text_dimension(text=self.text, font=self.font, font_size=self.font_size) - width = text_width * 2 # Text is already "centered" if we change nothing # For "left" and "right", move text_x by 1/2 text_width. From 859904ed52dbd6175356c581d618e69a654f22e8 Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sat, 11 Nov 2023 12:08:42 -0700 Subject: [PATCH 03/18] Rework milestone text alignment and add unit tests --- src/roadmapper/milestone.py | 37 ++++++++++--- src/tests/test_milstone.py | 104 ++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 src/tests/test_milstone.py diff --git a/src/roadmapper/milestone.py b/src/roadmapper/milestone.py index a7dc25a..81549a7 100644 --- a/src/roadmapper/milestone.py +++ b/src/roadmapper/milestone.py @@ -44,7 +44,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 @@ -60,14 +59,36 @@ def draw(self, painter: Painter) -> None: self.fill_colour, ) - text_width, _ = painter.get_text_dimension(text=self.text, font=self.font, font_size=self.font_size) + alignment = ( + self.text_alignment.lower() if self.text_alignment is not None else None + ) + + if alignment is None or alignment == "center": + pass # Text is already "center" 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 - # Text is already "centered" if we change nothing - # For "left" and "right", move text_x by 1/2 text_width. - if self.text_alignment == "right": - self.text_x = self.text_x + (text_width/2) - elif self.text_alignment == "left": - self.text_x = self.text_x - (text_width/2) + if alignment == "right": + self.text_x += offset + elif alignment == "left": + self.text_x -= offset + else: + raise ValueError( + 'text_alignment must be: "center", "left", "right" or None.' + f"\n\tGot {self.text_alignment=}" + ) if (self.text_x != 0) and (self.text_y != 0): painter.draw_text( diff --git a/src/tests/test_milstone.py b/src/tests/test_milstone.py new file mode 100644 index 0000000..e200f43 --- /dev/null +++ b/src/tests/test_milstone.py @@ -0,0 +1,104 @@ +import pytest +from datetime import datetime +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.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="center", + ) + milestone.diamond_x = 200 + milestone.diamond_y = 200 + milestone.text_x = 200 + milestone.text_y = 200 + return milestone + + +@pytest.fixture(scope="function") +def painter(): + return MockPainter() + + +# Text test cases +@pytest.mark.parametrize("text_alignment", [None, "center"]) +def test_draw_milestone_center_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.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.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.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()) From a6adfb4f65e0bd36ae018e1163f18fb9a8245585 Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sat, 11 Nov 2023 12:09:01 -0700 Subject: [PATCH 04/18] Add roadmap generation for milestone text_alignment --- src/tests/compare_generated_roadmaps_test.py | 7 +- .../milestone_text_alignment.py | 94 +++++++++++++++++++ .../roadmap_generators/roadmap_generator.py | 3 +- 3 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 src/tests/roadmap_generators/milestone_text_alignment.py 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/roadmap_generators/milestone_text_alignment.py b/src/tests/roadmap_generators/milestone_text_alignment.py new file mode 100644 index 0000000..8902280 --- /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 + center_task = group.add_task( + "Center align", start_date, "2023-12-31" + ) + center_task.add_milestone( + "not specified (center)", "2023-02-15" + ) + center_task.add_milestone( + "percent right", "2023-02-15", text_alignment="center" + ) + + + 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] + ColourTheme().generate_and_save_as(file_name=example_name) + else: + ColourTheme.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: From 8a61bdf67d73b2067b1532bb00cd7aff9eaa7e71 Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sat, 11 Nov 2023 13:24:02 -0700 Subject: [PATCH 05/18] Mark new unit tests for milestone --- src/tests/test_milstone.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/tests/test_milstone.py b/src/tests/test_milstone.py index e200f43..8b92b43 100644 --- a/src/tests/test_milstone.py +++ b/src/tests/test_milstone.py @@ -35,6 +35,7 @@ def get_text_dimension(self, text, font, font_size): return (100, 100) +@pytest.mark.unit @pytest.fixture(scope="function") def milestone(): milestone = Milestone( @@ -53,6 +54,7 @@ def milestone(): return milestone +@pytest.mark.unit @pytest.fixture(scope="function") def painter(): return MockPainter() @@ -67,13 +69,10 @@ def test_draw_milestone_center_alignment(milestone, painter, text_alignment): 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) - ], + [("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 @@ -81,13 +80,11 @@ def test_draw_milestone_left_alignment(milestone, painter, text_alignment, expec 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) - ], + [("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 @@ -96,7 +93,7 @@ def test_draw_milestone_right_alignment(milestone, painter, text_alignment, expe 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 From e358bb6d51ddb95b903fea94896fb2249bcded09 Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sat, 11 Nov 2023 13:38:06 -0700 Subject: [PATCH 06/18] Change center to British spelling for consistency --- src/roadmapper/milestone.py | 6 +++--- .../roadmap_generators/milestone_text_alignment.py | 10 +++++----- src/tests/test_milstone.py | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/roadmapper/milestone.py b/src/roadmapper/milestone.py index 81549a7..5dc7e6b 100644 --- a/src/roadmapper/milestone.py +++ b/src/roadmapper/milestone.py @@ -63,8 +63,8 @@ def draw(self, painter: Painter) -> None: self.text_alignment.lower() if self.text_alignment is not None else None ) - if alignment is None or alignment == "center": - pass # Text is already "center" if we change nothing + 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 @@ -86,7 +86,7 @@ def draw(self, painter: Painter) -> None: self.text_x -= offset else: raise ValueError( - 'text_alignment must be: "center", "left", "right" or None.' + 'text_alignment must be: "centre", "left", "right" or None.' f"\n\tGot {self.text_alignment=}" ) diff --git a/src/tests/roadmap_generators/milestone_text_alignment.py b/src/tests/roadmap_generators/milestone_text_alignment.py index 8902280..5a04dc8 100644 --- a/src/tests/roadmap_generators/milestone_text_alignment.py +++ b/src/tests/roadmap_generators/milestone_text_alignment.py @@ -66,14 +66,14 @@ def generate(self) -> Roadmap: ) # Group containing each version of none align milestone text - center_task = group.add_task( + centre_task = group.add_task( "Center align", start_date, "2023-12-31" ) - center_task.add_milestone( - "not specified (center)", "2023-02-15" + centre_task.add_milestone( + "not specified (centre)", "2023-02-15" ) - center_task.add_milestone( - "percent right", "2023-02-15", text_alignment="center" + centre_task.add_milestone( + "percent right", "2023-02-15", text_alignment="centre" ) diff --git a/src/tests/test_milstone.py b/src/tests/test_milstone.py index 8b92b43..7bedaa7 100644 --- a/src/tests/test_milstone.py +++ b/src/tests/test_milstone.py @@ -45,7 +45,7 @@ def milestone(): font_size=12, font_colour="black", fill_colour="white", - text_alignment="center", + text_alignment="centre", ) milestone.diamond_x = 200 milestone.diamond_y = 200 @@ -61,8 +61,8 @@ def painter(): # Text test cases -@pytest.mark.parametrize("text_alignment", [None, "center"]) -def test_draw_milestone_center_alignment(milestone, painter, text_alignment): +@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 From 07df80111216aadf6410dafb38a7f8df74008ffe Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sat, 11 Nov 2023 13:39:34 -0700 Subject: [PATCH 07/18] Fix if __main__ section. --- src/tests/roadmap_generators/milestone_text_alignment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/roadmap_generators/milestone_text_alignment.py b/src/tests/roadmap_generators/milestone_text_alignment.py index 5a04dc8..ac163ed 100644 --- a/src/tests/roadmap_generators/milestone_text_alignment.py +++ b/src/tests/roadmap_generators/milestone_text_alignment.py @@ -89,6 +89,6 @@ def is_arg_given(): if __name__ == '__main__': if is_arg_given(): example_name = sys.argv[1] - ColourTheme().generate_and_save_as(file_name=example_name) + MilestoneTextAlignment().generate_and_save_as(file_name=example_name) else: - ColourTheme.generate_and_save_as() + MilestoneTextAlignment.generate_and_save_as() From 50afc6df84b48081ff37852b34f8714670a8cb1b Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sat, 11 Nov 2023 13:49:30 -0700 Subject: [PATCH 08/18] Fix overlapping milestones in example --- src/tests/roadmap_generators/milestone_text_alignment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/roadmap_generators/milestone_text_alignment.py b/src/tests/roadmap_generators/milestone_text_alignment.py index ac163ed..df5054b 100644 --- a/src/tests/roadmap_generators/milestone_text_alignment.py +++ b/src/tests/roadmap_generators/milestone_text_alignment.py @@ -73,7 +73,7 @@ def generate(self) -> Roadmap: "not specified (centre)", "2023-02-15" ) centre_task.add_milestone( - "percent right", "2023-02-15", text_alignment="centre" + "percent right", "2023-08-01", text_alignment="centre" ) From 6a2177b67acefe2d427afc676b1bb848cb70322c Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sat, 11 Nov 2023 13:56:39 -0700 Subject: [PATCH 09/18] Fix label on center example --- src/tests/roadmap_generators/milestone_text_alignment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/roadmap_generators/milestone_text_alignment.py b/src/tests/roadmap_generators/milestone_text_alignment.py index df5054b..244f91b 100644 --- a/src/tests/roadmap_generators/milestone_text_alignment.py +++ b/src/tests/roadmap_generators/milestone_text_alignment.py @@ -73,7 +73,7 @@ def generate(self) -> Roadmap: "not specified (centre)", "2023-02-15" ) centre_task.add_milestone( - "percent right", "2023-08-01", text_alignment="centre" + "default centre", "2023-08-01", text_alignment="centre" ) From cb807afabf15eb49f5118af78b27916e5d7c3506 Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sat, 11 Nov 2023 13:59:39 -0700 Subject: [PATCH 10/18] Fix one more American English spelling of Center --- src/tests/roadmap_generators/milestone_text_alignment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/roadmap_generators/milestone_text_alignment.py b/src/tests/roadmap_generators/milestone_text_alignment.py index 244f91b..a3d2595 100644 --- a/src/tests/roadmap_generators/milestone_text_alignment.py +++ b/src/tests/roadmap_generators/milestone_text_alignment.py @@ -67,7 +67,7 @@ def generate(self) -> Roadmap: # Group containing each version of none align milestone text centre_task = group.add_task( - "Center align", start_date, "2023-12-31" + "Centre align", start_date, "2023-12-31" ) centre_task.add_milestone( "not specified (centre)", "2023-02-15" From 4c1ec326ad613bb2a5f3d7d7053364e4d6c323c3 Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sat, 11 Nov 2023 14:04:23 -0700 Subject: [PATCH 11/18] Add example images --- .../MilestoneTextAlignmentMacos.png | Bin 0 -> 28189 bytes .../MilestoneTextAlignmentUbuntu.png | Bin 0 -> 28158 bytes .../MilestoneTextAlignmentWindows.png | Bin 0 -> 28414 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/tests/example_roadmaps/MilestoneTextAlignmentMacos.png create mode 100644 src/tests/example_roadmaps/MilestoneTextAlignmentUbuntu.png create mode 100644 src/tests/example_roadmaps/MilestoneTextAlignmentWindows.png diff --git a/src/tests/example_roadmaps/MilestoneTextAlignmentMacos.png b/src/tests/example_roadmaps/MilestoneTextAlignmentMacos.png new file mode 100644 index 0000000000000000000000000000000000000000..432c0711f3ee2a8ab8291376c3dbf044aece6f0e GIT binary patch literal 28189 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 L^-=zZE4KXuoB|Q# literal 0 HcmV?d00001 diff --git a/src/tests/example_roadmaps/MilestoneTextAlignmentUbuntu.png b/src/tests/example_roadmaps/MilestoneTextAlignmentUbuntu.png new file mode 100644 index 0000000000000000000000000000000000000000..da0cb9d1b219b17e9b471bbc3a3e68c038735043 GIT binary patch literal 28158 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 literal 0 HcmV?d00001 diff --git a/src/tests/example_roadmaps/MilestoneTextAlignmentWindows.png b/src/tests/example_roadmaps/MilestoneTextAlignmentWindows.png new file mode 100644 index 0000000000000000000000000000000000000000..163f934a3cd935457d2ba6acbf3b59b9c60a1fc1 GIT binary patch literal 28414 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 A!T Date: Sat, 11 Nov 2023 14:07:18 -0700 Subject: [PATCH 12/18] Rename example images, append "Example" --- ...s.png => MilestoneTextAlignmentMacosExample.png} | Bin ....png => MilestoneTextAlignmentUbuntuExample.png} | Bin ...png => MilestoneTextAlignmentWindowsExample.png} | Bin 3 files changed, 0 insertions(+), 0 deletions(-) rename src/tests/example_roadmaps/{MilestoneTextAlignmentMacos.png => MilestoneTextAlignmentMacosExample.png} (100%) rename src/tests/example_roadmaps/{MilestoneTextAlignmentUbuntu.png => MilestoneTextAlignmentUbuntuExample.png} (100%) rename src/tests/example_roadmaps/{MilestoneTextAlignmentWindows.png => MilestoneTextAlignmentWindowsExample.png} (100%) diff --git a/src/tests/example_roadmaps/MilestoneTextAlignmentMacos.png b/src/tests/example_roadmaps/MilestoneTextAlignmentMacosExample.png similarity index 100% rename from src/tests/example_roadmaps/MilestoneTextAlignmentMacos.png rename to src/tests/example_roadmaps/MilestoneTextAlignmentMacosExample.png diff --git a/src/tests/example_roadmaps/MilestoneTextAlignmentUbuntu.png b/src/tests/example_roadmaps/MilestoneTextAlignmentUbuntuExample.png similarity index 100% rename from src/tests/example_roadmaps/MilestoneTextAlignmentUbuntu.png rename to src/tests/example_roadmaps/MilestoneTextAlignmentUbuntuExample.png diff --git a/src/tests/example_roadmaps/MilestoneTextAlignmentWindows.png b/src/tests/example_roadmaps/MilestoneTextAlignmentWindowsExample.png similarity index 100% rename from src/tests/example_roadmaps/MilestoneTextAlignmentWindows.png rename to src/tests/example_roadmaps/MilestoneTextAlignmentWindowsExample.png From a6b01ff3f77c9bd64143bb3a5fbabd54a070aba9 Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sat, 11 Nov 2023 15:01:10 -0700 Subject: [PATCH 13/18] Rename test file, add missing "e" --- src/tests/{test_milstone.py => test_milestone.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/tests/{test_milstone.py => test_milestone.py} (100%) diff --git a/src/tests/test_milstone.py b/src/tests/test_milestone.py similarity index 100% rename from src/tests/test_milstone.py rename to src/tests/test_milestone.py From b9247330348f8b64687b1b26753dd9d3c9a5ac04 Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Sun, 12 Nov 2023 22:41:56 -0700 Subject: [PATCH 14/18] 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 From 18dabc12fa275b818a412b237b6a6069545ec5fc Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Wed, 15 Nov 2023 22:13:42 -0700 Subject: [PATCH 15/18] Check if offset before attempting to shift x --- src/roadmapper/milestone.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/roadmapper/milestone.py b/src/roadmapper/milestone.py index 1827d11..6b03853 100644 --- a/src/roadmapper/milestone.py +++ b/src/roadmapper/milestone.py @@ -92,7 +92,7 @@ def apply_offset(self, alignment: Alignment, painter: Painter) -> None: ) offset = alignment.percent_of(text_width) - if direction == AlignmentDirection.RIGHT: + if direction == AlignmentDirection.RIGHT and offset: self.text_x += offset - elif alignment == AlignmentDirection.LEFT: + elif alignment == AlignmentDirection.LEFT and offset: self.text_x -= offset From 3b9bc3161fcf7a722d11b2f7e74dedbc729b7f9d Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Wed, 15 Nov 2023 22:26:19 -0700 Subject: [PATCH 16/18] Pass defaults to from_str --- src/roadmapper/alignment.py | 15 ++++++++++++--- src/roadmapper/milestone.py | 5 ++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/roadmapper/alignment.py b/src/roadmapper/alignment.py index e42a299..f9e1f7c 100644 --- a/src/roadmapper/alignment.py +++ b/src/roadmapper/alignment.py @@ -42,7 +42,11 @@ def from_value( if isinstance(alignment, Alignment): return cls.from_alignment(alignment) if isinstance(alignment, str): - return cls.from_string(alignment) + 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,' @@ -56,8 +60,13 @@ def from_alignment(cls, alignment: "Alignment") -> "Alignment": return new @classmethod - def from_string(cls, alignment: str) -> "Alignment": - new = cls() + def from_string( + cls, + alignment: str, + default_offset_type: Optional[OffsetType] = None, + default_offset: Optional[float] = None, + ) -> "Alignment": + new = cls(offset_type=default_offset_type, offset=default_offset) new.update_from_alignment_string(alignment) return new diff --git a/src/roadmapper/milestone.py b/src/roadmapper/milestone.py index 6b03853..56184a0 100644 --- a/src/roadmapper/milestone.py +++ b/src/roadmapper/milestone.py @@ -82,7 +82,6 @@ def draw(self, painter: Painter) -> None: 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 @@ -91,8 +90,8 @@ def apply_offset(self, alignment: Alignment, painter: Painter) -> None: 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 alignment == AlignmentDirection.LEFT and offset: + elif direction == AlignmentDirection.LEFT and offset: self.text_x -= offset From a6af49d308528d91f7f478866adc919beee2d7fc Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Wed, 15 Nov 2023 22:38:51 -0700 Subject: [PATCH 17/18] Add validation for impossible center + offset case --- src/roadmapper/alignment.py | 27 ++++++++++----------------- src/tests/test_alignment.py | 10 ++++++++++ 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/roadmapper/alignment.py b/src/roadmapper/alignment.py index f9e1f7c..1d81f82 100644 --- a/src/roadmapper/alignment.py +++ b/src/roadmapper/alignment.py @@ -30,6 +30,9 @@ class Alignment: offset_type: Optional[OffsetType] = None offset: Optional[Union[int, float]] = None + def __post_init__(self): + self.validate() + @classmethod def from_value( cls, @@ -68,6 +71,7 @@ def from_string( ) -> "Alignment": new = cls(offset_type=default_offset_type, offset=default_offset) new.update_from_alignment_string(alignment) + new.validate() return new @staticmethod @@ -101,6 +105,12 @@ def percent_of(self, whole: Union[int, float]) -> float: 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: @@ -111,20 +121,3 @@ def __str__(self): 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/tests/test_alignment.py b/src/tests/test_alignment.py index aec2a8e..93b1401 100644 --- a/src/tests/test_alignment.py +++ b/src/tests/test_alignment.py @@ -98,6 +98,16 @@ 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(): From 5b461b970f2a68f2a1a4a778c955f19e1b48a965 Mon Sep 17 00:00:00 2001 From: Patrick Shechet Date: Wed, 15 Nov 2023 22:44:39 -0700 Subject: [PATCH 18/18] Don't set offset to default if center. --- src/roadmapper/alignment.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/roadmapper/alignment.py b/src/roadmapper/alignment.py index 1d81f82..1afa774 100644 --- a/src/roadmapper/alignment.py +++ b/src/roadmapper/alignment.py @@ -69,8 +69,11 @@ def from_string( default_offset_type: Optional[OffsetType] = None, default_offset: Optional[float] = None, ) -> "Alignment": - new = cls(offset_type=default_offset_type, offset=default_offset) + 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