from typing import Dict, List, Optional, cast 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, QWidget from lib import query_error from lib.preferences import Preferences from lib.session import SessionDialog from lib.sounds import SoundOff from lib.words import Word from ui.ReadDialog import Ui_ReadDialog 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) doc = self.paraEdit.document() assert doc is not None doc.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) sb = self.paraEdit.verticalScrollBar() assert sb is not None sb.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) self.definition.pronounce.connect(self.sound.playSound) self.definition.newWord.connect(self.newWord) return # # Events # # # 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 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 sessionAction(self) -> None: self.sessionSignal.emit() self.newParagraph.emit(self.section_id, self.block) return @pyqtSlot() def playAction(self) -> None: if self.stackedWidget.currentIndex() != 0: return # find word cursor = self.paraEdit.textCursor() if cursor.hasSelection(): text = cursor.selectedText().strip() else: cursor.select(QTextCursor.SelectionType.WordUnderCursor) text = cursor.selectedText().strip() word = Word(text) if not word.isValid(): self.playAlert.emit() return word.playPRS() return @pyqtSlot() def scrollAction(self) -> None: doc = self.paraEdit.document() assert doc is not None position = doc.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)) sb = self.paraEdit.verticalScrollBar() assert sb is not None value = sb.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 if self.target > sb.maximum(): self.target = sb.maximum() - 1 timer = QTimer(self) timer.timeout.connect(self.softTick) timer.start(msPerTick) return @pyqtSlot() def softTick(self) -> None: sb = self.paraEdit.verticalScrollBar() assert sb is not None value = sb.value() maxValue = sb.maximum() 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 or value >= maxValue: sender.stop() return value += self.pxPerTick sb.setValue(value) self.update() return @pyqtSlot(int) def scrollSlot(self, _: 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: if self.stackedWidget.currentIndex() != 0: return cursor = self.paraEdit.textCursor() if cursor.hasSelection(): text = cursor.selectedText().strip() else: 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 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]) doc = self.paraEdit.document() assert doc is not None if start: self.block = 0 else: self.block = doc.blockCount() - 1 textBlock = doc.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 = doc.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, _: QPaintEvent | None) -> None: idx = self.stackedWidget.currentIndex() if idx > 0: return doc = self.paraEdit.document() assert doc is not None position = doc.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 doc = self.paraEdit.document() assert doc is not None if self.block >= doc.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 @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: self.returnBtn.setVisible(True) self.nextBtn.setText(self.tr("Next Definition")) self.prevBtn.setText(self.tr("Previous Definition")) 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() doc = self.paraEdit.document() assert doc is not None cursor.setPosition(doc.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