Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle text_alignment in Milestone #76

Merged
merged 18 commits into from
Nov 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions src/roadmapper/alignment.py
Original file line number Diff line number Diff line change
@@ -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}"
33 changes: 30 additions & 3 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 @@ -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

Expand All @@ -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,
Expand All @@ -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
7 changes: 4 additions & 3 deletions src/tests/compare_generated_roadmaps_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
94 changes: 94 additions & 0 deletions src/tests/roadmap_generators/milestone_text_alignment.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 2 additions & 1 deletion src/tests/roadmap_generators/roadmap_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading