import json
from typing import Any, Dict, List, Optional, cast
import requests
from lib import query_error
from PyQt6.QtCore import QPoint, QResource, Qt, QTimer, pyqtSignal, pyqtSlot
from PyQt6.QtGui import (QBrush, QColor, QKeyEvent, QPainter, QPainterPath,
QPaintEvent, QTextCharFormat, QTextCursor)
from PyQt6.QtSql import QSqlQuery
from PyQt6.QtWidgets import QDialog, QTextEdit, QWidget
from ui.ReadDialog import Ui_ReadDialog
from lib.preferences import Preferences
from lib.session import SessionDialog
from lib.sounds import SoundOff
class ReadDialog(QDialog, Ui_ReadDialog):
playSound = pyqtSignal(str)
playAlert = pyqtSignal()
block: int
preferences: Dict[str, str | List[str]]
paragraphs = True
sessionSignal = pyqtSignal()
displayedWord = pyqtSignal(int)
newParagraph = pyqtSignal(int, int)
def __init__(
self, parent: Optional[QWidget], session: SessionDialog, person_id: int
) -> None:
self.session = session
super(ReadDialog, self).__init__(parent)
self.person_id = person_id
self.preferences = cast(Dict[str, str | List[str]], Preferences().get())
self.sound = SoundOff()
styleSheet = QResource(":/display.css").data().decode("utf-8")
readerFont = cast(str, self.preferences["readerFont"])
phoneticFont = cast(str, self.preferences["phoneticFont"])
styleSheet = styleSheet.replace("{readerFont}", readerFont)
styleSheet = styleSheet.replace("{phoneticFont}", phoneticFont)
self.setupUi(self)
#
# Override UI
#
self.returnBtn.setVisible(False)
#
# End overrides
#
self.load_book(self.person_id)
self.titleLbl.setText(self.book_title)
blockNumber = self.block
self.paraEdit.setReadOnly(True)
self.paraEdit.document().setDefaultStyleSheet(styleSheet)
self.defEdit.setReadOnly(True)
self.defEdit.document().setDefaultStyleSheet(styleSheet)
self.show_section(self.section_id)
self.block = blockNumber
self.savePosition()
self.stackedWidget.setCurrentIndex(0)
#
# Connect widgets
#
self.defineBtn.clicked.connect(self.defineAction)
self.playBtn.clicked.connect(self.playAction)
self.scrollBtn.clicked.connect(self.scrollAction)
self.nextBtn.clicked.connect(self.nextAction)
self.prevBtn.clicked.connect(self.prevAction)
self.sessionBtn.clicked.connect(self.timerAction)
self.paraEdit.verticalScrollBar().valueChanged.connect(self.scrollSlot)
# self.defEdit.selectionChanged.connect(self.recursiveDef)
self.returnBtn.clicked.connect(self.returnAction)
#
# Connect signals
#
self.displayedWord.connect(self.session.addWord)
self.newParagraph.connect(self.session.addBlock)
self.playSound.connect(self.sound.playSound)
self.playAlert.connect(self.sound.alert)
return
#
# Events
#
#
# slots
#
@pyqtSlot()
def timerAction(self) -> None:
if self.session.isActive(): # We are stopping
self.sessionBtn.setText(self.tr("Start"))
else:
self.sessionBtn.setText(self.tr("Stop"))
self.session.timerAction()
self.newParagraph.emit(self.section_id, self.block)
return
@pyqtSlot()
def recursiveDef(self) -> None:
cursor = self.defEdit.textCursor()
selection = cursor.selectedText().strip()
if len(selection) <= 0:
return
query = QSqlQuery()
query.prepare("SELECT * FROM words " "WHERE word = :word")
query.bindValue(":word", selection)
if not query.exec():
query_error(query)
if not query.next():
response = requests.get(
f"https://api.dictionaryapi.dev/api/v2/entries/en/{selection}"
)
if response.status_code != 200:
return
definitions = json.loads(response.content.decode("utf-8"))
definition = definitions[0]
word_id = None
else:
definition = query.value("definition")
word_id = query.value("word_id")
self.setDefEdit(selection, word_id, definition)
return
@pyqtSlot()
def sessionAction(self) -> None:
self.sessionSignal.emit()
self.newParagraph.emit(self.section_id, self.block)
return
@pyqtSlot()
def playAction(self) -> None:
idx = self.stackedWidget.currentIndex()
if idx == 0: # Reading
# find word
cursor = self.paraEdit.textCursor()
fmt = cursor.charFormat()
if not fmt.fontUnderline():
self.addWord(self.paraEdit)
cursor = self.paraEdit.textCursor()
textBlock = self.paraEdit.document().findBlock(cursor.position())
blockNum = textBlock.blockNumber()
query = QSqlQuery()
query.prepare(
"SELECT w.* FROM word_block wb "
"LEFT JOIN words w "
"ON (w.word_id = wb.word_id) "
"WHERE :position BETWEEN wb.start AND wb.end "
"AND wb.block = :block AND wb.section_id = :section_id"
)
query.bindValue(
":position", cursor.position() - textBlock.position()
)
query.bindValue(":block", blockNum)
query.bindValue(":section_id", self.section_id)
if not query.exec():
query_error(query)
if not query.next():
return
data = json.loads(query.value("definition"))
if "phonetics" in data:
self.phonetics = data["phonetics"]
else:
self.phonetics = None
if not self.phonetics:
return
print(self.tr("Searching for audio file"))
for entry in self.phonetics:
if len(entry["audio"]) > 0:
# self.parent().playAlert.emit()
print(self.tr("playing ") + f"{entry['audio']}")
self.playSound.emit(entry["audio"])
return
@pyqtSlot()
def scrollAction(self) -> None:
position = (
self.paraEdit.document().findBlockByNumber(self.block).position()
)
cursor = self.paraEdit.textCursor()
cursor.setPosition(position)
pos = self.paraEdit.mapTo(
self, self.paraEdit.cursorRect(cursor).topLeft()
)
top = self.paraEdit.mapTo(self, QPoint(0, 0))
value = self.paraEdit.verticalScrollBar().value()
#
# XXX - Where does "4" come from?
#
delta = pos.y() - top.y() - 4
self.pxPerTick = int(1000 / delta)
if self.pxPerTick < 1:
if delta < 0:
self.pxPerTick = -1
else:
self.pxPerTick = 1
steps = abs(delta / self.pxPerTick)
msPerTick = int(1000 / steps)
if msPerTick < 3:
msPerTick = 3
self.target = value + delta
timer = QTimer(self)
timer.timeout.connect(self.softTick)
timer.start(msPerTick)
return
@pyqtSlot()
def softTick(self) -> None:
value = self.paraEdit.verticalScrollBar().value()
sender: QTimer = cast(QTimer, self.sender())
if self.pxPerTick < 0: # moving content up
if value < self.target:
sender.stop()
return
else:
if value > self.target:
sender.stop()
return
value += self.pxPerTick
self.paraEdit.verticalScrollBar().setValue(value)
self.update()
return
@pyqtSlot(int)
def scrollSlot(self, value: int) -> None:
self.update()
return
@pyqtSlot()
def nextAction(self) -> None:
if self.paragraphs:
self.nextParagraph()
else:
self.nextSection()
return
@pyqtSlot()
def prevAction(self) -> None:
if self.paragraphs:
self.prevParagraph()
else:
self.prevSection()
return
#
# Called when the "define" button is pressed
#
@pyqtSlot()
def defineAction(self) -> None:
editor = self.paraEdit
if self.stackedWidget.currentIndex() > 0:
editor = self.defEdit
self.addWord(editor)
self.showDefinition()
return
def defToHtml(self, word: str, definition: Dict[str, Any]) -> str:
SPEAKER = "\U0001F508"
html = f'
{word}
' + "\n"
try:
words: List[str] = []
for phonetic in definition["phonetics"]:
# XXX - Some phonetics have text and audio but audio is empty,
# some have just text and some have just audio
if phonetic["text"] in words:
continue
words.append(phonetic["text"])
html += f'{phonetic["text"]}'
if "audio" in phonetic:
html += f'{SPEAKER}'
html += "
\n"
except Exception:
pass
print(html + "\n")
html += '' + "\n"
for meaning in definition["meanings"]:
html += f"- {meaning['partOfSpeech']}"
html += '
'
for a_def in meaning["definitions"]:
html += f"- {a_def['definition']}
\n"
html += "
\n"
html += "
\n\n"
return html
def load_book(self, person_id: int) -> None:
query = QSqlQuery()
query.prepare(
"SELECT pb.*,b.title FROM people p "
"LEFT JOIN person_book pb "
"ON (p.book_id = pb.book_id "
"AND p.person_id = pb.person_id) "
"LEFT JOIN books b "
"ON (p.book_id = b.book_id) "
"WHERE p.person_id = :person_id"
)
query.bindValue(":person_id", person_id)
if not query.exec():
query_error(query)
if not query.next():
self.done(0)
self.book_id = query.value("book_id")
self.book_title = query.value("title")
self.section_id = query.value("section_id")
self.block = query.value("block")
self.sections = []
self.section_map = {}
self.sequence_map = {}
query.prepare(
"SELECT * FROM sections "
"WHERE book_id = :book_id "
"ORDER BY sequence"
)
query.bindValue(":book_id", self.book_id)
if not query.exec():
query_error(query)
while query.next():
self.sections.append(query.value("content"))
self.section_map[query.value("section_id")] = query.value(
"sequence"
)
self.sequence_map[query.value("sequence")] = query.value(
"section_id"
)
return
def show_section(self, section_id: int, start: bool = True) -> None:
sequence = self.section_map[section_id]
self.paraEdit.clear()
cursor = self.paraEdit.textCursor()
cursor.insertHtml(self.sections[sequence])
if start:
self.block = 0
else:
self.block = self.paraEdit.document().blockCount() - 1
textBlock = self.paraEdit.document().findBlockByNumber(self.block)
cursor.setPosition(textBlock.position())
self.paraEdit.setTextCursor(cursor)
self.paraEdit.ensureCursorVisible()
#
# Mark all the defined words with underlines
#
def_format = QTextCharFormat()
def_format.setFontUnderline(True)
cursor = QTextCursor(self.paraEdit.document())
query = QSqlQuery()
query.prepare(
"SELECT wb.*,w.word,w.definition FROM word_block wb "
"LEFT JOIN words w "
"ON (w.word_id = wb.word_id) "
"WHERE wb.section_id = :section_id"
)
query.bindValue(":section_id", section_id)
if not query.exec():
query_error(query)
while query.next():
#
# Define these variables so that the code matches
# the defining action
#
blockNum = query.value("block")
start = query.value("start")
end = query.value("end")
textBlock = self.paraEdit.document().findBlockByNumber(blockNum)
cursor.setPosition(
start + textBlock.position(), QTextCursor.MoveMode.MoveAnchor
)
cursor.setPosition(
end + textBlock.position(), QTextCursor.MoveMode.KeepAnchor
)
cursor.mergeCharFormat(def_format)
return
#
# Event handlers
#
def keyReleaseEvent(self, event: Optional[QKeyEvent]) -> None:
self.nextBtn.setText(self.tr("Next Paragraph"))
self.prevBtn.setText(self.tr("Previous Paragraph"))
self.defineBtn.setText(self.tr("Definition"))
self.paragraphs = True
super().keyReleaseEvent(event)
return
def keyPressEvent(self, event: Optional[QKeyEvent]) -> None:
self.nextBtn.setText(self.tr("Next Section"))
self.prevBtn.setText(self.tr("Previous Secttion"))
self.defineBtn.setText(self.tr("Definition"))
self.paragraphs = False
super().keyPressEvent(event)
return
def paintEvent(self, e: QPaintEvent | None) -> None:
idx = self.stackedWidget.currentIndex()
if idx > 0:
return
position = (
self.paraEdit.document().findBlockByNumber(self.block).position()
)
cursor = self.paraEdit.textCursor()
cursor.setPosition(position)
#
# rect is position in viewport coordenates.
rect = self.paraEdit.cursorRect(cursor)
c_pt = self.paraEdit.mapTo(self, rect.bottomLeft())
painter = QPainter(self)
brush = QBrush()
brush.setColor(QColor("green"))
brush.setStyle(Qt.BrushStyle.SolidPattern)
path = QPainterPath()
path.moveTo(0, 0)
path.lineTo(18, -rect.height() / 2.0)
path.lineTo(0, -rect.height())
path.lineTo(0, 0)
painter.translate(1.0, c_pt.y())
painter.fillPath(path, brush)
return
def nextParagraph(self) -> None:
self.block += 1
if self.block >= self.paraEdit.document().blockCount():
self.nextSection()
self.savePosition()
self.newParagraph.emit(self.section_id, self.block)
return
def prevParagraph(self) -> None:
self.block -= 1
if self.block < 0:
self.prevSection()
return
self.savePosition()
return
def addWord(self, editor: QTextEdit) -> None:
#
# Find the word
#
cursor = editor.textCursor()
word = cursor.selectedText()
start = cursor.selectionStart()
end = cursor.selectionEnd()
if start != end:
word = word.strip()
if len(word) == 0 or word.find(" ") >= 0:
cursor.select(QTextCursor.SelectionType.WordUnderCursor)
word = cursor.selectedText()
word = word.strip()
start = cursor.selectionStart()
end = cursor.selectionEnd()
if start > end:
tmp = start
start = end
end = tmp
#
# Find the block
#
document = editor.document()
assert document is not None
textBlock = document.findBlock(cursor.position())
blockNum = textBlock.blockNumber()
start = start - textBlock.position()
end = end - textBlock.position()
query = QSqlQuery()
query.prepare("SELECT * FROM words WHERE word = :word")
query.bindValue(":word", word)
if not query.exec():
query_error(query)
if query.next(): # we have something
self.defined(query.value("word_id"), blockNum, start, end)
return
#
# Get the defintion
#
response = requests.get(
f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
)
if response.status_code != 200:
print(f"{word}: {response.content.decode('utf8')}")
self.playAlert.emit()
return
definitions = json.loads(response.content.decode("utf-8"))
definition = definitions[0]
query.prepare(
"INSERT INTO words (word, definition) "
"VALUES (:word, :definition)"
)
query.bindValue(":word", word)
query.bindValue(":definition", json.dumps(definition))
if not query.exec():
query_error(query)
self.defined(query.lastInsertId(), blockNum, start, end)
return
def defined(
self, word_id: int, blockNum: int, start: int, end: int
) -> None:
query = QSqlQuery()
query.prepare(
"SELECT * FROM word_block "
"WHERE section_id = :section_id "
"AND block = :block "
"AND start = :start "
"AND end = :end"
)
query.bindValue(":word_id", word_id)
query.bindValue(":section_id", self.section_id)
query.bindValue(":block", blockNum)
query.bindValue(":start", start)
query.bindValue(":end", end)
if not query.exec():
query_error(query)
if not query.next():
query.prepare(
"INSERT INTO word_block VALUES "
"( :word_id, :section_id, :block, :start, :end)"
)
query.bindValue(":word_id", word_id)
query.bindValue(":section_id", self.section_id)
query.bindValue(":block", blockNum)
query.bindValue(":start", start)
query.bindValue(":end", end)
if not query.exec():
query_error(query)
def_format = QTextCharFormat()
def_format.setFontUnderline(True)
cursor = QTextCursor(self.paraEdit.document())
textBlock = self.paraEdit.document().findBlockByNumber(blockNum)
cursor.setPosition(
start + textBlock.position(), QTextCursor.MoveMode.MoveAnchor
)
cursor.setPosition(
end + textBlock.position(), QTextCursor.MoveMode.KeepAnchor
)
cursor.mergeCharFormat(def_format)
return
# XXX - rename
#
# Create a definition for the word under the cursor on the current
# panel.
#
def display_definition(self, idx: int) -> bool:
if idx == 0:
editor = self.paraEdit
else:
editor = self.defEdit
cursor = editor.textCursor()
cursor.select(QTextCursor.SelectionType.WordUnderCursor)
word = cursor.selectedText()
# fmt = cursor.charFormat()
# if not fmt.fontUnderline():
# self.addWord(editor)
query = QSqlQuery()
query.prepare("SELECT w.* FROM words w " "WHERE word = :word")
query.bindValue(":word", word)
if not query.exec():
query_error(query)
if not query.next():
return False
word = query.value("word")
definition = json.loads(query.value("definition"))
self.setDefEdit(word, query.value("word_id"), definition)
return True
def setDefEdit(
self, word: str, word_id: int, definition: Dict[str, str]
) -> None:
if "phonetics" in definition:
self.phonetics = definition["phonetics"]
else:
self.phonetics = None
self.defEdit.document().clear()
cursor = self.defEdit.textCursor()
cursor.insertHtml(self.defToHtml(word, definition))
cursor.setPosition(0)
self.defEdit.setTextCursor(cursor)
if word_id:
self.displayedWord.emit(word_id)
return
@pyqtSlot()
def returnAction(self) -> None:
self.returnBtn.setVisible(False)
if self.paragraphs:
self.nextBtn.setText(self.tr("Next Paragraph"))
self.prevBtn.setText(self.tr("Previous Paragraph"))
else:
self.nextBtn.setText(self.tr("Next Section"))
self.prevBtn.setText(self.tr("Previous Section"))
self.stackedWidget.setCurrentIndex(0)
self.update()
return
def showDefinition(self) -> None:
idx = self.stackedWidget.currentIndex()
self.returnBtn.setVisible(True)
self.nextBtn.setText(self.tr("Next Definition"))
self.prevBtn.setText(self.tr("Previous Definition"))
if not self.display_definition(idx):
return
self.stackedWidget.setCurrentIndex(1)
self.update()
return
def scrollTo(self, position: int) -> None:
cursor = self.paraEdit.textCursor()
cursor.setPosition(position)
return
def savePosition(self) -> None:
cursor = self.paraEdit.textCursor()
cursor.setPosition(
self.paraEdit.document().findBlockByNumber(self.block).position()
)
self.paraEdit.setTextCursor(cursor)
self.scrollTo(cursor.position())
self.paraEdit.ensureCursorVisible()
self.update()
query = QSqlQuery()
query.prepare(
"UPDATE person_book SET section_id = :section_id, "
"block = :block "
"WHERE book_id = :book_id "
"AND person_id = :person_id"
)
query.bindValue(":section_id", self.section_id)
query.bindValue(":block", self.block)
query.bindValue(":book_id", self.book_id)
query.bindValue(":person_id", self.person_id)
if not query.exec():
query_error(query)
return
def nextSection(self) -> None:
sequence = self.section_map[self.section_id]
sequence += 1
self.section_id = self.sequence_map[sequence]
self.show_section(self.section_id)
self.savePosition()
return
def prevSection(self) -> None:
sequence = self.section_map[self.section_id]
if sequence < 1:
return
sequence -= 1
self.section_id = self.sequence_map[sequence]
self.show_section(self.section_id, False)
self.savePosition()
return