Layout is good, click boxes is wrong

This commit is contained in:
Christopher T. Johnson
2024-05-07 11:26:15 -04:00
parent 51b1121176
commit 7d2532d775
6 changed files with 568 additions and 574 deletions

View File

@@ -1,20 +1,23 @@
import re
from typing import Any, Callable, Optional, Self, cast, overload
import unicodedata
from typing import Any, Callable, Optional, Self, TypedDict, cast
from PyQt6.QtCore import QMargins, QPoint, QRect, QSize, Qt, QUrl, pyqtSignal
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,
QTransform,
)
from PyQt6.QtWidgets import QWidget
from trycast import trycast
class Fragment:
@@ -24,7 +27,7 @@ class Fragment:
def __init__(
self,
which: str | Self,
which: str | Self | None = None,
font: QFont | None = None,
audio: str = "",
color: Optional[QColor] = None,
@@ -34,19 +37,22 @@ class Fragment:
for k, v in which.__dict__.items():
self.__dict__[k] = v
return
self._text: str = which
self._layout = QTextLayout()
if font is None:
raise TypeError("Missing required parameter 'font'")
self._font = font
self._audio: QUrl = QUrl(audio)
self._align = QTextOption(
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._position = QPoint()
self._rect = QRect()
self._borderRect = QRect()
self._clickRect = QRect()
@@ -57,133 +63,109 @@ class Fragment:
self._background = QColor()
self._asis = asis
self._indent = 0
self._target = "word"
if which is not None:
self.setText(which)
return
def __str__(self) -> str:
return self.__repr__()
def size(self) -> QSize:
return self.paintEvent()
def size(self, width: int) -> QSize:
return self.paintEvent(width)
def height(self) -> int:
return self.size().height()
def height(self, width: int) -> int:
return self.size(width).height()
def width(self, width: int) -> int:
return self.size(width).width()
def width(self) -> int:
return self.size().width()
def __repr__(self) -> str:
return f"({self._position.x()}, {self._position.y()}): {self._text}"
rect = self._layout.boundingRect()
text = self._layout.text()
return f"{text}: (({rect.x()}, {rect.y()}), {rect.width()}, {rect.height()})"
@overload
def paintEvent(self, widthSrc: int) -> QSize:
...
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
@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)
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 size
return QSize(int(size.width()), int(size.height()))
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
)
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 size.height()
return QSize(int(size.width()), int(size.height()))
#
# Setters
#
def setText(self, text: str) -> None:
self._text = text
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 setTarget(self, target: str) -> None:
self._target = target
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._font = font
self._layout.setFont(font)
return
def setAudio(self, audio: str | QUrl) -> None:
@@ -194,7 +176,7 @@ class Fragment:
return
def setAlign(self, align: QTextOption) -> None:
self._align = align
self._layout.setTextOption(align)
return
def setRect(self, rect: QRect) -> None:
@@ -291,7 +273,7 @@ class Fragment:
return
def setPosition(self, pnt: QPoint) -> None:
self._position = pnt
self._layout.setPosition(QPointF(pnt.x(), pnt.y()))
return
def setBorderRect(self, rect: QRect) -> None:
@@ -317,20 +299,23 @@ class Fragment:
#
# Getters
#
def background(self) -> QColor:
return self._background
def wRef(self) -> str:
return self._wref
def text(self) -> str:
return self._text
return self._layout.text()
def font(self) -> QFont:
return self._font
return self._layout.font()
def audio(self) -> QUrl:
return self._audio
def align(self) -> QTextOption:
return self._align
return self._layout.textOption()
def rect(self) -> QRect:
return self._rect
@@ -344,8 +329,8 @@ class Fragment:
def margin(self) -> QMargins:
return self._margin
def position(self) -> QPoint:
return self._position
def position(self) -> QPointF:
return self._layout.position()
def borderRect(self) -> QRect:
return self._borderRect
@@ -365,6 +350,8 @@ class Fragment:
def pixelIndent(self) -> int:
return self._indent * self._indentAmount
def layout(self) -> QTextLayout:
return self._layout
class Line:
parseText = None
@@ -391,99 +378,48 @@ class Line:
#
# we do not have an event field because we are not a true widget
#
lineSpacing = 0
pos = QSize(0,0)
for frag in self._fragments:
ls = frag.paintEvent(painter)
if ls > lineSpacing:
lineSpacing = ls
return lineSpacing
pos = frag.paintEvent(painter)
return pos.height()
def addFragment(
self,
frags: Fragment | list[Fragment],
) -> None:
SPEAKER = "\U0001F508"
#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)
self._fragments += frags
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
# 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.
#
maxHeight = -1
baseLine = -1
leading = -1
left = 0 # Left size of rect
maxHeight = 0
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()
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]:
@@ -492,6 +428,10 @@ class Line:
def getLineSpacing(self) -> int:
return self._leading + self._maxHeight
class Clickable(TypedDict):
bb: QRectF
frag: Fragment
fmt: QTextCharFormat
class Definition(QWidget):
pronounce = pyqtSignal(str)
@@ -510,64 +450,77 @@ class Definition(QWidget):
lines: list[Line] = word.get_def()
assert lines is not None
self._lines = lines
self._buttons: list[Fragment] = []
self._buttons: list[Clickable] = []
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()
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 line in self._lines:
for idx, line in enumerate(self._lines):
line.finalizeLine(self.width(), base)
base += line.getLineSpacing()
self.setFixedHeight(base)
super(Definition, self).resizeEvent(event)
return
_downFrag: Optional[Fragment | None] = None
_downClickable: Optional[Clickable] = 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
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._downFrag is not None and self._downFrag.clickRect().contains(
event.pos()
if (self._downClickable is not None and
self._downClickable["bb"].contains(event.position())
):
audio = self._downFrag.audio().url()
print(audio)
self.pronounce.emit(audio)
print("emit done")
self._downFrag = None
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._downFrag = None
self._downClickable = None
return super().mouseReleaseEvent(event)
def paintEvent(self, _: Optional[QPaintEvent]) -> None: # noqa
painter = QPainter(self)
painter.save()
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,
@@ -577,11 +530,17 @@ class Definition(QWidget):
# 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()
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