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