Compare commits

...

10 Commits

Author SHA1 Message Date
Christopher T. Johnson
73a96e79a2 update requirements 2024-05-24 11:24:22 -04:00
Christopher T. Johnson
0acba3ed9b More progress on all entites in MW data load 2024-05-14 11:08:02 -04:00
Christopher T. Johnson
7c65b466f1 Mostly working! 2024-05-10 12:08:21 -04:00
Christopher T. Johnson
f97305e36e Mostly working clickables. 2024-05-08 14:20:32 -04:00
Christopher T. Johnson
7d2532d775 Layout is good, click boxes is wrong 2024-05-07 11:26:15 -04:00
Christopher T. Johnson
51b1121176 Lint 2024-04-16 11:50:26 -04:00
Christopher T. Johnson
f1ad24d70a Clean plugins too 2024-04-16 11:48:19 -04:00
Christopher T. Johnson
1bce000978 Lots of additions and fixes 2024-04-16 10:52:34 -04:00
Christopher T. Johnson
303dbe6fe0 Add subduedBackground 2024-04-16 10:51:57 -04:00
Christopher T. Johnson
51a924b510 Make SoundOff a true singleton 2024-04-16 10:51:08 -04:00
11 changed files with 1241 additions and 781 deletions

View File

@@ -1,7 +1,7 @@
#!/bin/bash
source venv/bin/activate
set -e
isort --profile black *.py lib/*.py
black -l 80 *.py lib/*.py
flynt *.py lib/*.py
mypy esl_reader.py
isort --profile black *.py lib/*.py plugins/*.py
black -l 80 *.py lib/*.py plugins/*.py
flynt *.py lib/*.py plugins/*.py
mypy esl_reader.py plugins/*.py

38
deftest.py Normal file → Executable file
View File

@@ -3,20 +3,36 @@ import faulthandler
import os
import signal
import sys
from typing import cast
from typing import Any, cast
from PyQt6.QtCore import QResource, QSettings
from PyQt6.QtCore import QResource, QSettings, Qt, pyqtSlot
from PyQt6.QtGui import QFontDatabase
from PyQt6.QtSql import QSqlDatabase, QSqlQuery
from PyQt6.QtWidgets import QApplication
from PyQt6.QtWidgets import QApplication, QScrollArea
from lib import DefinitionArea, Word
from lib import Word
from lib.sounds import SoundOff
from lib.utils import query_error
from lib.words import Definition
class DefinitionArea(QScrollArea):
def __init__(self, w: Word, *args: Any, **kwargs: Any) -> None:
super(DefinitionArea, self).__init__(*args, *kwargs)
self.definition = Definition(w)
self.setWidget(self.definition)
self.setWidgetResizable(True)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.definition.newWord.connect(self.newWord)
return
def monkeyClose(self, event):
@pyqtSlot(str)
def newWord(self, word:str) -> None:
print(f"newWord: {word}")
w = Word(word)
self.definition.setWord(w)
return
def closeEvent(self, event):
settings = QSettings("Troglodite", "esl_reader")
settings.setValue("geometry", self.saveGeometry())
super(DefinitionArea, self).closeEvent(event)
@@ -66,12 +82,15 @@ def main() -> int:
):
query_error(query)
word = Word("cowbell")
word = Word("lower")
snd = SoundOff()
DefinitionArea.closeEvent = monkeyClose
print("Pre widget")
widget = DefinitionArea(word) # xnoqa: F841
print("post widget")
settings = QSettings("Troglodite", "esl_reader")
widget.restoreGeometry(settings.value("geometry"))
geometry = settings.value("geometry")
if geometry is not None:
widget.restoreGeometry(geometry)
d = cast(Definition, widget.widget())
assert d is not None
d.pronounce.connect(snd.playSound)
@@ -81,4 +100,7 @@ def main() -> int:
if __name__ == "__main__":
faulthandler.register(signal.Signals.SIGUSR1)
faulthandler.register(signal.Signals.SIGTERM)
faulthandler.register(signal.Signals.SIGHUP)
faulthandler.enable()
sys.exit(main())

View File

@@ -1,8 +1,8 @@
# pyright: ignore
from .utils import query_error # isort: skip
from .books import Book
from .definition import Definition, Fragment, Line
from .person import PersonDialog
from .read import ReadDialog
from .session import SessionDialog
from .words import DefinitionArea, Word
from .definition import Fragment, Line, Definition
from .words import Word

View File

@@ -1,39 +1,64 @@
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
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,
font: QFont|None = None,
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():
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()
@@ -44,147 +69,114 @@ 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:
...
@overload
def paintEvent(self, widthSrc: QPainter) -> int:
...
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, 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)
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)
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.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()
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:
@@ -195,7 +187,7 @@ class Fragment:
return
def setAlign(self, align: QTextOption) -> None:
self._align = align
self._layout.setTextOption(align)
return
def setRect(self, rect: QRect) -> None:
@@ -292,7 +284,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:
@@ -310,6 +302,7 @@ class Fragment:
def setBackground(self, color: QColor) -> None:
self._background = color
return
def setIndent(self, indent: int) -> None:
self._indent = indent
return
@@ -317,20 +310,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 +340,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,8 +361,12 @@ class Fragment:
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
@@ -379,8 +379,9 @@ class Line:
"|".join([x.text() for x in self._fragments])
+ f"|{self._maxHeight}"
)
@classmethod
def setParseText(cls, call) -> None:
def setParseText(cls, call: Callable) -> None:
cls.parseText = call
return
@@ -388,95 +389,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"
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)
frags = [
frags,
]
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
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
left = 0 # Left size of rect
maxHeight = 0
for frag in self._fragments:
if left < frag.pixelIndent():
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()
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]:
@@ -485,13 +439,22 @@ class Line:
def getLineSpacing(self) -> int:
return self._leading + self._maxHeight
class Definition(QWidget):
pronounce = pyqtSignal(str)
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)
@@ -499,21 +462,15 @@ class Definition(QWidget):
def setWord(self, word: Any) -> None:
self._word = word
lines:list[Line] = word.get_def()
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
@@ -526,40 +483,58 @@ class Definition(QWidget):
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
return
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._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
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._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"))
#
# 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,
@@ -569,11 +544,6 @@ 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()
line.paintEvent(painter)
return

View File

@@ -1,12 +1,9 @@
import json
from typing import Any, Dict, List, Optional, cast
from typing import Dict, List, Optional, cast
import requests
from PyQt6.QtCore import QPoint, QResource, Qt, QTimer, pyqtSignal, pyqtSlot
from PyQt6.QtGui import (
QBrush,
QColor,
QCursor,
QKeyEvent,
QPainter,
QPainterPath,
@@ -15,7 +12,7 @@ from PyQt6.QtGui import (
QTextCursor,
)
from PyQt6.QtSql import QSqlQuery
from PyQt6.QtWidgets import QDialog, QTextEdit, QWidget
from PyQt6.QtWidgets import QDialog, QWidget
from lib import query_error
from lib.preferences import Preferences
@@ -89,6 +86,7 @@ class ReadDialog(QDialog, Ui_ReadDialog):
self.playSound.connect(self.sound.playSound)
self.playAlert.connect(self.sound.alert)
self.definition.pronounce.connect(self.sound.playSound)
self.definition.newWord.connect(self.newWord)
return
#
@@ -98,6 +96,15 @@ class ReadDialog(QDialog, Ui_ReadDialog):
#
# slots
#
@pyqtSlot(str)
def newWord(self, word: str) -> None:
w = Word(word)
if not w.isValid():
self.playAlert.emit()
return
self.definition.setWord(w)
return
@pyqtSlot()
def timerAction(self) -> None:
if self.session.isActive(): # We are stopping
@@ -127,6 +134,9 @@ class ReadDialog(QDialog, Ui_ReadDialog):
cursor.select(QTextCursor.SelectionType.WordUnderCursor)
text = cursor.selectedText().strip()
word = Word(text)
if not word.isValid():
self.playAlert.emit()
return
word.playPRS()
return
@@ -221,6 +231,9 @@ class ReadDialog(QDialog, Ui_ReadDialog):
cursor.select(cursor.SelectionType.WordUnderCursor)
text = cursor.selectedText().strip()
word = Word(text)
if not word.isValid():
self.playAlert.emit()
return
self.definition.setWord(word)
self.showDefinition()
return

View File

@@ -22,6 +22,7 @@ from PyQt6.QtNetwork import (
QNetworkReply,
QNetworkRequest,
)
from trycast import trycast
# from PyQt6.QtWidgets import QWidget
@@ -33,39 +34,53 @@ class SoundOff(QObject):
if cls._instance:
return cls._instance
cls._instance = super(SoundOff, cls).__new__(cls)
#
# Setup devices
#
cls.virtualDevice = None
for output in QMediaDevices.audioOutputs():
if output.id().data().decode("utf-8") == "virt-input":
cls.virtualDevice = output
if output.isDefault():
cls.localDevice = output
cls.alertEffect = QSoundEffect()
cls.alertEffect.setSource(QUrl("qrc:/beep.wav"))
cls.alertEffect.setAudioDevice(cls.localDevice)
cls.alertEffect.setVolume(0.25)
cls.alertEffect.setLoopCount(1)
cls.localPlayer = QMediaPlayer()
cls.localPlayer.setObjectName("localPlayer")
cls.localOutput = QAudioOutput()
cls.localOutput.setDevice(cls.localDevice)
cls.localPlayer.setAudioOutput(cls.localOutput)
if cls.virtualDevice:
cls.virtualPlayer = QMediaPlayer()
cls.virtualPlayer.setObjectName("virtualPlayer")
cls.virtualOutput = QAudioOutput()
cls.virtualOutput.setVolume(1.0)
cls.virtualOutput.setDevice(cls.virtualDevice)
cls.virtualPlayer.setAudioOutput(cls.virtualOutput)
cacheDir = QDir(
QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.GenericCacheLocation
)
)
cacheDir.mkdir("Troglodite")
cacheDir = QDir(cacheDir.path() + QDir.separator() + "Troglodite")
netCache = QNetworkDiskCache()
netCache.setCacheDirectory(cacheDir.path())
cls.nam = QNetworkAccessManager()
cls.nam.setCache(netCache)
return cls._instance
def __init__(self) -> None:
super().__init__()
#
# Setup devices
#
self.virtualDevice = None
if self.localPlayer.receivers(self.localPlayer.errorOccurred) > 0:
print("SoundOff, __init__() after __init__()")
for output in QMediaDevices.audioOutputs():
if output.id().data().decode("utf-8") == "virt-input":
self.virtualDevice = output
if output.isDefault():
self.localDevice = output
self.alertEffect = QSoundEffect()
self.alertEffect.setSource(QUrl("qrc:/beep.wav"))
self.alertEffect.setAudioDevice(self.localDevice)
self.alertEffect.setVolume(0.25)
self.alertEffect.setLoopCount(1)
self.localPlayer = QMediaPlayer()
self.localPlayer.setObjectName("localPlayer")
self.localOutput = QAudioOutput()
self.localOutput.setDevice(self.localDevice)
self.localPlayer.setAudioOutput(self.localOutput)
if self.virtualDevice:
self.virtualPlayer = QMediaPlayer()
self.virtualPlayer.setObjectName("virtualPlayer")
self.virtualOutput = QAudioOutput()
self.virtualOutput.setVolume(1.0)
self.virtualOutput.setDevice(self.virtualDevice)
self.virtualPlayer.setAudioOutput(self.virtualOutput)
#
# Connections
#
@@ -76,18 +91,6 @@ class SoundOff(QObject):
self.virtualPlayer.errorOccurred.connect(self.mediaError)
self.virtualPlayer.mediaStatusChanged.connect(self.mediaStatus)
self.virtualPlayer.playbackStateChanged.connect(self.playbackState)
cacheDir = QDir(
QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.GenericCacheLocation
)
)
cacheDir.mkdir("Troglodite")
cacheDir = QDir(cacheDir.path() + QDir.separator() + "Troglodite")
netCache = QNetworkDiskCache()
netCache.setCacheDirectory(cacheDir.path())
self.nam = QNetworkAccessManager()
self.nam.setCache(netCache)
self.nam.finished.connect(self.finished)
return
@pyqtSlot(QMediaPlayer.Error, str)
@@ -98,11 +101,13 @@ class SoundOff(QObject):
@pyqtSlot(QMediaPlayer.MediaStatus)
def mediaStatus(self, status: QMediaPlayer.MediaStatus) -> None:
# print(f"mediaStatus: {status}")
if status == QMediaPlayer.MediaStatus.LoadedMedia:
player: Optional[QMediaPlayer] = cast(QMediaPlayer, self.sender())
player = trycast(QMediaPlayer, self.sender())
if player is None:
player = trycast(QMediaPlayer, self.lastSender)
assert player is not None
player.play()
self.lastSender = self.sender()
return
@pyqtSlot(QMediaPlayer.PlaybackState)
@@ -119,6 +124,7 @@ class SoundOff(QObject):
return
@pyqtSlot(str)
@pyqtSlot(QUrl)
def playSound(self, url: str | QUrl) -> None:
if isinstance(url, str):
url = QUrl(url)
@@ -128,8 +134,10 @@ class SoundOff(QObject):
self.virtualPlayer.setAudioOutput(self.virtualOutput)
if url != self._lastUrl:
request = QNetworkRequest(url)
self.nam.get(request)
reply = self.nam.get(request)
assert reply is not None
self._lastUrl = url
reply.finished.connect(self.finished)
return
for player in [self.localPlayer, self.virtualPlayer]:
if not player:
@@ -151,20 +159,22 @@ class SoundOff(QObject):
_buffer: dict[QMediaPlayer, QBuffer] = {}
_lastUrl = QUrl()
@pyqtSlot(QNetworkReply)
def finished(self, reply: QNetworkReply) -> None:
storage = reply.readAll()
crypto = QCryptographicHash(QCryptographicHash.Algorithm.Sha256)
@pyqtSlot()
def finished(self) -> None:
reply = trycast(QNetworkReply, self.sender())
assert reply is not None
code = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
print(f"HttpStatusCodeAttribute: {code}, error: {reply.error()}")
self._reply = reply.readAll()
url = reply.request().url()
reply.close()
if self._reply.isEmpty() or self._reply.isNull():
return
for player in [self.localPlayer, self.virtualPlayer]:
if not player:
continue
self._storage[player] = QByteArray(storage)
crypto.addData(self._storage[player])
print(player, crypto.result().toHex())
crypto.reset()
self._storage[player] = QByteArray(self._reply)
self._buffer[player] = QBuffer(self._storage[player])
url = reply.request().url()
player.setSourceDevice(self._buffer[player], url)
player.setPosition(0)
if player.mediaStatus() == QMediaPlayer.MediaStatus.LoadedMedia:

View File

@@ -2,7 +2,7 @@
from typing import NoReturn, Self
from PyQt6.QtCore import QCoreApplication, QDir, QStandardPaths, Qt
from PyQt6.QtGui import QColor, QFont, QFontDatabase
from PyQt6.QtGui import QColor, QFont, QFontDatabase, QTextCharFormat
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkDiskCache
from PyQt6.QtSql import QSqlQuery
@@ -22,6 +22,7 @@ def query_error(query: QSqlQuery) -> NoReturn:
)
raise Exception(translate("MainWindow", "SQL Error"))
class Resources:
_instance = None
nam = QNetworkAccessManager()
@@ -38,11 +39,60 @@ class Resources:
linkColor: QColor
subduedColor: QColor
subduedBackground: QColor
headerFormat = QTextCharFormat()
labelFormat = QTextCharFormat()
subduedFormat = QTextCharFormat()
subduedItalicFormat = QTextCharFormat()
sOnSFormat = QTextCharFormat()
subduedLabelFormat = QTextCharFormat()
phonticFormat = QTextCharFormat()
boldFormat = QTextCharFormat()
boldOnSFormat = QTextCharFormat()
italicFormat = QTextCharFormat()
textFormat = QTextCharFormat()
smallCapsFormat = QTextCharFormat()
def __new__(cls: type[Self]) -> Self:
if cls._instance:
return cls._instance
cls._instance = super(Resources, cls).__new__(cls)
#
# colors
#
cls.baseColor = QColor(Qt.GlobalColor.white)
cls.linkColor = QColor("#4a7d95")
cls.subduedColor = QColor(Qt.GlobalColor.gray)
cls.subduedBackground = QColor("#444")
#
# Formats
#
LARGE = 36
MEDIUM = 22
SMALL = 18
cls.headerFormat.setFontPointSize(LARGE)
cls.labelFormat.setFontPointSize(MEDIUM)
cls.sOnSFormat.setForeground(cls.subduedColor)
#cls.sOnSFormat.setBackground(cls.subduedBackground)
cls.sOnSFormat.setFontPointSize(SMALL)
cls.subduedFormat.setForeground(cls.subduedColor)
cls.subduedFormat.setFontPointSize(SMALL)
cls.subduedLabelFormat.setForeground(cls.subduedColor)
cls.subduedLabelFormat.setFontPointSize(SMALL)
cls.phonticFormat.setFont(QFontDatabase.font("Gentium", None,20))
cls.phonticFormat.setFontPointSize(SMALL)
cls.boldFormat.setFontWeight(QFont.Weight.Bold)
cls.boldFormat.setFontPointSize(SMALL)
cls.boldOnSFormat.setFontWeight(QFont.Weight.Bold)
cls.boldOnSFormat.setFontPointSize(SMALL)
cls.boldOnSFormat.setBackground(cls.subduedBackground)
cls.italicFormat.setFontItalic(True)
cls.italicFormat.setFontPointSize(SMALL)
cls.textFormat.setFontPointSize(SMALL)
cls.smallCapsFormat.setFontPointSize(SMALL)
cls.smallCapsFormat.setFontCapitalization(QFont.Capitalization.SmallCaps)
#
# Fonts
#
cls.headerFont = QFontDatabase.font("OpenDyslexic", None, 10)
@@ -59,21 +109,12 @@ class Resources:
cls.headerFont.setWeight(QFont.Weight.Bold)
cls.boldFont.setBold(True)
cls.italicFont.setItalic(True)
print(f"Resources().italicFont: {cls.italicFont.toString()}")
print(f"Resources().boldFont: {cls.boldFont.toString()}")
cls.capsFont.setCapitalization(QFont.Capitalization.AllUppercase)
cls.smallCapsFont.setCapitalization(QFont.Capitalization.SmallCaps)
cls.phonicFont = QFontDatabase.font("Gentium", None, 10)
cls.phonicFont.setPixelSize(20)
#
# colors
#
cls.baseColor = QColor(Qt.GlobalColor.white)
cls.linkColor = QColor("#4a7d95")
cls.subduedColor = QColor(Qt.GlobalColor.gray)
#
# Setup the Network Manager
#

View File

@@ -1,39 +1,46 @@
import importlib
import pkgutil
import json
from typing import Any, TypedDict, cast
import pkgutil
from types import ModuleType
from typing import Any, Iterable, TypedDict, cast
from PyQt6.QtCore import (
Qt,
pyqtSlot,
)
from PyQt6.QtCore import Qt, pyqtSlot
from PyQt6.QtSql import QSqlQuery
from PyQt6.QtWidgets import QScrollArea
from lib.utils import query_error
from lib.sounds import SoundOff
from lib.definition import Definition, Line
from trycast import trycast
import plugins
def find_plugins(ns_pkg):
return pkgutil.iter_modules(ns_pkg.__path__, ns_pkg.__name__ + '.')
from lib.definition import Definition, Line
from lib.sounds import SoundOff
from lib.utils import query_error
def find_plugins(ns_pkg: ModuleType) -> Iterable[pkgutil.ModuleInfo]:
return pkgutil.iter_modules(ns_pkg.__path__, ns_pkg.__name__ + ".")
discovered_plugins = {
# finder, name, ispkg
importlib.import_module(name).registration['source']: importlib.import_module(name) for _, name, _ in find_plugins(plugins)
importlib.import_module(name).registration[
"source"
]: importlib.import_module(name)
for _, name, _ in find_plugins(plugins)
}
API = "https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
class WordType(TypedDict):
word: str
source: str
definition: str
class Word:
"""All processing of a dictionary word."""
_words: dict[str, WordType] = {}
_valid = False
def __init__(self, word: str) -> None:
#
@@ -56,6 +63,7 @@ class Word:
"definition": json.loads(query.value("definition")),
}
self.current = Word._words[word]
self._valid = True
return
#
# The code should look at our settings to see if we have an API
@@ -64,6 +72,9 @@ class Word:
source = "mw"
self._words[word] = discovered_plugins[source].fetch(word)
if self._words[word] is None:
self._valid = False
return
self.current = Word._words[word]
query.prepare(
"INSERT INTO words "
@@ -75,39 +86,41 @@ class Word:
query.bindValue(":definition", json.dumps(self.current["definition"]))
if not query.exec():
query_error(query)
self._valid = True
return
def isValid(self) -> bool:
return self._valid
@pyqtSlot()
def playSound(self) -> None:
url = discovered_plugins[self.current['source']].getFirstSound(self.current['definition'])
url = discovered_plugins[self.current["source"]].getFirstSound(
self.current["definition"]
)
if url.isValid():
snd = SoundOff()
snd.playSound(url)
return
def playPRS(self) -> None:
return
def getWord(self) -> str:
return cast(str, self.current["word"])
return self.current["word"]
def get_html(self) -> str | None:
src = self.current['source']
src = self.current["source"]
try:
return discovered_plugins[src].getHtml(self.current)
return cast(str, discovered_plugins[src].getHtml(self.current))
except KeyError:
raise Exception(f"Unknown source: {src}")
def get_def(self) -> list[Line]:
src = self.current['source']
src = self.current["source"]
try:
lines = discovered_plugins[src].getDef(self.current["definition"])
lines = trycast(list[Line], lines)
assert lines is not None
return lines
except KeyError:
raise Exception(f"Unknown source: {self.current['source']}")
class DefinitionArea(QScrollArea):
def __init__(self, w: Word, *args: Any, **kwargs: Any) -> None:
super(DefinitionArea, self).__init__(*args, *kwargs)
d = Definition(w)
self.setWidget(d)
self.setWidgetResizable(True)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
return

File diff suppressed because it is too large Load Diff

94
requirements.venv Normal file
View File

@@ -0,0 +1,94 @@
altgraph==0.17.4
asgiref==3.7.2
astor==0.8.1
astroid==3.0.3
asttokens==2.4.1
autopep8==2.0.4
beautifulsoup4==4.12.3
black==23.12.1
bs4==0.0.2
certifi==2023.11.17
charlockholmes==0.0.3
charset-normalizer==3.3.2
click==8.1.7
coloredlogs==15.0.1
css-inline==0.13.0
debugpy==1.8.1
decorator==5.1.1
dill==0.3.8
docstring-to-markdown==0.13
executing==2.0.1
flake8==7.0.0
flynt==1.0.1
future==0.18.3
futures==3.0.5
goslate==1.5.4
hiredis==2.3.2
humanfriendly==10.0
idna==3.6
importlib-metadata==7.0.1
ipython==8.20.0
isort==5.13.2
jedi==0.19.1
matplotlib-inline==0.1.6
mccabe==0.7.0
mime==0.1.0
mypy==1.8.0
mypy-extensions==1.0.0
packaging==23.2
param==2.1.0
parso==0.8.3
pathspec==0.12.1
pexpect==4.9.0
pickleshare==0.7.5
pillow==10.2.0
platformdirs==4.1.0
pluggy==1.4.0
prompt-toolkit==3.0.43
ptyprocess==0.7.0
pure-eval==0.2.2
pycodestyle==2.11.1
PyDictionary==2.0.1
pydocstyle==6.3.0
pyflakes==3.2.0
pygame==2.5.2
Pygments==2.17.2
pygments-github-lexers==0.0.5
pyinstaller==6.5.0
pyinstaller-hooks-contrib==2024.3
pylint==3.0.3
PyQt6==6.6.1
PyQt6-Qt6==6.6.1
PyQt6-sip==13.6.0
PySide6==6.6.1
PySide6-Addons==6.6.1
PySide6-Essentials==6.6.1
python-lsp-jsonrpc==1.1.2
python-lsp-server==1.10.0
pytoolconfig==1.3.1
PyYAML==6.0.1
qt6-applications==6.5.0.2.3
requests==2.31.0
rope==1.12.0
scanner==0.1.0
setuptools==69.0.3
shiboken6==6.6.1
six==1.16.0
snowballstemmer==2.2.0
soupsieve==2.5
sqlparse==0.4.4
stack-data==0.6.3
tabulate==0.9.0
tomli==2.0.1
tomlkit==0.12.3
traitlets==5.14.1
trycast==1.1.0
types-requests==2.31.0.20240106
typing_extensions==4.9.0
ujson==5.9.0
unicorn==2.0.1.post1
urllib3==2.1.0
wcwidth==0.2.13
whatthepatch==1.0.5
yapf==0.40.2
zipp==3.17.0