Files
esl-reader/lib/definition.py
2024-05-14 11:08:02 -04:00

550 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 lib.sounds import SoundOff
from PyQt6.QtWidgets import QWidget
class MyPointF(QPointF):
def __repr__(self):
return f"({self.x()}, {self.y()})"
def __str__(self):
return self.__repr__()
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():
runs = self._layout.glyphRuns(fmt.start, fmt.length)
bb = runs[0].boundingRect()
bb.moveTo(bb.topLeft() + self._layout.position())
url = QUrl(fmt.format.anchorHref())
if url.scheme() == 'audio':
painter.setPen(QColor('red'))
radius = (bb.topLeft() - bb.bottomLeft()).manhattanLength()/4
painter.drawRoundedRect(bb, radius,radius)
else:
painter.setPen(QColor("blue"))
#painter.drawRect(bb)
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(QUrl)
alert = pyqtSignal()
newWord = pyqtSignal(str)
def __init__(
self, word: Optional[Any] = None, *args: Any, **kwargs: Any
) -> None:
super(Definition, self).__init__(*args, **kwargs)
self._sound = SoundOff()
self.pronounce.connect(self._sound.playSound)
self.alert.connect(self._sound.alert)
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
base = 0
for line in self._lines:
line.finalizeLine(self.width(), base)
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
_downClickable: Optional[Clickable] = None
def mousePressEvent(self, event: Optional[QMouseEvent]) -> None:
if not event:
return super().mousePressEvent(event)
position = MyPointF(event.position())
print(f"mousePressEvent: {position}")
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)
assert len(runs) == 1
bb = runs[0].boundingRect()
bb.moveTo(bb.topLeft() + layout.position())
if bb.contains(event.position()):
self._downClickable = {
'bb': bb,
'fmt': fmtRng.format,
'frag': frag,
}
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
url = QUrl(clk['fmt'].anchorHref())
if url.scheme() == 'audio':
url.setScheme('https')
self.pronounce.emit(url)
elif url.scheme() == 'word':
self.newWord.emit(url.path())
elif url.scheme() == 'sense':
self.newWord.emit(url.path())
else:
print(f"{clk['fmt'].anchorHref()}")
self.alert.emit()
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"))
#
# 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 line in self._lines:
line.paintEvent(painter)
return