Files
esl-reader/lib/definition.py
Christopher T. Johnson 814206148a Misc cleanups
2024-04-16 10:48:46 -04:00

580 lines
18 KiB
Python

import re
from typing import Any, Optional, Self, cast, overload
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"""
_indentAmount = 35
def __init__(
self,
which: str|Self,
font: QFont|None = None,
audio: str = "",
color: Optional[QColor] = None,
asis: bool = False,
) -> None:
if isinstance(which, Fragment):
for k,v in which.__dict__.items():
self.__dict__[k] = v
return
self._text:str = which
if font is None:
raise TypeError("Missing required parameter 'font'")
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:
return self.paintEvent(width)
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}"
@overload
def paintEvent(self, widthSrc:int) -> QSize:
...
@overload
def paintEvent(self, widthSrc: QPainter) -> int:
...
def paintEvent(self, widthSrc) -> int|QSize:
if isinstance(widthSrc, QPainter):
viewportWidth = widthSrc.viewport().width()
painter = widthSrc
else:
viewportWidth = widthSrc
painter = None
fm = QFontMetrics(self._font)
top = (
self._position.y()
+ fm.descent()
- fm.height()
)
left = self._position.x()
width = viewportWidth - left
height = 2000
rect = QRect(left, top, width, height)
indent = self._indent * self._indentAmount
flags = (
Qt.AlignmentFlag.AlignLeft
| Qt.AlignmentFlag.AlignBaseline
)
boundingNoWrap = fm.boundingRect(
rect, flags|Qt.TextFlag.TextSingleLine, self._text
)
bounding = fm.boundingRect(
rect, flags|Qt.TextFlag.TextWordWrap, self._text
)
text = self._text
remainingText = ''
if boundingNoWrap.height() < bounding.height():
#
# This is not optimal, but it is only a few iterations
#
lastSpace = 0
char = 0
pos = rect.x()
while pos < rect.right():
if text[char] == ' ':
lastSpace = char
pos += fm.horizontalAdvance(
text[char]
)
char += 1
if lastSpace > 0:
remainingText = text[lastSpace+1:]
text = text[:lastSpace]
size = boundingNoWrap.size()
boundingNoWrap = fm.boundingRect(
rect, flags|Qt.TextFlag.TextSingleLine, text
)
rect.setSize(boundingNoWrap.size())
if remainingText != '':
top += size.height()
remainingRect = QRect(
indent, top,
viewportWidth - indent, height
)
boundingRemaingRect = fm.boundingRect(
remainingRect, flags | Qt.TextFlag.TextWordWrap, remainingText
)
size = size.grownBy(
QMargins(
0,0,0, boundingRemaingRect.height()
)
)
remainingRect.setSize(boundingRemaingRect.size())
size = size.grownBy(self._margin)
size = size.grownBy(self._border)
size = size.grownBy(self._padding)
if painter is None:
return size
painter.save()
painter.setFont(self._font)
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)
if self._background.isValid():
brush = painter.brush()
brush.setColor(self._background)
brush.setStyle(Qt.BrushStyle.SolidPattern)
painter.setBrush(brush)
painter.fillRect(rect,brush)
painter.drawText(rect, flags, text)
if remainingText:
if self._background.isValid():
painter.fillRect(remainingRect, brush)
painter.drawText(remainingRect, flags|Qt.TextFlag.TextWordWrap, remainingText)
painter.restore()
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
def pixelIndent(self) -> int:
return self._indent * self._indentAmount
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:
cls.parseText = call
return
def paintEvent(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.paintEvent(painter)
if ls > lineSpacing:
lineSpacing = ls
return lineSpacing
def addFragment(self, frags: Fragment|list[Fragment],) -> None:
SPEAKER = "\U0001F508"
if not isinstance(frags, list):
frags = [frags, ]
for frag in frags:
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.pixelIndent()
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:list[Line] = 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.paintEvent(painter)
painter.restore()
return