517 lines
16 KiB
Python
517 lines
16 KiB
Python
import re
|
|
import copy
|
|
from typing import Any, Optional, cast
|
|
import re
|
|
from PyQt6.QtCore import QMargins, QPoint, QRect, QSize, QUrl, Qt, pyqtSignal
|
|
from PyQt6.QtGui import QColor, QFont, QFontMetrics, QMouseEvent, QPaintEvent, QPainter, QResizeEvent, QTextOption, QTransform, QBrush
|
|
from PyQt6.QtWidgets import QWidget
|
|
|
|
class Fragment:
|
|
"""A fragment of text to be displayed"""
|
|
|
|
def __init__(
|
|
self,
|
|
text: str,
|
|
font: QFont,
|
|
audio: str = "",
|
|
color: Optional[QColor] = None,
|
|
asis: bool = False,
|
|
) -> None:
|
|
self._text = text
|
|
self._font = font
|
|
self._audio: QUrl = QUrl(audio)
|
|
self._align = QTextOption(
|
|
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBaseline
|
|
)
|
|
self._padding = QMargins()
|
|
self._border = QMargins()
|
|
self._margin = QMargins()
|
|
self._wref = ""
|
|
self._position = QPoint()
|
|
self._rect = QRect()
|
|
self._borderRect = QRect()
|
|
self._clickRect = QRect()
|
|
if color:
|
|
self._color = color
|
|
else:
|
|
self._color = QColor()
|
|
self._background = QColor()
|
|
self._asis = asis
|
|
self._indent = 0
|
|
self._target = "word"
|
|
return
|
|
|
|
def __str__(self) -> str:
|
|
return self.__repr__()
|
|
|
|
def size(self, width: int) -> QSize:
|
|
pos = self._position
|
|
pos.setX(self._indent * 30)
|
|
rect = QRect(pos, QSize(width - self._position.x(), 2000))
|
|
flags = (
|
|
Qt.AlignmentFlag.AlignLeft
|
|
| Qt.AlignmentFlag.AlignBaseline
|
|
| Qt.TextFlag.TextWordWrap
|
|
)
|
|
fm = QFontMetrics(self._font)
|
|
bounding = fm.boundingRect(rect, flags, self._text)
|
|
size = bounding.size()
|
|
size = size.grownBy(self._padding)
|
|
size = size.grownBy(self._border)
|
|
size = size.grownBy(self._margin)
|
|
return size
|
|
|
|
def height(self, width: int) -> int:
|
|
return self.size(width).height()
|
|
|
|
def width(self, width: int) -> int:
|
|
return self.size(width).width()
|
|
|
|
def __repr__(self) -> str:
|
|
return f"({self._position.x()}, {self._position.y()}): {self._text}"
|
|
|
|
def repaintEvent(self, painter: QPainter) -> int:
|
|
painter.save()
|
|
painter.setFont(self._font)
|
|
painter.setPen(self._color)
|
|
rect = QRect()
|
|
rect.setLeft(self._position.x())
|
|
rect.setTop(
|
|
self._position.y()
|
|
+ painter.fontMetrics().descent()
|
|
- painter.fontMetrics().height()
|
|
)
|
|
rect.setWidth(painter.viewport().width() - self._position.x())
|
|
rect.setHeight(2000)
|
|
flags = (
|
|
Qt.AlignmentFlag.AlignLeft
|
|
| Qt.AlignmentFlag.AlignBaseline
|
|
| Qt.TextFlag.TextWordWrap
|
|
)
|
|
bounding = painter.boundingRect(rect, flags, self._text)
|
|
#
|
|
# We need to determine if the text caused a word wrap.
|
|
# If it has wrapped. We need to locate that exact break point
|
|
#
|
|
size = bounding.size()
|
|
|
|
painter.setPen(QColor("#f00"))
|
|
if self._audio.isValid():
|
|
radius = self._borderRect.height() / 2
|
|
painter.drawRoundedRect(self._borderRect, radius, radius)
|
|
if self._wref:
|
|
start = bounding.bottomLeft()
|
|
end = bounding.bottomRight()
|
|
painter.drawLine(start, end)
|
|
painter.setPen(self._color)
|
|
painter.drawText(rect, flags, self._text)
|
|
painter.restore()
|
|
size = size.grownBy(self._margin)
|
|
size = size.grownBy(self._border)
|
|
size = size.grownBy(self._padding)
|
|
return size.height()
|
|
|
|
#
|
|
# Setters
|
|
#
|
|
def setText(self, text: str) -> None:
|
|
self._text = text
|
|
return
|
|
|
|
def setTarget(self, target: str) -> None:
|
|
self._target = target
|
|
return
|
|
|
|
def setFont(self, font: QFont) -> None:
|
|
self._font = font
|
|
return
|
|
|
|
def setAudio(self, audio: str | QUrl) -> None:
|
|
if type(audio) is str:
|
|
self._audio = QUrl(audio)
|
|
else:
|
|
self._audio = cast(QUrl, audio)
|
|
return
|
|
|
|
def setAlign(self, align: QTextOption) -> None:
|
|
self._align = align
|
|
return
|
|
|
|
def setRect(self, rect: QRect) -> None:
|
|
self._rect = rect
|
|
return
|
|
|
|
def setPadding(self, *args: int, **kwargs: int) -> None:
|
|
top = kwargs.get("top", -1)
|
|
right = kwargs.get("right", -1)
|
|
bottom = kwargs.get("bottom", -1)
|
|
left = kwargs.get("left", -1)
|
|
if top > -1 or right > -1 or bottom > -1 or left > -1:
|
|
if top >= 0:
|
|
self._padding.setTop(top)
|
|
if right >= 0:
|
|
self._padding.setRight(right)
|
|
if bottom >= 0:
|
|
self._padding.setBottom(bottom)
|
|
if left >= 0:
|
|
self._padding.setLeft(left)
|
|
return
|
|
if len(args) == 4:
|
|
(top, right, bottom, left) = [args[0], args[1], args[2], args[3]]
|
|
elif len(args) == 3:
|
|
(top, right, bottom, left) = [args[0], args[1], args[2], args[1]]
|
|
elif len(args) == 2:
|
|
(top, right, bottom, left) = [args[0], args[1], args[0], args[1]]
|
|
elif len(args) == 1:
|
|
(top, right, bottom, left) = [args[0], args[0], args[0], args[0]]
|
|
else:
|
|
raise Exception("argument error")
|
|
self._padding = QMargins(left, top, right, bottom)
|
|
return
|
|
|
|
def setBorder(self, *args: int, **kwargs: int) -> None:
|
|
top = kwargs.get("top", -1)
|
|
right = kwargs.get("right", -1)
|
|
bottom = kwargs.get("bottom", -1)
|
|
left = kwargs.get("left", -1)
|
|
if top > -1 or right > -1 or bottom > -1 or left > -1:
|
|
if top >= 0:
|
|
self._border.setTop(top)
|
|
if right >= 0:
|
|
self._border.setRight(right)
|
|
if bottom >= 0:
|
|
self._border.setBottom(bottom)
|
|
if left >= 0:
|
|
self._border.setLeft(left)
|
|
return
|
|
if len(args) == 4:
|
|
(top, right, bottom, left) = [args[0], args[1], args[2], args[3]]
|
|
elif len(args) == 3:
|
|
(top, right, bottom, left) = [args[0], args[1], args[2], args[1]]
|
|
elif len(args) == 2:
|
|
(top, right, bottom, left) = [args[0], args[1], args[0], args[1]]
|
|
elif len(args) == 1:
|
|
(top, right, bottom, left) = [args[0], args[0], args[0], args[0]]
|
|
else:
|
|
raise Exception("argument error")
|
|
self._border = QMargins(left, top, right, bottom)
|
|
return
|
|
|
|
def setMargin(self, *args: int, **kwargs: int) -> None:
|
|
top = kwargs.get("top", -1)
|
|
right = kwargs.get("right", -1)
|
|
bottom = kwargs.get("bottom", -1)
|
|
left = kwargs.get("left", -1)
|
|
|
|
if top > -1 or right > -1 or bottom > -1 or left > -1:
|
|
if top >= 0:
|
|
self._margin.setTop(top)
|
|
if right >= 0:
|
|
self._margin.setRight(right)
|
|
if bottom >= 0:
|
|
self._margin.setBottom(bottom)
|
|
if left >= 0:
|
|
self._margin.setLeft(left)
|
|
return
|
|
if len(args) == 4:
|
|
(top, right, bottom, left) = [args[0], args[1], args[2], args[3]]
|
|
elif len(args) == 3:
|
|
(top, right, bottom, left) = [args[0], args[1], args[2], args[1]]
|
|
elif len(args) == 2:
|
|
(top, right, bottom, left) = [args[0], args[1], args[0], args[1]]
|
|
elif len(args) == 1:
|
|
(top, right, bottom, left) = [args[0], args[0], args[0], args[0]]
|
|
else:
|
|
raise Exception("argument error")
|
|
self._margin = QMargins(left, top, right, bottom)
|
|
return
|
|
|
|
def setWRef(self, ref: str) -> None:
|
|
self._wref = ref
|
|
return
|
|
|
|
def setPosition(self, pnt: QPoint) -> None:
|
|
self._position = pnt
|
|
return
|
|
|
|
def setBorderRect(self, rect: QRect) -> None:
|
|
self._borderRect = rect
|
|
return
|
|
|
|
def setClickRect(self, rect: QRect) -> None:
|
|
self._clickRect = rect
|
|
return
|
|
|
|
def setColor(self, color: QColor) -> None:
|
|
self._color = color
|
|
return
|
|
|
|
def setBackground(self, color: QColor) -> None:
|
|
self._background = color
|
|
return
|
|
def setIndent(self, indent: int) -> None:
|
|
self._indent = indent
|
|
return
|
|
|
|
#
|
|
# Getters
|
|
#
|
|
def wRef(self) -> str:
|
|
return self._wref
|
|
|
|
def text(self) -> str:
|
|
return self._text
|
|
|
|
def font(self) -> QFont:
|
|
return self._font
|
|
|
|
def audio(self) -> QUrl:
|
|
return self._audio
|
|
|
|
def align(self) -> QTextOption:
|
|
return self._align
|
|
|
|
def rect(self) -> QRect:
|
|
return self._rect
|
|
|
|
def padding(self) -> QMargins:
|
|
return self._padding
|
|
|
|
def border(self) -> QMargins:
|
|
return self._border
|
|
|
|
def margin(self) -> QMargins:
|
|
return self._margin
|
|
|
|
def position(self) -> QPoint:
|
|
return self._position
|
|
|
|
def borderRect(self) -> QRect:
|
|
return self._borderRect
|
|
|
|
def clickRect(self) -> QRect:
|
|
return self._clickRect
|
|
|
|
def color(self) -> QColor:
|
|
return self._color
|
|
|
|
def asis(self) -> bool:
|
|
return self._asis
|
|
|
|
def indent(self) -> int:
|
|
return self._indent
|
|
|
|
class Line:
|
|
parseText = None
|
|
def __init__(self) -> None:
|
|
self._maxHeight = -1
|
|
self._baseLine = -1
|
|
self._leading = -1
|
|
self._fragments: list[Fragment] = []
|
|
return
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
"|".join([x.text() for x in self._fragments])
|
|
+ f"|{self._maxHeight}"
|
|
)
|
|
@classmethod
|
|
def setParseText(cls, call) -> None:
|
|
print(call)
|
|
cls.parseText = call
|
|
return
|
|
|
|
def repaintEvent(self, painter: QPainter) -> int:
|
|
#
|
|
# we do not have an event field because we are not a true widget
|
|
#
|
|
lineSpacing = 0
|
|
for frag in self._fragments:
|
|
ls = frag.repaintEvent(painter)
|
|
if ls > lineSpacing:
|
|
lineSpacing = ls
|
|
return lineSpacing
|
|
|
|
|
|
def addFragment(
|
|
self,
|
|
frag: Fragment,
|
|
) -> None:
|
|
SPEAKER = "\U0001F508"
|
|
|
|
if frag.audio().isValid():
|
|
frag.setText(frag.text() + " " + SPEAKER)
|
|
|
|
text = frag.text()
|
|
text = re.sub(r"\*", "\u2022", text)
|
|
text = re.sub(r"\{ldquo\}", "\u201c", text)
|
|
text = re.sub(r"\{rdquo\}", "\u201d", text)
|
|
frag.setText(text)
|
|
if frag.audio().isValid():
|
|
frag.setPadding(3, 0, 0, 5)
|
|
frag.setBorder(1)
|
|
frag.setMargin(0, 0, 0, 0)
|
|
if Line.parseText:
|
|
items = Line.parseText(frag)
|
|
self._fragments += items
|
|
else:
|
|
self._fragments.append(frag)
|
|
return
|
|
|
|
def finalizeLine(self, width: int, base: int) -> None:
|
|
"""Create all of the positions for all the fragments."""
|
|
#
|
|
# Find the maximum hight and max baseline
|
|
#
|
|
maxHeight = -1
|
|
baseLine = -1
|
|
leading = -1
|
|
for frag in self._fragments:
|
|
fm = QFontMetrics(frag.font())
|
|
height = frag.height(width)
|
|
bl = fm.height() - fm.descent()
|
|
if fm.leading() > leading:
|
|
leading = fm.leading()
|
|
if height > maxHeight:
|
|
maxHeight = height
|
|
if bl > baseLine:
|
|
baseLine = bl
|
|
self._baseLine = baseLine
|
|
self._maxHeight = maxHeight
|
|
self._leading = leading
|
|
x = 0
|
|
for frag in self._fragments:
|
|
left = frag.indent() * 30
|
|
if x < left:
|
|
x = left
|
|
#
|
|
# We need to calculate the location to draw the
|
|
# text. We also need to calculate the bounding Rectangle
|
|
# for this fragment
|
|
#
|
|
size = frag.size(width)
|
|
fm = QFontMetrics(frag.font())
|
|
offset = (
|
|
frag.margin().left()
|
|
+ frag.border().left()
|
|
+ frag.padding().left()
|
|
)
|
|
frag.setPosition(QPoint(x + offset, self._baseLine))
|
|
if not frag.border().isNull() or not frag.wRef():
|
|
#
|
|
# self._baseLine is where the text will be drawn
|
|
# fm.descent is the distance from the baseline of the
|
|
# text to the bottom of the rect
|
|
# The top of the bounding rect is at self._baseLine
|
|
# + fm.descent - rect.height
|
|
# The border is drawn at top-padding-border-margin+marin
|
|
#
|
|
top = self._baseLine + fm.descent() - fm.height()
|
|
y = top - frag.padding().top() - frag.border().top()
|
|
pos = QPoint(x, y)
|
|
rect = QRect(pos, size.shrunkBy(frag.margin()))
|
|
frag.setBorderRect(rect)
|
|
pos.setY(pos.y() + base)
|
|
frag.setClickRect(QRect(pos, size.shrunkBy(frag.margin())))
|
|
x += size.width()
|
|
return
|
|
|
|
def getLine(self) -> list[Fragment]:
|
|
return self._fragments
|
|
|
|
def getLineSpacing(self) -> int:
|
|
return self._leading + self._maxHeight
|
|
|
|
class Definition(QWidget):
|
|
pronounce = pyqtSignal(str)
|
|
|
|
def __init__(
|
|
self, word: Optional[Any] = None, *args: Any, **kwargs: Any
|
|
) -> None:
|
|
super(Definition, self).__init__(*args, **kwargs)
|
|
self._word = word
|
|
if word is not None:
|
|
self.setWord(word)
|
|
return
|
|
|
|
def setWord(self, word: Any) -> None:
|
|
self._word = word
|
|
lines = word.get_def()
|
|
assert lines is not None
|
|
self._lines = lines
|
|
self._buttons: list[Fragment] = []
|
|
base = 0
|
|
|
|
for line in self._lines:
|
|
line.finalizeLine(self.width(), base)
|
|
for frag in line.getLine():
|
|
if frag.audio().isValid():
|
|
self._buttons.append(frag)
|
|
if frag.wRef():
|
|
print(f"Adding {frag} as an anchor")
|
|
self._buttons.append(frag)
|
|
base += line.getLineSpacing()
|
|
self.setFixedHeight(base)
|
|
return
|
|
|
|
def resizeEvent(self, event: Optional[QResizeEvent] = None) -> None:
|
|
base = 0
|
|
for line in self._lines:
|
|
line.finalizeLine(self.width(), base)
|
|
base += line.getLineSpacing()
|
|
self.setFixedHeight(base)
|
|
super(Definition, self).resizeEvent(event)
|
|
return
|
|
|
|
_downFrag: Optional[Fragment | None] = None
|
|
|
|
def mousePressEvent(self, event: Optional[QMouseEvent]) -> None:
|
|
if not event:
|
|
return super().mousePressEvent(event)
|
|
print(f"mousePressEvent: {event.pos()}")
|
|
for frag in self._buttons:
|
|
rect = frag.clickRect()
|
|
if rect.contains(event.pos()):
|
|
self._downFrag = frag
|
|
return
|
|
return super().mousePressEvent(event)
|
|
|
|
def mouseReleaseEvent(self, event: Optional[QMouseEvent]) -> None:
|
|
if not event:
|
|
return super().mouseReleaseEvent(event)
|
|
if self._downFrag is not None and self._downFrag.clickRect().contains(
|
|
event.pos()
|
|
):
|
|
audio = self._downFrag.audio().url()
|
|
print(audio)
|
|
self.pronounce.emit(audio)
|
|
print("emit done")
|
|
self._downFrag = None
|
|
return
|
|
self._downFrag = None
|
|
return super().mouseReleaseEvent(event)
|
|
|
|
def paintEvent(self, _: Optional[QPaintEvent]) -> None: # noqa
|
|
painter = QPainter(self)
|
|
painter.save()
|
|
painter.setBrush(QBrush())
|
|
painter.setPen(QColor("white"))
|
|
|
|
#
|
|
# Each line needs a base calculated. To do that, we need to find the
|
|
# bounding rectangle of the text. Once we have the bounding rectangle,
|
|
# we can use the descendant to calculate the baseline within that
|
|
# bounding box.
|
|
#
|
|
# All text on this line needs to be on the same baseline
|
|
#
|
|
assert self._lines is not None
|
|
base = 0
|
|
for line in self._lines:
|
|
transform = QTransform()
|
|
transform.translate(0, base)
|
|
painter.setTransform(transform)
|
|
base += line.repaintEvent(painter)
|
|
painter.restore()
|
|
return
|