import unicodedata from typing import Any, Callable, Optional, Self, TypedDict, cast from PyQt6.QtCore import QMargins, QPoint, QPointF, QRect, QRectF, QSize, Qt, QUrl, pyqtSignal from PyQt6.QtGui import ( QBrush, QColor, QFont, QFontDatabase, QFontMetrics, QMouseEvent, QPainter, QPaintEvent, QResizeEvent, QTextCharFormat, QTextLayout, QTextOption, ) from PyQt6.QtWidgets import QWidget from trycast import trycast class Fragment: """A fragment of text to be displayed""" _indentAmount = 35 def __init__( self, which: str | Self | None = None, 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._layout = QTextLayout() if font is None: self._layout.setFont( QFontDatabase.font("OpenDyslexic", None, 20) ) else: self._layout.setFont(font) align = QTextOption( Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBaseline ) self._layout.setTextOption(align) self._audio: QUrl = QUrl(audio) self._padding = QMargins() self._border = QMargins() self._margin = QMargins() self._wref = "" 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 if which is not None: self.setText(which) return def size(self) -> QSize: return self.paintEvent() def height(self) -> int: return self.size().height() def width(self) -> int: return self.size().width() def __repr__(self) -> str: rect = self._layout.boundingRect() text = self._layout.text() return f"{text}: (({rect.x()}, {rect.y()}), {rect.width()}, {rect.height()})" def doLayout(self, width: int) -> QPointF: leading = QFontMetrics(self._layout.font()).leading() eol = self._layout.position() base = 0 indent = 0 self._layout.setCacheEnabled(True) self._layout.beginLayout() while True: line = self._layout.createLine() if not line.isValid(): break line.setLineWidth(width - self._layout.position().x()) line.setPosition(QPointF(indent, base+leading)) rect = line.naturalTextRect() eol = rect.bottomRight() assert isinstance(eol, QPointF) base += line.height() indent = self.pixelIndent() - self._layout.position().x() self._layout.endLayout() result = eol return result def paintEvent(self, painter: Optional[QPainter] | None = None) -> QSize: rect = self._layout.boundingRect() size = rect.size() assert size is not None if painter is None: return QSize(int(size.width()), int(size.height())) painter.save() self._layout.draw(painter, QPointF(0,0)) # # TODO: draw the rounded rect around audio buttons # painter.brush().setColor(Qt.GlobalColor.green) for fmt in self._layout.formats(): if fmt.format.isAnchor(): #text = self._layout.text()[fmt.start:fmt.start+fmt.length] runs = self._layout.glyphRuns(fmt.start, fmt.length) bb = runs[0].boundingRect() bb.moveTo(bb.topLeft() + self._layout.position()) painter.drawRect(bb) #print(f"({bb.left()}-{bb.right()}, {bb.top()}-{bb.bottom()}): {text}") painter.restore() return QSize(int(size.width()), int(size.height())) # # Setters # def addText(self, text: str, fmt: Optional[QTextCharFormat] = None) -> None: oldText = self._layout.text() self._layout.setText(oldText + text) if Line.parseText: self._layout = Line.parseText(self) if fmt is not None: fr = QTextLayout.FormatRange() fr.format = fmt fr.length = len(self._layout.text()) - len(oldText) fr.start = len(oldText) fmts = self._layout.formats() fmts.append(fr) self._layout.setFormats(fmts) return def setText(self, text: str) -> None: text = unicodedata.normalize("NFKD",text) self._layout.setText(text) if Line.parseText: self._layout = Line.parseText(self) if self.audio().isValid(): fr = QTextLayout.FormatRange() fr.start=0 fr.length = len(self._layout.text()) fmt = QTextCharFormat() fmt.setAnchor(True) fmt.setAnchorHref(self._audio.toString()) fr.format = fmt formats = self._layout.formats() formats.append(fr) self._layout.setFormats(formats) return def setFont(self, font: QFont) -> None: self._layout.setFont(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._layout.setTextOption(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._layout.setPosition(QPointF(pnt.x(), pnt.y())) 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 background(self) -> QColor: return self._background def wRef(self) -> str: return self._wref def text(self) -> str: return self._layout.text() def font(self) -> QFont: return self._layout.font() def audio(self) -> QUrl: return self._audio def align(self) -> QTextOption: return self._layout.textOption() 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) -> QPointF: return self._layout.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 def layout(self) -> QTextLayout: return self._layout 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 # pos = QSize(0,0) for frag in self._fragments: pos = frag.paintEvent(painter) return pos.height() def addFragment( self, frags: Fragment | list[Fragment], ) -> None: #SPEAKER = "\U0001F508" if not isinstance(frags, list): frags = [ frags, ] self._fragments += frags return def finalizeLine(self, width: int, base: int) -> None: """Create all of the positions for all the fragments.""" # # Each fragment needs to be positioned to the left of the # last fragment or at the indent level. # It needs to be aligned with the baseline of all the # other fragments in the line. # left = 0 # Left size of rect maxHeight = 0 for frag in self._fragments: if left < frag.pixelIndent(): left = frag.pixelIndent() frag.setPosition(QPoint(left, base)) eol =frag.doLayout(width) left = int(eol.x()+0.5) if frag.layout().lineCount() > 1: base = int(eol.y()+0.5) if eol.y() > maxHeight: maxHeight = eol.y() self._maxHeight = int(maxHeight+0.5) self._leading = 0 return def getLine(self) -> list[Fragment]: return self._fragments def getLineSpacing(self) -> int: return self._leading + self._maxHeight class Clickable(TypedDict): bb: QRectF frag: Fragment fmt: QTextCharFormat 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[Clickable] = [] base = 0 for line in self._lines: line.finalizeLine(self.width(), base) base += line.getLineSpacing() for line in self._lines: for frag in line.getLine(): layout = frag.layout() for fmtRng in layout.formats(): if fmtRng.format.isAnchor(): runs = layout.glyphRuns(fmtRng.start, fmtRng.length) bb = runs[0].boundingRect() pos = layout.position() text = frag.text()[fmtRng.start:fmtRng.start + fmtRng.length] new = bb.topLeft() + pos print(f"({bb.left()}, {bb.top()}), ({pos.x()}, {pos.y()}), ({new.x()}, {new.y()}): {text}") bb.moveTo(bb.topLeft() + pos) self._buttons.append( {'bb': bb, 'fmt': fmtRng.format, 'frag': frag, } ) self.setFixedHeight(base) return def resizeEvent(self, event: Optional[QResizeEvent] = None) -> None: base = 0 for idx, line in enumerate(self._lines): line.finalizeLine(self.width(), base) base += line.getLineSpacing() self.setFixedHeight(base) super(Definition, self).resizeEvent(event) return _downClickable: Optional[Clickable] = None def mousePressEvent(self, event: Optional[QMouseEvent]) -> None: if not event: return super().mousePressEvent(event) print(f"mousePressEvent: {event.position()}") for clk in self._buttons: if clk["bb"].contains(event.position()): print("inside") self._downClickable = clk return return super().mousePressEvent(event) def mouseReleaseEvent(self, event: Optional[QMouseEvent]) -> None: if not event: return super().mouseReleaseEvent(event) if (self._downClickable is not None and self._downClickable["bb"].contains(event.position()) ): print(f"mousePressPseudoEvent: {event.position()}") clk = self._downClickable bb = clk['bb'] print(f"({bb.left()}-{bb.right()}, {bb.top()}-{bb.bottom()})", clk["fmt"].anchorHref(),) #self.pronounce.emit(audio) self._downClickable = None return self._downClickable = None return super().mouseReleaseEvent(event) def paintEvent(self, _: Optional[QPaintEvent]) -> None: # noqa painter = QPainter(self) painter.setBrush(QBrush()) painter.setPen(QColor("white")) red = QColor("red") # # 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 for idx, line in enumerate(self._lines): text = '' for frag in line.getLine(): text += frag.text() + '_' line.paintEvent(painter) green = QColor("green") for clickRect in self._buttons: painter.setPen(green) painter.drawRect(clickRect['bb']) painter.setPen(red) bb = clickRect['frag'].layout().boundingRect() bb.moveTo(clickRect['frag'].layout().position()) painter.drawRect(bb) return