import re from typing import Any, Callable, Optional, Self, cast, overload from PyQt6.QtCore import QMargins, QPoint, QRect, QSize, Qt, QUrl, pyqtSignal from PyQt6.QtGui import ( QBrush, QColor, QFont, QFontMetrics, QMouseEvent, QPainter, QPaintEvent, QResizeEvent, QTextOption, QTransform, ) 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: QPainter | int) -> 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: Callable) -> 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