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

\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