# TODO needs to pin base of pointer if show at will move pointer below
# TODO needs a license
# TODO display of body rect gives double thickness lines, anti aliasing bug?
# TODO it would be good to clip the interior widget for edge to edge views
# TODO it would be good to check the recommendations in https://www.vikingsoftware.com/creating-custom-widgets
# TODO override offset could have a better name
# TODO profile
# TODO balloon pointer is clipped when outside original bounding rect
import sys
from functools import singledispatchmethod
from typing import Optional, List, Union
from PyQt5 import QtGui, QtCore
from PyQt5.QtCore import QRectF, Qt, QRect, QPoint, pyqtProperty, QTimer, QEvent, QSize
from PyQt5.QtGui import QPainterPath, QPainter, QPen, QColor, QBrush, QPolygon, QPolygonF, QPixmap, QPalette, QCursor, \
QFontMetrics, QGuiApplication
from PyQt5.QtWidgets import QApplication, QLabel, QWidget, QGridLayout, QFrame
from ccpn.ui.gui.widgets.BalloonMetrics import Side, BalloonMetrics, OPPOSITE_SIDES, rect_get_side, calc_side_distance_outside_rect, \
SIDE_AXIS, OPPOSITE_AXIS
from ccpn.core.lib.ContextManagers import AntiAliasedPaintContext
DEFAULT_SEPARATOR = '|'
LEFT_LABEL = 0
MIDDLE_LABEL = 1
RIGHT_LABEL = 2
[docs]class MyApplication(QApplication):
def __init__(self, arg):
super(MyApplication, self).__init__(arg)
self._timer = QTimer(self)
self._timer.timeout.connect(self._update)
self._timer.start(0)
def _update(self):
pos = QCursor.pos()
window = self.activeWindow()
if window:
window.move_pointer_to(pos)
[docs]class SpeechBalloon(QWidget):
r""" Popover window class
inspired by but not sharing any code with FUKIDASHI
aka https://github.com/sharkpp/qtpopover (MIT licensed!)
*__/\___ * = 原点 - origin
| | 時計回りに描画 - draw clockwise
+------+
"""
def __init__(self, side=Side.BOTTOM, percentage=50, owner=None, parent=None, on_top=False):
super(SpeechBalloon, self).__init__(parent)
flags = Qt.FramelessWindowHint
if on_top:
flags |= Qt.WindowStaysOnTopHint
self.setWindowFlags(flags)
self.setAttribute(Qt.WA_NoSystemBackground)
self._metrics: BalloonMetrics = BalloonMetrics()
self._metrics.pointer_height = 10
self._metrics.pointer_width = 20
self._metrics.pointer_side = side
self._metrics.alignment = percentage / 100.0
self._metrics.corner_radius = 3
self._screen_margin: int = 10
self._pen_width: int = 0
self._owner: QWidget = owner
self._central_widget: Optional[QWidget] = None
[docs] def event(self, event: QtCore.QEvent) -> bool:
if event.type() == QEvent.LayoutRequest:
self._layout()
result = True
else:
result = super(SpeechBalloon, self).event(event)
return result
[docs] def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
super(SpeechBalloon, self).resizeEvent(event)
new_outer = QRect(self.geometry())
new_outer.setSize(event.size())
self._metrics.from_outer(new_outer)
self._central_widget.setGeometry(self._metrics.inner_viewport)
@pyqtProperty(int)
def cornerRadius(self):
return self._metrics.corner_radius
@cornerRadius.setter
def cornerRadius(self, radius):
self._metrics.corner_radius = radius
self._metrics.reset()
self.updateGeometry()
@pyqtProperty(int)
def pointerHeight(self):
return self._metrics.pointer_height
@pointerHeight.setter
def pointerHeight(self, height):
self._metrics.pointer_height = height
self._metrics.reset()
self.updateGeometry()
@pyqtProperty(int)
def pointerWidth(self):
return self._metrics.pointer_width
@pointerWidth.setter
def pointerWidth(self, width):
self._metrics.pointer_width = width
self._metrics.reset()
self.updateGeometry()
@pyqtProperty(Side)
def pointerSide(self):
return self._metrics.pointer_side
@pointerSide.setter
def pointerSide(self, side):
self._metrics.pointer_side = side
self._metrics.reset()
self.updateGeometry()
@pyqtProperty(float)
def pointerAlignment(self):
return self._metrics.pointer_alignment
@pointerAlignment.setter
def pointerAlignment(self, alignment):
self.metrics.pointer_alignment = alignment
self._metrics.reset()
self.updateGeometry()
@pyqtProperty(int)
def screenMargin(self):
return self._screen_margin
@screenMargin.setter
def screenMargin(self, screen_margin):
self._screen_margin = screen_margin
self.updateGeometry()
[docs] def paintEvent(self, a0: QtGui.QPaintEvent) -> None:
self.setMask(self.window_mask())
painterPath = self.window_path()
with AntiAliasedPaintContext(QPainter(self)) as painter:
pal = self.palette()
fgColor = pal.color(QPalette.Active, QPalette.Text)
bgColor = pal.color(QPalette.Active, QPalette.Window)
brush = QBrush(bgColor)
pen = QPen(fgColor)
pen.setWidth(self._pen_width)
painter.fillPath(painterPath, brush)
painter.strokePath(painterPath, pen)
return super(SpeechBalloon, self).paintEvent(a0)
[docs] def window_path(self):
self._metrics.pointer_side = self._metrics.pointer_side
self._metrics.from_outer(self.frameGeometry())
path = QPainterPath()
corner_radius = self._metrics.corner_radius
path.addRoundedRect(QRectF(self._metrics.body_rect_viewport), corner_radius, corner_radius)
pointer_polygon = QPolygonF(QPolygon(self._metrics.pointer_viewport))
path.addPolygon(pointer_polygon)
path = path.simplified()
return path
[docs] def window_mask(self):
path = self.window_path()
pixmap = QPixmap(int(path.boundingRect().width() + 2), int(path.boundingRect().height() + 2))
with AntiAliasedPaintContext(QPainter(pixmap)) as painter:
brush = QBrush(QColor('white'))
painter.fillRect(pixmap.rect(), brush)
brush = QBrush(QColor('black'))
painter.setBrush(brush)
painter.drawPath(path)
result = pixmap.createHeuristicMask(False)
return result
def _pointer_offset(self):
pointer_pos = self.mapToGlobal(self._metrics.pointer_viewport.top)
return pointer_pos - self.pos()
[docs] def move_pointer_to(self, pos):
self._metrics.pointer_position = pos
self.setGeometry(self._metrics.outer)
def _central_widget_size(self):
result = self._central_widget.sizeHint()
if self._central_widget.minimumWidth() == self._central_widget.maximumWidth():
result.setWidth(self._central_widget.minimumWidth())
if self._central_widget.minimumHeight() == self._central_widget.maximumHeight():
result.setHeight(self._central_widget.minimumHeight())
return result
def _layout(self):
self._metrics.from_inner(QRect(QPoint(0, 0), self._central_widget_size()))
self.setGeometry(self._metrics.outer)
self._central_widget.setGeometry(self._metrics.inner_viewport)
[docs] def show(self):
self._central_widget.show()
super(SpeechBalloon, self).show()
self._layout()
@staticmethod
def _rect_middle_sides(global_rect: QRect):
width = global_rect.width()
height = global_rect.height()
width_2 = int(width / 2)
height_2 = int(height / 2)
result = {}
for side in Side:
if side == Side.BOTTOM:
result[side] = QPoint(global_rect.x() + width_2, global_rect.y() + height)
elif side == Side.TOP:
result[side] = QPoint(global_rect.x() + width_2, global_rect.y())
elif side == Side.LEFT:
result[side] = QPoint(global_rect.x(), global_rect.y() + height_2)
else: # side == Side.RIGHT:
result[side] = QPoint(global_rect.x() + width, global_rect.y() + height_2)
return result
[docs] @singledispatchmethod
def showAt(self, point: Union[QPoint, QRect], preferred_side=Side.RIGHT,
side_priority=(Side.RIGHT, Side.LEFT, Side.BOTTOM, Side.TOP), target_screen=None):
self._showAtRect(QRect(point, QSize(1, 1)), preferred_side=preferred_side, side_priority=side_priority,
target_screen=target_screen)
@showAt.register
def _showAtRect(self, rect: QRect, preferred_side=Side.RIGHT,
side_priority=(Side.RIGHT, Side.LEFT, Side.BOTTOM, Side.TOP), target_screen=None):
"""choose a side to show based on: maximal screen-window overlap, maximal screen button overlap
side priority or a general priority order"""
best_side = None
best_screen = target_screen
if not target_screen:
best_screen = self._best_screen_overlap(rect)
side_by_overlap = self._calc_screen_overlap_all_sides(rect, best_screen)
best_sides_key = max(side_by_overlap.keys())
best_sides = side_by_overlap[best_sides_key]
if preferred_side in best_sides:
best_side = preferred_side
else:
for side in side_priority:
if side in best_sides:
best_side = side
break
side_positions = self._rect_middle_sides(rect)
position = side_positions[best_side]
self._setup_metrics(position, best_side, best_screen)
self._layout()
self.show()
def _setup_metrics(self, position, side, screen):
self._metrics.override_offset = 0
self._metrics.pointer_position = position
self._metrics.pointer_side = OPPOSITE_SIDES[side]
self._metrics.from_inner(self._central_widget.geometry())
screen_rect = screen.availableGeometry()
distances = calc_side_distance_outside_rect(self._metrics.body_rect, screen_rect)
offset = self._distances_to_offset(distances)
self._metrics.override_offset = offset
def _best_screen_overlap(self, rect):
screen_button_overlap = self._calc_screen_by_overlap(rect)
best_screen_for_button = screen_button_overlap[max(screen_button_overlap.keys())]
return best_screen_for_button
def _distances_to_offset(self, distances):
offsets = [0, 0]
for side, distance in distances.items():
axis = SIDE_AXIS[side]
offsets[axis] -= distance
pointer_axis = SIDE_AXIS[self._metrics.pointer_side]
offsets[pointer_axis] = 0
if offsets[OPPOSITE_AXIS[pointer_axis]] > 0:
offsets[OPPOSITE_AXIS[pointer_axis]] += 10
elif offsets[OPPOSITE_AXIS[pointer_axis]] < 0:
offsets[OPPOSITE_AXIS[pointer_axis]] -= 10
return QPoint(*offsets)
@staticmethod
def _calc_screen_by_overlap(body_rect):
result = {}
for screen in QGuiApplication.screens():
screen_rect = screen.availableGeometry()
intersection = screen_rect.intersected(body_rect)
intersection_area = intersection.width() * intersection.height()
# note ties by intersection are are removed by default
result[intersection_area] = screen
return result
def _calc_screen_overlap_all_sides(self, rect, target_screen):
result = {}
side_position = self._rect_middle_sides(rect)
for side, middle_pos in side_position.items():
opposite_side = OPPOSITE_SIDES[side]
metrics = BalloonMetrics(pointer_side=opposite_side)
metrics.from_inner(self._central_widget.geometry())
metrics.pointer_position = middle_pos
outer_rect = metrics.outer
screen_by_overlap = self._calc_screen_by_overlap(outer_rect)
max_area = outer_rect.width() * outer_rect.height()
for intersection_area, screen in screen_by_overlap.items():
if not screen or screen == target_screen:
result.setdefault(intersection_area / max_area, []).append(side)
return result
[docs] @staticmethod
def find_side_outside_rect_offset(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] = inter_side - test_side
return result
[docs] def leaveEvent(self, a0: QtCore.QEvent) -> None:
if self._owner:
self._owner.leaveEvent(a0)
[docs]class DoubleLabel(QFrame):
def __init__(self, text=('',), parent=None):
super(DoubleLabel, self).__init__(parent=parent)
layout = QGridLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# if you ever allow this to be set you will need to
# call setLabelText to reset the text widths
self._margin: int = 2
self._labels: List[Optional[QLabel]] = [None] * 3
left_label = QLabel()
left_label.setAlignment(Qt.AlignRight)
layout.addWidget(left_label, 0, 0)
left_label.setMargin(self._margin)
self._labels[LEFT_LABEL] = left_label
center_label = QLabel(DEFAULT_SEPARATOR)
center_label.setAlignment(Qt.AlignCenter)
layout.addWidget(center_label, 0, 1)
self._labels[MIDDLE_LABEL] = center_label
right_label = QLabel()
right_label.setAlignment(Qt.AlignLeft)
right_label.setMargin(self._margin)
layout.addWidget(right_label, 0, 2)
self._labels[RIGHT_LABEL] = right_label
self.setLayout(layout)
self._max_digit_width = self._get_max_digit_width()
self._separator = DEFAULT_SEPARATOR
self.setLabels(text)
def _get_max_digit_width(self):
self._font = self._labels[LEFT_LABEL].font()
self._font_metrics = QFontMetrics(self._font)
widths = [self._font_metrics.boundingRect(digit).width() for digit in list('01234567890-')]
return max(widths)
[docs] def setLabelText(self, widget_id, text):
self._check_widget_index(widget_id)
self._labels[widget_id].setText(text)
x = self._labels[LEFT_LABEL].text()
y = self._labels[RIGHT_LABEL].text()
width_x = (self._max_digit_width * len(x)) + self._margin * 2
width_y = (self._max_digit_width * len(y)) + self._margin * 2
width = max(width_x, width_y)
self._labels[LEFT_LABEL].setFixedWidth(width)
self._labels[RIGHT_LABEL].setFixedWidth(width)
self.updateGeometry()
[docs] def setLabels(self, text):
if len(text) not in (1, 2):
raise ValueError("Error double label supports 1 or 2 labels")
if len(text) == 1:
self.setLabelVisible(LEFT_LABEL, False)
self.setLabelVisible(MIDDLE_LABEL, True)
self.setLabelVisible(RIGHT_LABEL, False)
self.setLabelText(MIDDLE_LABEL, text[0])
else:
self.setLabelVisible(LEFT_LABEL, True)
self.setLabelVisible(MIDDLE_LABEL, True)
self.setLabelVisible(RIGHT_LABEL, True)
self.setLabelText(LEFT_LABEL, text[0])
self.setLabelText(MIDDLE_LABEL, self._separator)
self.setLabelText(RIGHT_LABEL, text[1])
self.updateGeometry()
@staticmethod
def _check_widget_index(widget_id):
if widget_id < LEFT_LABEL or widget_id > RIGHT_LABEL:
raise ValueError('Error widget id should be one of LEFT_WIDGET, MIDDLE_WIDGET or RIGHT_WIDGET')
[docs] def setLabelVisible(self, widget_id, visible):
self._check_widget_index(widget_id)
self._labels[widget_id].setVisible(visible)
if not self._labels[LEFT_LABEL].isVisible() and not self._labels[LEFT_LABEL].isVisible():
self._labels[MIDDLE_LABEL].setMargin(self._margin)
else:
self._labels[MIDDLE_LABEL].setMargin(0)
[docs]class MousePositionLabel(DoubleLabel):
def __init__(self, parent=None):
super(MousePositionLabel, self).__init__(parent=parent)
self.timer = QTimer()
self.timer.timeout.connect(self.get_position)
self.timer.setInterval(50)
self.timer.start()
[docs] def get_position(self):
ev = QCursor.pos()
x = '%i' % ev.x()
y = '%i' % ev.y()
self.setLabels([x, y])
if __name__ == '__main__':
app = MyApplication(sys.argv)
window2 = SpeechBalloon()
window2.cornerRadius = 3
app.setActiveWindow(window2)
label = MousePositionLabel(parent=window2)
window2.setCentralWidget(label)
window2.show()
app.exec_()