# TODO move out rect code
from enum import IntEnum
from math import ceil, sqrt, floor
from typing import NamedTuple, Optional, Sequence
from PyQt5.QtCore import QPoint, QRect, QSize
[docs]class Pointer(NamedTuple):
left: QPoint
top: QPoint
bottom: QPoint
[docs] class POINTS(IntEnum):
LEFT = 0
TOP = 1
RIGHT = 2
[docs]class InvalidStateError(ValueError):
pass
[docs]def rect_right(rect: QRect):
return rect.left() + rect.width()
[docs]def rect_bottom(rect: QRect):
return rect.top() + rect.height()
[docs]def rect_bottom_right(rect: QRect):
return QPoint(rect_bottom(rect), rect_right(rect))
[docs]def new_rect_lefttop_rightbottom(left_top, right_bottom):
width = right_bottom.x() - left_top.x()
height = right_bottom.y() - left_top.x()
return QRect(left_top, QSize(width, height))
[docs]def new_rect_xleftytop_xrightybottom(xleft, ytop, xright, ybottom):
width = xright - xleft
height = ybottom - ytop
return QRect(QPoint(xleft, ytop), QSize(width, height))
[docs]def display_rect(rect, name=''):
top___ = str(rect.top()).rjust(8)
left__ = rect.left()
right_ = rect_right(rect)
bottom = rect_bottom(rect)
result = f'''
{name}
left | {left__}
|
{top___} top - ----------------------
| |
| |
---------------------- -- bottom {bottom}
|
right | {right_}
'''
print(result)
[docs]class Side(IntEnum):
TOP = 1
LEFT = 0
RIGHT = 2
BOTTOM = 3
OPPOSITE_SIDES = {
Side.TOP: Side.BOTTOM,
Side.RIGHT: Side.LEFT,
Side.BOTTOM: Side.TOP,
Side.LEFT: Side.RIGHT
}
[docs]def rect_get_side(rect: QRect, side: Side) -> int:
if side == Side.TOP:
result = rect.top()
elif side == Side.BOTTOM:
result = rect.top() + rect.height()
elif side == Side.LEFT:
result = rect.left()
else:
result = rect.left() + rect.width()
return result
[docs]def calc_side_distance_outside_rect(test_rect: QRect, target_rect: QRect):
intersection = test_rect.intersected(target_rect)
result = {}
for side in Side:
inter_side = rect_get_side(intersection, side)
test_side = rect_get_side(test_rect, side)
if inter_side != test_side:
result[side] = test_side - inter_side
return result
[docs]class Axis(IntEnum):
X = 0,
Y = 1
SIDE_AXIS = {
Side.TOP: Axis.Y,
Side.RIGHT: Axis.X,
Side.BOTTOM: Axis.Y,
Side.LEFT: Axis.X
}
OPPOSITE_AXIS = {
Axis.X: Axis.Y,
Axis.Y: Axis.X
}
POINTER_SIDES = {
Side.TOP: (Side.BOTTOM, Side.TOP),
Side.RIGHT: (Side.LEFT, Side.RIGHT),
Side.BOTTOM: (Side.TOP, Side.BOTTOM),
Side.LEFT: (Side.RIGHT, Side.LEFT)
}
# note these are ordered: top - bottom, left - right
NON_POINTER_SIDES = {
Side.TOP: (Side.LEFT, Side.RIGHT),
Side.RIGHT: (Side.TOP, Side.BOTTOM),
Side.BOTTOM: (Side.LEFT, Side.RIGHT),
Side.LEFT: (Side.TOP, Side.BOTTOM)
}
SIGNS = [-1, -1, 1, 1]
RECT_NUM_SIDES = 4
[docs]class SubscriptableQPoint:
def __init__(self, target):
self._target = target
def __getitem__(self, item):
if item == Axis.X:
result = self._target.x()
elif item == Axis.Y:
result = self._target.y()
else:
raise IndexError(f'Error bad index for QPoint {item}')
return result
def __setitem__(self, item, value):
if item == Axis.X:
self._target.setX(value)
elif item == Axis.Y:
self._target.setY(value)
else:
raise IndexError(f'Error bad index for QPoint {item}')
def __str__(self):
return f'[{self._target.x()},{self._target.y()}]'
[docs]class BalloonMetrics:
def __init__(self, corner_radius=3, pointer_side=Side.RIGHT, pointer_height=10, pointer_width=20):
self.corner_radius: int = corner_radius
self.pointer_side: Side = pointer_side
self.pointer_height: int = pointer_height
self.pointer_width: int = pointer_width
self.antialias_margin: int = 1
self.pointer_alignment: float = 0.5
self.pointer_position: Optional[QPoint] = None
self.override_offset: Optional[QPoint] = None
self._inner: Optional[QRect] = None
self._outer: Optional[QRect] = None
self._body_rect: Optional[QRect] = None
self._pointer_rect: Optional[QRect] = None
self._pointer: Optional[Pointer] = None
# TODO replace reset with property change?
# TODO recalc on last rect set?
[docs] def reset(self):
self._inner = None
self._outer = None
self._body_rect = None
self._pointer_rect = None
self._pointer = None
self.override_offset = None
@property
def outer_viewport(self):
self._raise_invalid_if_required()
translation = self._outer.topLeft() * -1
return self._outer.translated(translation)
@property
def inner_viewport(self):
self._raise_invalid_if_required()
translation = self._outer.topLeft() * -1
return self._inner.translated(translation)
@property
def body_rect_viewport(self):
self._raise_invalid_if_required()
translation = self._outer.topLeft() * -1
return self._body_rect.translated(translation)
def _pointer_rect_viewport(self):
translation = self._outer.topLeft() * -1
return self._pointer_rect.translated(translation)
@property
def pointer_viewport(self):
self._raise_invalid_if_required()
pointer_rect_viewport = self._pointer_rect_viewport()
min_left_pointer, max_right_pointer = self._calc_minleft_maxright_pointer_base(pointer_rect_viewport)
translation = self._outer.topLeft() * -1
result = [point + translation for point in self._pointer]
result = self._add_override_offset_pointer(result)
axis = OPPOSITE_AXIS[SIDE_AXIS[self.pointer_side]]
left = SubscriptableQPoint(result[Pointer.POINTS.LEFT])
right = SubscriptableQPoint(result[Pointer.POINTS.RIGHT])
if left[axis] < min_left_pointer:
left[axis] = min_left_pointer
right[axis] = left[axis] + self.pointer_width
if right[axis] > max_right_pointer:
right[axis] = max_right_pointer
elif right[axis] > max_right_pointer:
right[axis] = max_right_pointer
left[axis] = right[axis] - self.pointer_width
if left[axis] < min_left_pointer:
left[axis] = min_left_pointer
result = Pointer(*result)
return result
def _calc_minleft_maxright_pointer_base(self, pointer_rect_viewport):
movement_sides = NON_POINTER_SIDES[self.pointer_side]
values = [rect_get_side(pointer_rect_viewport, side) for side in movement_sides]
min_left_pointer, max_right_pointer = values
min_left_pointer += self.corner_radius
max_right_pointer -= self.corner_radius
return min_left_pointer, max_right_pointer
@property
def outer(self):
self._raise_invalid_if_required()
result = QRect(self._outer)
result = self._translate_to_pointer(result)
return self._add_override_offset_rect(result)
def _global_pointer_offset(self):
result = QPoint()
if self.pointer_position:
result = self.pointer_position - self._pointer.top
return result
def _translate_to_pointer(self, rect: QRect):
pointer_offset = self._global_pointer_offset()
rect.translate(pointer_offset)
return rect
def _add_override_offset_rect(self, rect: QRect):
if self.override_offset:
rect.translate(self.override_offset)
return rect
def _add_override_offset_pointer(self, points: Sequence[QPoint]):
if self.override_offset:
points = [QPoint(point) - self.override_offset for point in points]
return points
@property
def inner(self):
self._raise_invalid_if_required()
result = QRect(self._inner)
result = self._translate_to_pointer(result)
return self._add_override_offset_rect(result)
@property
def body_rect(self):
self._raise_invalid_if_required()
result = QRect(self._body_rect)
result = self._translate_to_pointer(result)
return self._add_override_offset_rect(result)
@property
def pointer(self):
self._raise_invalid_if_required()
offset = self._global_pointer_offset()
points = [QPoint(point) + offset for point in self._pointer]
result = Pointer(*points)
return self._add_override_offset_pointer(result)
@property
def pointer_rect(self):
self._raise_invalid_if_required()
return QRect(self._pointer_rect)
def _get_corner_margin(self):
result = self.corner_radius / sqrt(2)
result = int(ceil(result))
return result
@staticmethod
def _add_margins(rect: QRect, margin, multiplier=1):
margins = [margin * multiplier] * 4
margins = [margins[i] * SIGNS[i] for i in range(RECT_NUM_SIDES)]
return rect.adjusted(*margins)
def _add_central_widget_margins(self, rect: QRect, multiplier=1):
return self._add_margins(rect, self._get_corner_margin(), multiplier)
def _add_pointer_margin(self, rect: QRect, multiplier=1):
signs = [SIGNS[i] * multiplier for i in range(RECT_NUM_SIDES)]
margin = [self.pointer_height] * RECT_NUM_SIDES
margin = [margin[i] * signs[i] for i in range(RECT_NUM_SIDES)]
mask = [0] * RECT_NUM_SIDES
mask[self.pointer_side] = 1
pointer_margins = [margin[i] * mask[i] for i in range(RECT_NUM_SIDES)]
result = rect.adjusted(*pointer_margins)
return result
def _add_antialias_margin(self, rect: QRect, multiplier=1):
return self._add_margins(rect, self.antialias_margin, multiplier)
[docs] def from_inner(self, rect: QRect):
if rect != self._inner:
self._inner = QRect(rect)
pointer_box = {
Axis.X: [],
Axis.Y: []
}
result = self._add_central_widget_margins(rect)
self._body_rect = QRect(result)
pointer_bottom = rect_get_side(result, self.pointer_side)
pointer_box[SIDE_AXIS[self.pointer_side]].append(pointer_bottom)
result = self._add_pointer_margin(result)
pointer_top = rect_get_side(result, self.pointer_side)
pointer_box[SIDE_AXIS[self.pointer_side]].append(pointer_top)
for orthogonal_side in NON_POINTER_SIDES[self.pointer_side]:
orthogonal_axis = OPPOSITE_AXIS[SIDE_AXIS[self.pointer_side]]
pointer_box[orthogonal_axis].append(rect_get_side(result, orthogonal_side))
self._pointer_rect = new_rect_xleftytop_xrightybottom(min(pointer_box[Axis.X]), min(pointer_box[Axis.Y]),
max(pointer_box[Axis.X]), max(pointer_box[Axis.Y]))
result = self._add_antialias_margin(result)
self._outer = result
self._calc_pointer_position()
return self
[docs] def from_outer(self, rect: QRect):
if rect != self._outer:
self._outer = QRect(rect)
pointer_box = {
Axis.X: [],
Axis.Y: []
}
result = self._add_antialias_margin(rect, multiplier=-1)
pointer_top = rect_get_side(result, self.pointer_side)
pointer_box[SIDE_AXIS[self.pointer_side]].append(pointer_top)
for orthogonal_side in NON_POINTER_SIDES[self.pointer_side]:
orthogonal_axis = OPPOSITE_AXIS[SIDE_AXIS[self.pointer_side]]
pointer_box[orthogonal_axis].append(rect_get_side(result, orthogonal_side))
result = self._add_pointer_margin(result, multiplier=-1)
pointer_bottom = rect_get_side(result, self.pointer_side)
pointer_box[SIDE_AXIS[self.pointer_side]].append(pointer_bottom)
self._pointer_rect = new_rect_xleftytop_xrightybottom(min(pointer_box[Axis.X]), min(pointer_box[Axis.Y]),
max(pointer_box[Axis.X]), max(pointer_box[Axis.Y]))
self._body_rect = QRect(result)
result = self._add_central_widget_margins(result, multiplier=-1)
self._inner = result
self._calc_pointer_position()
return self
def _calc_pointer_position(self):
self._raise_invalid_if_required()
min_left, max_right = self._calc_minleft_maxright_pointer_base(self._pointer_rect)
pointer_width_2 = self.pointer_width/2
if max_right < min_left:
left = min_left
right = max_right
centre = min_left + ((max_right-min_left) / 2)
else:
range_left = min_left + pointer_width_2
range_right = max_right - pointer_width_2
centre_range = range_right - range_left
centre = range_left + int(floor(centre_range * self.pointer_alignment))
left = centre - pointer_width_2
right = centre + pointer_width_2
pointer_sides = [rect_get_side(self._pointer_rect, side) for side in POINTER_SIDES[self.pointer_side]]
bottom, top = pointer_sides
pointer_axis = SIDE_AXIS[self.pointer_side]
range_axis = OPPOSITE_AXIS[pointer_axis]
pointer_centre = [0] * 2
pointer_left = [0] * 2
pointer_right = [0] * 2
pointer_centre[pointer_axis] = top
pointer_centre[range_axis] = int(centre)
pointer_left[pointer_axis] = bottom
pointer_left[range_axis] = int(left)
pointer_right[pointer_axis] = bottom
pointer_right[range_axis] = int(right)
self._pointer = Pointer(QPoint(*pointer_left), QPoint(*pointer_centre), QPoint(*pointer_right))
def _raise_invalid_if_required(self):
if self._pointer_rect is None:
raise InvalidStateError('Error: call from_inner or from_outer first!')
[docs]def test_expand():
metrics = BalloonMetrics()
test_rect = QRect(QPoint(0, 0), QSize(10, 100))
assert QRect(QPoint(-3, -3), QSize(16, 106)) == metrics._add_central_widget_margins(test_rect)
assert QRect(QPoint(0, 0), QSize(20, 100)) == metrics._add_pointer_margin(test_rect)
assert QRect(QPoint(-1, -1), QSize(12, 102)) == metrics._add_antialias_margin(test_rect)
assert QRect(QPoint(-4, -4), QSize(28, 108)) == metrics.from_inner(test_rect)._outer
[docs]def test_reduce():
metrics = BalloonMetrics()
test_rect = QRect(QPoint(0, 0), QSize(10, 100))
assert QRect(QPoint(1, 1), QSize(8, 98)) == metrics._add_antialias_margin(test_rect, multiplier=-1)
assert QRect(QPoint(3, 3), QSize(4, 94)) == metrics._add_central_widget_margins(test_rect, multiplier=-1)
assert QRect(QPoint(0, 0), QSize(0, 100)) == metrics._add_pointer_margin(test_rect, multiplier=-1)
assert test_rect == metrics.from_outer(QRect(QPoint(-4, -4), QSize(28, 108)))._inner
[docs]def test_expand_sides():
metrics = BalloonMetrics()
test_rect = QRect(QPoint(0, 0), QSize(200, 100))
expected = {
Side.TOP: QRect(QPoint(0, -10), QSize(200, 110)),
Side.RIGHT: QRect(QPoint(0, 0), QSize(210, 100)),
Side.BOTTOM: QRect(QPoint(0, 0), QSize(200, 110)),
Side.LEFT: QRect(QPoint(-10, 0), QSize(210, 100))
}
for side, expanded in expected.items():
metrics.pointer_side = side
assert expanded == metrics._add_pointer_margin(test_rect)
[docs]def test_rects_from_outer():
test_rect = QRect(QPoint(0, 0), QSize(200, 100))
expected_for_side = {
Side.TOP: new_rect_xleftytop_xrightybottom(-3, -13, 203, -3),
Side.RIGHT: new_rect_xleftytop_xrightybottom(203, -3, 213, 103),
Side.BOTTOM: new_rect_xleftytop_xrightybottom(-3, 103, 203, 113),
Side.LEFT: new_rect_xleftytop_xrightybottom(-13, -3, -3, 103)
}
for side in expected_for_side:
metrics = BalloonMetrics(pointer_side=side)
metrics.from_inner(test_rect)
assert metrics.pointer_rect == expected_for_side[side]
[docs]def test_inners_from_outer():
test_rect = QRect(QPoint(0, 0), QSize(200, 100))
outer_for_side = {
Side.TOP: new_rect_xleftytop_xrightybottom(-4, -14, 204, 104),
Side.RIGHT: new_rect_xleftytop_xrightybottom(-4, -4, 214, 104),
Side.BOTTOM: new_rect_xleftytop_xrightybottom(-4, -4, 204, 114),
Side.LEFT: new_rect_xleftytop_xrightybottom(-14, -4, 204, 104)
}
for side, outer in outer_for_side.items():
metrics = BalloonMetrics(pointer_side=side)
metrics.from_outer(outer)
assert metrics.inner == test_rect
[docs]def test_pointer_rects_from_outer():
outer_for_side = {
Side.TOP: new_rect_xleftytop_xrightybottom(-4, -14, 204, 104),
Side.RIGHT: new_rect_xleftytop_xrightybottom(-4, -4, 214, 104),
Side.BOTTOM: new_rect_xleftytop_xrightybottom(-4, -4, 204, 114),
Side.LEFT: new_rect_xleftytop_xrightybottom(-14, -4, 204, 104)
}
expected_for_side = {
Side.TOP: new_rect_xleftytop_xrightybottom(-3, -13, 203, -3),
Side.RIGHT: new_rect_xleftytop_xrightybottom(203, -3, 213, 103),
Side.BOTTOM: new_rect_xleftytop_xrightybottom(-3, 103, 203, 113),
Side.LEFT: new_rect_xleftytop_xrightybottom(-13, -3, -3, 103)
}
for side, outer in outer_for_side.items():
expected = expected_for_side[side]
metrics = BalloonMetrics(pointer_side=side)
metrics.from_outer(outer)
assert metrics.pointer_rect == expected
[docs]def test_viewport_views():
test_rect = QRect(QPoint(0, 0), QSize(200, 100))
expected_outer = QRect(QPoint(0, 0), QSize(218, 108))
expected_inner = QRect(QPoint(4, 4), QSize(200, 100))
expected_pointer = Pointer(QPoint(207, 44), QPoint(217, 54), QPoint(207, 64))
metrics = BalloonMetrics()
metrics.from_inner(test_rect)
assert metrics.outer_viewport == expected_outer
assert metrics.inner_viewport == expected_inner
assert metrics.pointer_viewport == expected_pointer
[docs]def test_reset():
import pytest
metrics = BalloonMetrics()
# noinspection PyStatementEffect
def check_raises():
with pytest.raises(InvalidStateError):
metrics.inner
with pytest.raises(InvalidStateError):
metrics.outer
with pytest.raises(InvalidStateError):
metrics.pointer_rect
with pytest.raises(InvalidStateError):
metrics.pointer
with pytest.raises(InvalidStateError):
metrics.body_rect
check_raises()
metrics.from_outer(QRect(0, 0, 100, 200))
assert metrics.inner is not None
assert metrics.outer is not None
assert metrics.pointer_rect is not None
assert metrics.pointer is not None
metrics.reset()
check_raises()
[docs]def test_body_rect():
test_rect = QRect(QPoint(0, 0), QSize(200, 100))
expected = QRect(QPoint(-3, -3), QSize(206, 106))
expected_local = {
Side.TOP: QRect(QPoint(1, 11), QSize(206, 106)),
Side.LEFT: QRect(QPoint(11, 1), QSize(206, 106)),
Side.BOTTOM: QRect(QPoint(1, 1), QSize(206, 106)),
Side.RIGHT: QRect(QPoint(1, 1), QSize(206, 106))
}
for side in Side:
metrics = BalloonMetrics(pointer_side=side)
metrics.from_inner(test_rect)
assert expected == metrics.body_rect
assert expected_local[side] == metrics.body_rect_viewport
[docs]def test_pointer_positions():
test_rect = QRect(QPoint(0, 0), QSize(200, 100))
expected = {
(0.0, Side.TOP): Pointer(QPoint(0, -3), QPoint(10, -13), QPoint(20, -3)),
(0.0, Side.RIGHT): Pointer(QPoint(203, 0), QPoint(213, 10), QPoint(203, 20)),
(0.0, Side.BOTTOM): Pointer(QPoint(0, 103), QPoint(10, 113), QPoint(20, 103)),
(0.0, Side.LEFT): Pointer(QPoint(-3, 0), QPoint(-13, 10), QPoint(-3, 20)),
(0.5, Side.TOP): Pointer(QPoint(90, -3), QPoint(100, -13), QPoint(110, -3)),
(0.5, Side.RIGHT): Pointer(QPoint(203, 40), QPoint(213, 50), QPoint(203, 60)),
(0.5, Side.BOTTOM): Pointer(QPoint(90, 103), QPoint(100, 113), QPoint(110, 103)),
(0.5, Side.LEFT): Pointer(QPoint(-3, 40), QPoint(-13, 50), QPoint(-3, 60)),
(1.0, Side.TOP): Pointer(QPoint(180, -3), QPoint(190, -13), QPoint(200, -3)),
(1.0, Side.RIGHT): Pointer(QPoint(203, 80), QPoint(213, 90), QPoint(203, 100)),
(1.0, Side.BOTTOM): Pointer(QPoint(180, 103), QPoint(190, 113), QPoint(200, 103)),
(1.0, Side.LEFT): Pointer(QPoint(-3, 80), QPoint(-13, 90), QPoint(-3, 100)),
}
for (percentage, side), expected in expected.items():
metrics = BalloonMetrics(pointer_side=side)
metrics.pointer_alignment = percentage
metrics.from_inner(test_rect)
assert expected == metrics.pointer
[docs]def test_pointer_position():
test_rect = QRect(QPoint(0, 0), QSize(200, 100))
expected_pointer = Pointer(QPoint(90, 90), QPoint(100, 100), QPoint(90, 110))
expected_outer = QRect(QPoint(-117, 46), QSize(218, 108))
expected_inner = QRect(QPoint(-113, 50), QSize(200, 100))
expected_body = QRect(QPoint(-116, 47), QSize(206, 106))
metrics = BalloonMetrics()
metrics.from_inner(test_rect)
metrics.pointer_position = QPoint(100, 100)
assert expected_pointer == metrics.pointer
assert expected_outer == metrics.outer
assert expected_inner == metrics.inner
assert expected_body == metrics.body_rect
[docs]def test_find_side_outside_rect_offset():
test_rect = QRect(QPoint(0, 0), QSize(200, 100))
inside_rect = QRect(QPoint(1, 1), QSize(198, 98))
above_rect = QRect(QPoint(1, -1), QSize(198, 98))
below_rect = QRect(QPoint(0, 0), QSize(200, 101))
above_below_rect = QRect(QPoint(0, -1), QSize(200, 102))
outside_rect = QRect(QPoint(-1, -1), QSize(202, 102))
assert calc_side_distance_outside_rect(inside_rect, test_rect) == {}
assert calc_side_distance_outside_rect(above_rect, test_rect) == {Side.TOP: -1}
assert calc_side_distance_outside_rect(below_rect, test_rect) == {Side.BOTTOM: 1}
assert calc_side_distance_outside_rect(above_below_rect, test_rect) == {Side.TOP: -1, Side.BOTTOM: 1}
assert calc_side_distance_outside_rect(outside_rect, test_rect) == {Side.TOP: -1, Side.BOTTOM: 1,
Side.LEFT: -1, Side.RIGHT: 1}
[docs]def test_override_offset():
test_rect = QRect(QPoint(0, 0), QSize(200, 100))
offset = (10, -5)
expected_inner = QRect(QPoint(10, -5), QSize(200, 100))
expected_outer = QRect(QPoint(6, -19), QSize(208, 118))
expected_body_rect = QRect(QPoint(7, -8), QSize(206, 106))
point_offset = QPoint(*offset)
metrics = BalloonMetrics(pointer_side=Side.TOP)
metrics.from_inner(test_rect)
metrics.override_offset = point_offset
assert metrics.inner == expected_inner
assert metrics.outer == expected_outer
assert metrics.body_rect == expected_body_rect
def _run_tests():
import pytest
# pytest.main(['%s::test_reset' % __file__])
pytest.main([__file__, '-vv'])
if __name__ == '__main__':
_run_tests()