547 lines
17 KiB
Python
547 lines
17 KiB
Python
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
|