PDF Viewer pdf_viewer

Wed, 18 Jan 2023 14:31:55 +0100

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Wed, 18 Jan 2023 14:31:55 +0100
branch
pdf_viewer
changeset 9704
6e1650b9b3b5
parent 9702
7c973954919d
child 9705
7629a6f23b2e

PDF Viewer
- created a specialized PDF view class to intercept and handle certain events
- added a 'Search' widget

eric7.epj file | annotate | diff | comparison | revisions
src/eric7/PdfViewer/PdfInfoWidget.py file | annotate | diff | comparison | revisions
src/eric7/PdfViewer/PdfSearchWidget.py file | annotate | diff | comparison | revisions
src/eric7/PdfViewer/PdfToCWidget.py file | annotate | diff | comparison | revisions
src/eric7/PdfViewer/PdfView.py file | annotate | diff | comparison | revisions
src/eric7/PdfViewer/PdfViewerWindow.py file | annotate | diff | comparison | revisions
src/eric7/Preferences/__init__.py file | annotate | diff | comparison | revisions
src/eric7/icons/breeze-dark/sidebarExpandLeft.svg file | annotate | diff | comparison | revisions
src/eric7/icons/breeze-light/sidebarExpandLeft.svg file | annotate | diff | comparison | revisions
src/eric7/icons/oxygen/sidebarExpandLeft.png file | annotate | diff | comparison | revisions
--- a/eric7.epj	Mon Jan 16 11:56:23 2023 +0100
+++ b/eric7.epj	Wed Jan 18 14:31:55 2023 +0100
@@ -1318,7 +1318,9 @@
       "src/eric7/PdfViewer/PdfGoToDialog.py",
       "src/eric7/PdfViewer/PdfInfoWidget.py",
       "src/eric7/PdfViewer/PdfPageSelector.py",
+      "src/eric7/PdfViewer/PdfSearchWidget.py",
       "src/eric7/PdfViewer/PdfToCWidget.py",
+      "src/eric7/PdfViewer/PdfView.py",
       "src/eric7/PdfViewer/PdfViewerWindow.py",
       "src/eric7/PdfViewer/PdfZoomSelector.py",
       "src/eric7/PdfViewer/__init__.py",
--- a/src/eric7/PdfViewer/PdfInfoWidget.py	Mon Jan 16 11:56:23 2023 +0100
+++ b/src/eric7/PdfViewer/PdfInfoWidget.py	Wed Jan 18 14:31:55 2023 +0100
@@ -72,8 +72,6 @@
         )
         self.__layout.addRow(self.tr("Keywords:"), self.__infoLabels["keywords"])
         self.__layout.addRow(self.tr("Security:"), self.__infoLabels["security"])
-        #self.__layout.addRow(self.tr(""), self.__infoLabels[""])
-        # TODO: add more info labels
 
         self.setLayout(self.__layout)
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/eric7/PdfViewer/PdfSearchWidget.py	Wed Jan 18 14:31:55 2023 +0100
@@ -0,0 +1,359 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2023 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a Search widget.
+"""
+
+from PyQt6.QtCore import Qt, pyqtSlot, QModelIndex, pyqtSignal
+from PyQt6.QtPdf import QPdfSearchModel, QPdfDocument, QPdfLink
+from PyQt6.QtWidgets import (
+    QWidget, QVBoxLayout, QLabel, QLineEdit, QHBoxLayout, QToolButton,
+    QAbstractItemView, QTreeWidget, QTreeWidgetItem
+)
+
+from eric7 import Preferences
+from eric7.EricGui import EricPixmapCache
+
+
+class PdfSearchResultsWidget(QTreeWidget):
+    """
+    Class implementing a widget to show the search results.
+
+    @signal rowCountChanged() emitted to indicate a change of the number
+        of items
+    """
+
+    rowCountChanged = pyqtSignal()
+
+    def __init__(self, parent=None):
+        """
+        Constructor
+
+        @param parent reference to the parent widget (defaults to None)
+        @type QWidget (optional)
+        """
+        super().__init__(parent)
+
+        self.setColumnCount(2)
+        self.setHeaderHidden(True)
+        self.setAlternatingRowColors(True)
+        self.setSortingEnabled(False)
+        self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
+        self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+
+        self.__searchModel = QPdfSearchModel(self)
+        self.__searchModel.modelReset.connect(self.__clear)
+        self.__searchModel.rowsInserted.connect(self.__rowsInserted)
+
+    def setSearchString(self, searchString):
+        """
+        Public method to set the search string.
+
+        @param searchString search string
+        @type str
+        """
+        self.__searchModel.setSearchString(searchString)
+
+    def searchString(self):
+        """
+        Public method to get the current search string.
+
+        @return search string
+        @rtype str
+        """
+        return self.__searchModel.searchString()
+
+    def setDocument(self, document):
+        """
+        Public method to set the PDF document object to be searched.
+
+        @param document reference to the PDF document object
+        @type QPdfDocument
+        """
+        self.__searchModel.setDocument(document)
+
+    def document(self):
+        """
+        Public method to get the reference to the PDF document object.
+
+        @return reference to the PDF document object
+        @rtype QPdfDocument
+        """
+        return self.__searchModel.document()
+
+    @pyqtSlot()
+    def __clear(self):
+        """
+        Private slot to clear the list of search results.
+        """
+        self.clear()
+        self.rowCountChanged.emit()
+
+    @pyqtSlot(QModelIndex, int, int)
+    def __rowsInserted(self, parent, first, last):
+        """
+        Private slot to handle the insertion of rows of the search model.
+
+        @param parent reference to the parent index
+        @type QModelIndex
+        @param first first row inserted
+        @type int
+        @param last last row inserted
+        @type int
+        """
+        contextLength = Preferences.getPdfViewer("PdfSearchContextLength")
+
+        for row in range(first, last + 1):
+            index = self.__searchModel.index(row, 0)
+            itm = QTreeWidgetItem(
+                self,
+                [
+                    self.tr("Page {0}").format(
+                        self.__searchModel.document().pageLabel(
+                            self.__searchModel.data(
+                                index, QPdfSearchModel.Role.Page.value
+                            )
+                        )
+                    ),
+                    "",
+                ]
+            )
+            contextBefore = self.__searchModel.data(
+                index, QPdfSearchModel.Role.ContextBefore.value
+            )
+            if len(contextBefore) > contextLength:
+                contextBefore = "... {0}".format(contextBefore[-contextLength:])
+            contextAfter = self.__searchModel.data(
+                index, QPdfSearchModel.Role.ContextAfter.value
+            )
+            if len(contextAfter) > contextLength:
+                contextAfter = "{0} ...".format(contextAfter[:contextLength])
+            resultLabel = QLabel(
+                self.tr(
+                    "{0}<b>{1}</b>{2}",
+                    "context before, search string, context after"
+                ).format(contextBefore, self.searchString(), contextAfter)
+            )
+            self.setItemWidget(itm, 1, resultLabel)
+
+        for column in range(self.columnCount()):
+            self.resizeColumnToContents(column)
+
+        self.rowCountChanged.emit()
+
+    def rowCount(self):
+        """
+        Public method to get the number of rows.
+
+        @return number of rows
+        @rtype int
+        """
+        return self.topLevelItemCount()
+
+    def currentRow(self):
+        """
+        Public method to get the current row.
+
+        @return current row
+        @rtype int
+        """
+        curItem = self.currentItem()
+        if curItem is None:
+            return -1
+        else:
+            return self.indexOfTopLevelItem(curItem)
+
+    def setCurrentRow(self, row):
+        """
+        Public method to set the current row.
+
+        @param row row number to make the current row
+        @type int
+        """
+        if 0 <= row < self.topLevelItemCount():
+            self.setCurrentItem(self.topLevelItem(row))
+
+    def searchResultData(self, item, role):
+        """
+        Public method to get data of a search result item.
+
+        @param item reference to the search result item
+        @type QTreeWidgetItem
+        @param role item data role
+        @type QPdfSearchModel.Role or Qt.ItemDataRole
+        @return requested data
+        @rtype Any
+        """
+        row = self.indexOfTopLevelItem(item)
+        index = self.__searchModel.index(row, 0)
+        return self.__searchModel.data(index, role)
+
+    def getPdfLink(self, item):
+        """
+        Public method to get the PDF link associated with a search result item.
+
+        @param item reference to the search result item
+        @type QTreeWidgetItem
+        @return associated PDF link
+        @rtype QPdfLink
+        """
+        row = self.indexOfTopLevelItem(item)
+        return self.__searchModel.resultAtIndex(row)
+
+
+class PdfSearchWidget(QWidget):
+    """
+    Class implementing a Search widget.
+    """
+
+    searchResultActivated = pyqtSignal(QPdfLink)
+
+    def __init__(self, document, parent=None):
+        """
+        Constructor
+
+        @param document reference to the PDF document object
+        @type QPdfDocument
+        @param parent reference to the parent widget (defaults to None)
+        @type QWidget (optional)
+        """
+        super().__init__(parent)
+
+        self.__layout = QVBoxLayout(self)
+
+        # Line 1: a header label
+        self.__header = QLabel("<h2>{0}</h2>".format(self.tr("Search")))
+        self.__header.setAlignment(Qt.AlignmentFlag.AlignCenter)
+        self.__layout.addWidget(self.__header)
+
+        # Line 2: search entry and navigation buttons
+        self.__searchLineLayout = QHBoxLayout()
+        
+        self.__searchEdit = QLineEdit(self)
+        self.__searchEdit.setPlaceholderText(self.tr("Search ..."))
+        self.__searchEdit.setClearButtonEnabled(True)
+        self.__searchLineLayout.addWidget(self.__searchEdit)
+        
+        # layout for the navigation buttons
+        self.__buttonsLayout = QHBoxLayout()
+        self.__buttonsLayout.setSpacing(0)
+
+        self.__findPrevButton = QToolButton(self)
+        self.__findPrevButton.setToolTip(
+            self.tr("Press to move to the previous occurrence")
+        )
+        self.__findPrevButton.setIcon(EricPixmapCache.getIcon("1leftarrow"))
+        self.__buttonsLayout.addWidget(self.__findPrevButton)
+
+        self.__findNextButton = QToolButton(self)
+        self.__findNextButton.setToolTip(self.tr("Press to move to the next occurrence"))
+        self.__findNextButton.setIcon(EricPixmapCache.getIcon("1rightarrow"))
+        self.__buttonsLayout.addWidget(self.__findNextButton)
+
+        self.__searchLineLayout.addLayout(self.__buttonsLayout)
+        self.__layout.addLayout(self.__searchLineLayout)
+
+        self.__resultsWidget = PdfSearchResultsWidget(self)
+        self.__resultsWidget.setDocument(document)
+        self.__layout.addWidget(self.__resultsWidget)
+
+        self.setLayout(self.__layout)
+
+        self.__searchEdit.setEnabled(False)
+        self.__resultsWidget.setEnabled(False)
+        self.__findPrevButton.setEnabled(False)
+        self.__findNextButton.setEnabled(False)
+
+        self.__resultsWidget.itemActivated.connect(self.__entrySelected)
+        document.statusChanged.connect(self.__handleDocumentStatus)
+        self.__searchEdit.returnPressed.connect(self.__search)
+        self.__searchEdit.textChanged.connect(self.__searchTextChanged)
+        self.__resultsWidget.rowCountChanged.connect(self.__updateButtons)
+        self.__resultsWidget.currentItemChanged.connect(
+            self.__updateButtons
+        )
+        self.__findNextButton.clicked.connect(self.__nextResult)
+        self.__findPrevButton.clicked.connect(self.__previousResult)
+
+    @pyqtSlot(QPdfDocument.Status)
+    def __handleDocumentStatus(self, status):
+        """
+        Private slot to handle a change of the document status.
+
+        @param status document status
+        @type QPdfDocument.Status
+        """
+        ready = status == QPdfDocument.Status.Ready
+
+        self.__searchEdit.setEnabled(ready)
+        self.__resultsWidget.setEnabled(ready)
+
+        if not ready:
+            self.__searchEdit.clear()
+
+    @pyqtSlot(str)
+    def __searchTextChanged(self, text):
+        """
+        Private slot to handle a change of the search string.
+
+        @param text search string
+        @type str
+        """
+        if not text:
+            self.__resultsWidget.setSearchString("")
+
+    @pyqtSlot()
+    def __search(self):
+        """
+        Private slot to initiate a new search.
+        """
+        searchString = self.__searchEdit.text()
+        self.__resultsWidget.setSearchString(searchString)
+
+    @pyqtSlot()
+    def __updateButtons(self):
+        """
+        Private slot to update the state of the navigation buttons.
+        """
+        hasSearchResults = bool(self.__resultsWidget.rowCount())
+        currentRow = self.__resultsWidget.currentRow()
+        self.__findPrevButton.setEnabled(hasSearchResults and currentRow > 0)
+        self.__findNextButton.setEnabled(
+            hasSearchResults and currentRow < self.__resultsWidget.rowCount() - 2
+        )
+
+    @pyqtSlot(QTreeWidgetItem)
+    def __entrySelected(self, item):
+        """
+        Private slot to handle the selection of a search result entry.
+
+        @param index index of the activated entry
+        @type QModelIndex
+        """
+        link = self.__resultsWidget.getPdfLink(item)
+        self.searchResultActivated.emit(link)
+
+    @pyqtSlot()
+    def __nextResult(self):
+        """
+        Private slot to activate the next result.
+        """
+        row = self.__resultsWidget.currentRow()
+        if row < self.__resultsWidget.rowCount() - 2:
+            nextItem = self.__resultsWidget.topLevelItem(row + 1)
+            self.__resultsWidget.setCurrentItem(nextItem)
+            self.__entrySelected(nextItem)
+
+    @pyqtSlot()
+    def __previousResult(self):
+        """
+        Private slot to activate the previous result.
+        """
+        row = self.__resultsWidget.currentRow()
+        if row > 0:
+            prevItem = self.__resultsWidget.topLevelItem(row - 1)
+            self.__resultsWidget.setCurrentItem(prevItem)
+            self.__entrySelected(prevItem)
--- a/src/eric7/PdfViewer/PdfToCWidget.py	Mon Jan 16 11:56:23 2023 +0100
+++ b/src/eric7/PdfViewer/PdfToCWidget.py	Wed Jan 18 14:31:55 2023 +0100
@@ -99,12 +99,18 @@
         self.__tocModel.setDocument(document)
         self.__tocFilterModel = QSortFilterProxyModel(self)
         self.__tocFilterModel.setRecursiveFilteringEnabled(True)
+        self.__tocFilterModel.setFilterCaseSensitivity(
+            Qt.CaseSensitivity.CaseInsensitive
+        )
         self.__tocFilterModel.setSourceModel(self.__tocModel)
         self.__tocWidget.setModel(self.__tocFilterModel)
         self.__layout.addWidget(self.__tocWidget)
 
         self.setLayout(self.__layout)
 
+        self.__searchEdit.setEnabled(False)
+        self.__tocWidget.setEnabled(False)
+
         self.__tocWidget.activated.connect(self.__topicSelected)
         document.statusChanged.connect(self.__handleDocumentStatus)
         self.__searchEdit.textEdited.connect(self.__searchTextChanged)
@@ -133,11 +139,15 @@
         @param status document status
         @type QPdfDocument.Status
         """
-        if status == QPdfDocument.Status.Ready:
+        ready = status == QPdfDocument.Status.Ready
+        if ready:
             self.__tocWidget.expandAll()
             for column in range(self.__tocModel.columnCount(QModelIndex())):
                 self.__tocWidget.resizeColumnToContents(column)
 
+        self.__searchEdit.setEnabled(ready)
+        self.__tocWidget.setEnabled(ready)
+
     @pyqtSlot(str)
     def __searchTextChanged(self, text):
         """
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/eric7/PdfViewer/PdfView.py	Wed Jan 18 14:31:55 2023 +0100
@@ -0,0 +1,195 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2023 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a specialized PDF view class.
+"""
+
+from PyQt6.QtCore import QSize, Qt, pyqtSlot, QEvent
+from PyQt6.QtGui import QGuiApplication
+from PyQt6.QtPdfWidgets import QPdfView
+
+from .PdfZoomSelector import PdfZoomSelector
+
+
+class PdfView(QPdfView):
+    """
+    Class implementing a specialized PDF view.
+    """
+
+    def __init__(self, parent):
+        """
+        Constructor
+
+        @param parent reference to the parent widget
+        @type QWidget
+        """
+        super().__init__(parent)
+
+        self.__screenResolution = (
+            QGuiApplication.primaryScreen().logicalDotsPerInch() / 72.0
+        )
+
+        self.grabGesture(Qt.GestureType.PinchGesture)
+
+    def __zoomInOut(self, zoomIn):
+        """
+        Private method to zoom into or out of the view.
+
+        @param zoomIn flag indicating to zoom into the view
+        @type bool
+        """
+        zoomFactor = self.__zoomFactorForMode(self.zoomMode())
+
+        factors = list(PdfZoomSelector.ZoomValues)
+        factors.append(self.__zoomFactorForMode(QPdfView.ZoomMode.FitInView))
+        factors.append(self.__zoomFactorForMode(QPdfView.ZoomMode.FitToWidth))
+        if zoomIn:
+            factors.sort()
+            if zoomFactor >= factors[-1]:
+                return
+            newIndex = next(x for x, val in enumerate(factors) if val > zoomFactor)
+        else:
+            factors.sort(reverse=True)
+            if zoomFactor <= factors[-1]:
+                return
+            newIndex = next(x for x, val in enumerate(factors) if val < zoomFactor)
+        newFactor = factors[newIndex]
+        if newFactor == self.__zoomFactorForMode(QPdfView.ZoomMode.FitInView):
+            self.setZoomMode(QPdfView.ZoomMode.FitInView)
+            self.zoomModeChanged.emit(QPdfView.ZoomMode.FitInView)
+        elif newFactor == self.__zoomFactorForMode(QPdfView.ZoomMode.FitToWidth):
+            self.setZoomMode(QPdfView.ZoomMode.FitToWidth)
+            self.zoomModeChanged.emit(QPdfView.ZoomMode.FitToWidth)
+        else:
+            self.setZoomFactor(newFactor)
+            self.zoomFactorChanged.emit(newFactor)
+            self.setZoomMode(QPdfView.ZoomMode.Custom)
+            self.zoomModeChanged.emit(QPdfView.ZoomMode.Custom)
+
+    def __zoomFactorForMode(self, zoomMode):
+        """
+        Private method to calculate the zoom factor iaw. the current zoom mode.
+
+        @param zoomMode zoom mode to get the zoom factor for
+        @type QPdfView.ZoomMode
+        @return zoom factor
+        @rtype float
+        """
+        if zoomMode == QPdfView.ZoomMode.Custom:
+            return self.zoomFactor()
+        else:
+            viewport = self.viewport()
+            curPage = self.pageNavigator().currentPage()
+            margins = self.documentMargins()
+            if zoomMode == QPdfView.ZoomMode.FitToWidth:
+                pageSize = (
+                    self.document().pagePointSize(curPage) * self.__screenResolution
+                ).toSize()
+                factor = (
+                    viewport.width() - margins.left() - margins.right()
+                ) / pageSize.width()
+                pageSize *= factor
+            else:
+                # QPdfView.ZoomMode.FitInView
+                viewportSize = viewport.size() + QSize(
+                    -margins.left() - margins.right(), -self.pageSpacing()
+                )
+                pageSize = (
+                    self.document().pagePointSize(curPage) * self.__screenResolution
+                ).toSize()
+                pageSize = pageSize.scaled(
+                    viewportSize, Qt.AspectRatioMode.KeepAspectRatio
+                )
+            zoomFactor = pageSize.width() / (
+                self.document().pagePointSize(curPage) * self.__screenResolution
+            ).width()
+            return zoomFactor
+
+    @pyqtSlot()
+    def zoomIn(self):
+        """
+        Public slot to zoom into the view.
+        """
+        self.__zoomInOut(True)
+
+    @pyqtSlot()
+    def zoomOut(self):
+        """
+        Public slot to zoom out of the view.
+        """
+        self.__zoomInOut(False)
+
+    @pyqtSlot()
+    def zoomReset(self):
+        """
+        Public slot to reset the zoom factor of the view.
+        """
+        if self.zoomMode() != QPdfView.ZoomMode.Custom or self.zoomFactor() != 1.0:
+            self.setZoomFactor(1.0)
+            self.zoomFactorChanged.emit(1.0)
+            self.setZoomMode(QPdfView.ZoomMode.Custom)
+            self.zoomModeChanged.emit(QPdfView.ZoomMode.Custom)
+
+    def wheelEvent(self, evt):
+        """
+        Protected method to handle wheel events.
+
+        @param evt reference to the wheel event
+        @type QWheelEvent
+        """
+        delta = evt.angleDelta().y()
+        if evt.modifiers() & Qt.KeyboardModifier.ControlModifier:
+            if delta < 0:
+                self.zoomOut()
+            elif delta > 0:
+                self.zoomIn()
+            evt.accept()
+            return
+
+        elif evt.modifiers() & Qt.KeyboardModifier.ShiftModifier:
+            if delta < 0:
+                self.pageNavigator().back()
+            elif delta > 0:
+                self.pageNavigator().forward()
+            evt.accept()
+            return
+
+        super().wheelEvent(evt)
+
+    def event(self, evt):
+        """
+        Public method handling events.
+
+        @param evt reference to the event
+        @type QEvent
+        @return flag indicating, if the event was handled
+        @rtype bool
+        """
+        if evt.type() == QEvent.Type.Gesture:
+            self.gestureEvent(evt)
+            return True
+
+        return super().event(evt)
+
+    def gestureEvent(self, evt):
+        """
+        Protected method handling gesture events.
+
+        @param evt reference to the gesture event
+        @type QGestureEvent
+        """
+        pinch = evt.gesture(Qt.GestureType.PinchGesture)
+        if pinch:
+            if pinch.state() == Qt.GestureState.GestureStarted:
+                pinch.setTotalScaleFactor(self.__zoomFactorForMode(self.zoomMode()))
+            elif pinch.state() == Qt.GestureState.GestureUpdated:
+                if self.zoomMode() != QPdfView.ZoomMode.Custom:
+                    self.setZoomMode(QPdfView.ZoomMode.Custom)
+                    self.zoomModeChanged.emit(QPdfView.ZoomMode.Custom)
+                zoomFactor = pinch.totalScaleFactor()
+                self.setZoomFactor(zoomFactor)
+                self.zoomFactorChanged.emit(zoomFactor)
+            evt.accept()
--- a/src/eric7/PdfViewer/PdfViewerWindow.py	Mon Jan 16 11:56:23 2023 +0100
+++ b/src/eric7/PdfViewer/PdfViewerWindow.py	Wed Jan 18 14:31:55 2023 +0100
@@ -12,8 +12,10 @@
 import pathlib
 
 from PyQt6.QtCore import Qt, pyqtSignal, QSize, pyqtSlot, QPointF
-from PyQt6.QtGui import QAction, QKeySequence, QGuiApplication, QActionGroup
-from PyQt6.QtPdf import QPdfDocument
+from PyQt6.QtGui import (
+    QAction, QKeySequence, QActionGroup, QGuiApplication, QClipboard
+)
+from PyQt6.QtPdf import QPdfDocument, QPdfLink
 from PyQt6.QtPdfWidgets import QPdfView
 from PyQt6.QtWidgets import (
     QWhatsThis, QMenu, QTabWidget, QSplitter, QToolBar, QDialog
@@ -32,6 +34,8 @@
 from .PdfZoomSelector import PdfZoomSelector
 from .PdfToCWidget import PdfToCWidget
 from .PdfInfoWidget import PdfInfoWidget
+from .PdfSearchWidget import PdfSearchWidget
+from .PdfView import PdfView
 
 
 class PdfViewerWindow(EricMainWindow):
@@ -67,19 +71,16 @@
         self.__fromEric = fromEric
         self.setWindowIcon(EricPixmapCache.getIcon("ericPdf"))
 
-        self.__screenResolution = (
-            QGuiApplication.primaryScreen().logicalDotsPerInch() / 72.0
-        )
-
         if not self.__fromEric:
             self.setStyle(Preferences.getUI("Style"), Preferences.getUI("StyleSheet"))
 
         self.__pdfDocument = QPdfDocument(self)
 
         self.__cw = QSplitter(Qt.Orientation.Horizontal, self)
+        self.__cw.setChildrenCollapsible(False)
         self.__info = QTabWidget(self)
         self.__cw.addWidget(self.__info)
-        self.__view = QPdfView(self)
+        self.__view = PdfView(self)
         self.__view.setDocument(self.__pdfDocument)
         self.__cw.addWidget(self.__view)
         self.setCentralWidget(self.__cw)
@@ -90,7 +91,13 @@
             self.__documentInfoWidget, EricPixmapCache.getIcon("documentProperties"), ""
         )
         self.__info.setTabToolTip(index, self.tr("Document Properties"))
-        
+
+        self.__searchWidget = PdfSearchWidget(self.__pdfDocument, self)
+        index = self.__info.addTab(
+            self.__searchWidget, EricPixmapCache.getIcon("find"), ""
+        )
+        self.__info.setTabToolTip(index, self.tr("Search"))
+
         self.__tocWidget = PdfToCWidget(self.__pdfDocument, self)
         index = self.__info.addTab(
             self.__tocWidget, EricPixmapCache.getIcon("listSelection"), ""
@@ -140,6 +147,7 @@
         self.__view.zoomModeChanged.connect(self.__zoomSelector.setZoomMode)
 
         self.__tocWidget.topicActivated.connect(self.__tocActivated)
+        self.__searchWidget.searchResultActivated.connect(self.__handleSearchResult)
 
         PdfViewerWindow.windows.append(self)
 
@@ -168,6 +176,8 @@
         self.__initFileActions()
         self.__initGotoActions()
         self.__initViewActions()
+        self.__initEditActions()
+        self.__initSettingsActions()
         self.__initHelpActions()
 
     def __initFileActions(self):
@@ -229,7 +239,6 @@
         self.reloadAct.triggered.connect(self.__reload)
         self.__actions.append(self.reloadAct)
 
-        # TODO: maybe this will be a tab of the side widget
         self.propertiesAct = EricAction(
             self.tr("Properties"),
             EricPixmapCache.getIcon("documentProperties"),
@@ -409,6 +418,7 @@
         """
         Private method to define the view related user interface actions.
         """
+        # TODO: add Fullscreen action
         self.zoomInAct = EricAction(
             self.tr("Zoom in"),
             EricPixmapCache.getIcon("zoomIn"),
@@ -471,20 +481,61 @@
         self.zoomWholePageAct.setCheckable(True)
         self.__actions.append(self.zoomWholePageAct)
 
-        ##self.__displayModeActGrp = QActionGroup(self)
-##
-        ##self.displayModeSingleAct = EricAction(
-            ##self.tr("Whole Page"),
-            ##EricPixmapCache.getIcon("zoomFitPage"),
-            ##self.tr("Whole &Page"),
-            ##0,
-            ##0,
-            ##self,
-            ##"pdfviewer_view_zoomwholePage",
-        ##)
-        ##self.displayModeSingleAct.triggered.connect(self.__zoomWholePage)
-        ##self.displayModeSingleAct.setCheckable(True)
-        ##self.__actions.append(self.displayModeSingleAct)
+    def __initEditActions(self):
+        """
+        Private method to create the Edit actions.
+        """
+        self.copyAllAct = EricAction(
+            self.tr("Copy All Text"),
+            EricPixmapCache.getIcon("editCopy"),
+            self.tr("&Copy All Text"),
+            QKeySequence(self.tr("Ctrl+C", "Edit|Copy All Text")),
+            0,
+            self,
+            "pdfviewer_edit_copyall",
+        )
+        self.copyAllAct.triggered.connect(self.__copyAllText)
+        self.__actions.append(self.copyAllAct)
+
+        self.copyAllPageAct = EricAction(
+            self.tr("Copy All Page Text"),
+            self.tr("Copy &All Page Text"),
+            QKeySequence(self.tr("Shift+Ctrl+C", "Edit|Copy All Page Text")),
+            0,
+            self,
+            "pdfviewer_edit_copyallpage",
+        )
+        self.copyAllPageAct.triggered.connect(self.__copyAllTextOfPage)
+        self.__actions.append(self.copyAllPageAct)
+
+    def __initSettingsActions(self):
+        """
+        Private method to create the Settings actions.
+        """
+        self.sidebarAct = EricAction(
+            self.tr("Show Sidebar"),
+            EricPixmapCache.getIcon("sidebarExpandLeft"),
+            self.tr("Show &Sidebar"),
+            0,
+            0,
+            self,
+            "pdfviewer_settings_sidebar",
+        )
+        self.sidebarAct.triggered.connect(self.__toggleSideBar)
+        self.sidebarAct.setCheckable(True)
+        self.__actions.append(self.sidebarAct)
+
+        self.openRecentNewAct = EricAction(
+            self.tr("Open Recent File in New Window"),
+            self.tr("Open Recent File in New Window"),
+            0,
+            0,
+            self,
+            "pdfviewer_settings_openrecent new",
+        )
+        self.openRecentNewAct.triggered.connect(self.__toggleOpenRecentNew)
+        self.openRecentNewAct.setCheckable(True)
+        self.__actions.append(self.sidebarAct)
 
     def __initHelpActions(self):
         """
@@ -577,18 +628,6 @@
         self.zoomWholePageAct.setEnabled(ready)
         self.__zoomSelector.setEnabled(ready)
 
-        # TODO: not yet implemented
-
-    ##def setRecentPath(self, openPath):
-        ##"""
-        ##Public method to set the last open path.
-##
-        ##@param openPath least recently used open path
-        ##@type str
-        ##"""
-        ##if openPath:
-            ##self.__lastOpenPath = openPath
-
     def __initMenus(self):
         """
         Private method to create the menus.
@@ -635,6 +674,12 @@
         self.__displayModeActGrp.addAction(self.__continuousPageAct)
         modeMenu.triggered.connect(self.__displayModeSelected)
 
+        menu = mb.addMenu(self.tr("&Edit"))
+        menu.setTearOffEnabled(True)
+        menu.addAction(self.copyAllAct)
+        menu.addSeparator()
+        menu.addAction(self.copyAllPageAct)
+
         menu = mb.addMenu(self.tr("&Go To"))
         menu.setTearOffEnabled(True)
         menu.addAction(self.previousPageAct)
@@ -648,6 +693,12 @@
         menu.addSeparator()
         menu.addAction(self.gotoAct)
 
+        menu = mb.addMenu(self.tr("Se&ttings"))
+        menu.setTearOffEnabled(True)
+        menu.addAction(self.sidebarAct)
+        menu.addSeparator()
+        menu.addAction(self.openRecentNewAct)
+
         mb.addSeparator()
 
         menu = mb.addMenu(self.tr("&Help"))
@@ -665,6 +716,10 @@
         mainToolBar.setMovable(False)
         mainToolBar.setFloatable(False)
 
+        # 0. Sidebar action
+        mainToolBar.addAction(self.sidebarAct)
+        mainToolBar.addSeparator()
+
         # 1. File actions
         mainToolBar.addAction(self.newWindowAct)
         mainToolBar.addAction(self.openAct)
@@ -683,7 +738,6 @@
         mainToolBar.addSeparator()
 
         # 3. View actions
-        # TODO: not yet implemented
         mainToolBar.addAction(self.zoomOutAct)
         mainToolBar.addWidget(self.__zoomSelector)
         mainToolBar.addAction(self.zoomInAct)
@@ -727,11 +781,16 @@
         """
         Private method to save the PDF Viewer state data.
         """
-        # TODO: save current zoom factor and mode + page mode
         state = self.saveState()
         Preferences.setPdfViewer("PdfViewerState", state)
         splitterState = self.__cw.saveState()
         Preferences.setPdfViewer("PdfViewerSplitterState", splitterState)
+        Preferences.setPdfViewer("PdfViewerSidebarVisible", self.sidebarAct.isChecked())
+        Preferences.setPdfViewer("PdfViewerZoomFactor", self.__view.zoomFactor())
+        Preferences.setPdfViewer("PdfViewerZoomMode", self.__view.zoomMode())
+        Preferences.setPdfViewer(
+            "PdfViewerOpenRecentInNewWindow", self.openRecentNewAct.isChecked()
+        )
 
         if not self.__fromEric:
             Preferences.syncPreferences()
@@ -740,11 +799,16 @@
         """
         Private method to restore the PDF Viewer state data.
         """
-        # TODO: restore zoom factor and mode + page mode
         state = Preferences.getPdfViewer("PdfViewerState")
         self.restoreState(state)
         splitterState = Preferences.getPdfViewer("PdfViewerSplitterState")
         self.__cw.restoreState(splitterState)
+        self.__toggleSideBar(Preferences.getPdfViewer("PdfViewerSidebarVisible"))
+        self.__view.setZoomFactor(Preferences.getPdfViewer("PdfViewerZoomFactor"))
+        self.__view.setZoomMode(Preferences.getPdfViewer("PdfViewerZoomMode"))
+        self.openRecentNewAct.setChecked(
+            Preferences.getPdfViewer("PdfViewerOpenRecentInNewWindow")
+        )
 
     def __setViewerTitle(self, title):
         """
@@ -849,9 +913,12 @@
             self.__loadPdfFile(fileName)
 
     @pyqtSlot()
-    def __openPdfFileNewWindow(self):
+    def __openPdfFileNewWindow(self, fileName=""):
         """
         Private slot called to open a PDF file in new viewer window.
+
+        @param fileName name of the file to open (defaults to "")
+        @type str (optional)
         """
         if (
             not self.__lastOpenPath
@@ -860,12 +927,13 @@
         ):
             self.__lastOpenPath = self.__project.getProjectPath()
 
-        fileName = EricFileDialog.getOpenFileName(
-            self,
-            self.tr("Open PDF File"),
-            self.__lastOpenPath,
-            self.tr("PDF Files (*.pdf);;All Files (*)"),
-        )
+        if not fileName:
+            fileName = EricFileDialog.getOpenFileName(
+                self,
+                self.tr("Open PDF File"),
+                self.__lastOpenPath,
+                self.tr("PDF Files (*.pdf);;All Files (*)"),
+            )
         if fileName:
             viewer = PdfViewerWindow(
                 fileName=fileName,
@@ -918,6 +986,16 @@
         nav = self.__view.pageNavigator()
         nav.jump(page, QPointF(), zoomFactor)
 
+    @pyqtSlot(QPdfLink)
+    def __handleSearchResult(self, link):
+        """
+        Private slot to handle the selection of a search result.
+
+        @param link PDF link to navigate to
+        @type QPdfLink
+        """
+        self.__view.pageNavigator().jump(link)
+
     def __setCurrentFile(self, fileName):
         """
         Private method to register the file name of the current file.
@@ -1008,9 +1086,11 @@
         @param act reference to the action that triggered
         @type QAction
         """
-        # TODO: add config option to open recent files in new viewer or the same one
         fileName = act.data()
-        self.__loadPdfFile(fileName)
+        if Preferences.getPdfViewer("PdfViewerOpenRecentInNewWindow"):
+            self.__openPdfFileNewWindow(fileName)
+        else:
+            self.__loadPdfFile(fileName)
 
     @pyqtSlot()
     def __clearRecent(self):
@@ -1059,14 +1139,14 @@
         """
         Private slot to open a dialog showing the document properties.
         """
-        # TODO: not yet implemented
+        self.__toggleSideBar(True)
+        self.__info.setCurrentWidget(self.__documentInfoWidget)
 
     @pyqtSlot()
     def __gotoPage(self):
         """
         Private slot to show a dialog to select a page to jump to.
         """
-        # TODO: not yet implemented
         from .PdfGoToDialog import PdfGoToDialog
 
         dlg = PdfGoToDialog(
@@ -1124,60 +1204,26 @@
         """
         self.__view.pageNavigator().forward()
 
-    def __zoomInOut(self, zoomIn):
-        """
-        Private method to zoom into or out of the view.
-
-        @param zoomIn flag indicating to zoom into the view
-        @type bool
-        """
-        zoomFactor = self.__zoomFactorForMode(self.__view.zoomMode())
-
-        factors = list(PdfZoomSelector.ZoomValues)
-        factors.append(self.__zoomFactorForMode(QPdfView.ZoomMode.FitInView))
-        factors.append(self.__zoomFactorForMode(QPdfView.ZoomMode.FitToWidth))
-        if zoomIn:
-            factors.sort()
-            if zoomFactor >= factors[-1]:
-                return
-            newIndex = next(x for x, val in enumerate(factors) if val > zoomFactor)
-        else:
-            factors.sort(reverse=True)
-            if zoomFactor <= factors[-1]:
-                return
-            newIndex = next(x for x, val in enumerate(factors) if val < zoomFactor)
-        newFactor = factors[newIndex]
-        if newFactor == self.__zoomFactorForMode(QPdfView.ZoomMode.FitInView):
-            self.__zoomWholePage(True)
-        elif newFactor == self.__zoomFactorForMode(QPdfView.ZoomMode.FitToWidth):
-            self.__zoomPageWidth(True)
-        else:
-            self.__view.setZoomFactor(newFactor)
-            self.__zoomSelector.setZoomFactor(newFactor)
-            self.__zoomModeChanged(QPdfView.ZoomMode.Custom)
-
     @pyqtSlot()
     def __zoomIn(self):
         """
         Private slot to zoom into the view.
         """
-        self.__zoomInOut(True)
+        self.__view.zoomIn()
 
     @pyqtSlot()
     def __zoomOut(self):
         """
         Private slot to zoom out of the view.
         """
-        self.__zoomInOut(False)
+        self.__view.zoomOut()
 
     @pyqtSlot()
     def __zoomReset(self):
         """
         Private slot to reset the zoom factor of the view.
         """
-        self.__view.setZoomFactor(1.0)
-        self.__zoomSelector.setZoomFactor(1.0)
-        self.__zoomModeChanged(QPdfView.ZoomMode.Custom)
+        self.__view.zoomReset()
 
     @pyqtSlot(bool)
     def __zoomPageWidth(self, checked):
@@ -1214,52 +1260,6 @@
         self.zoomWholePageAct.setChecked(zoomMode == QPdfView.ZoomMode.FitInView)
         self.zoomPageWidthAct.setChecked(zoomMode == QPdfView.ZoomMode.FitToWidth)
 
-    def __zoomFactorForMode(self, zoomMode):
-        """
-        Private method to calculate the zoom factor iaw. the current zoom mode.
-
-        @param zoomMode zoom mode to get the zoom factor for
-        @type QPdfView.ZoomMode
-        @return zoom factor
-        @rtype float
-        """
-        if zoomMode == QPdfView.ZoomMode.Custom:
-            return self.__view.zoomFactor()
-        else:
-            viewport = self.__view.viewport()
-            curPage = self.__view.pageNavigator().currentPage()
-            margins = self.__view.documentMargins()
-            if zoomMode == QPdfView.ZoomMode.FitToWidth:
-                pageSize = (
-                    self.__pdfDocument.pagePointSize(curPage) * self.__screenResolution
-                ).toSize()
-                factor = (
-                    viewport.width() - margins.left() - margins.right()
-                ) / pageSize.width()
-                pageSize *= factor
-            else:
-                # QPdfView.ZoomMode.FitInView
-                viewportSize = viewport.size() + QSize(
-                    -margins.left() - margins.right(), -self.__view.pageSpacing()
-                )
-                pageSize = (
-                    self.__pdfDocument.pagePointSize(curPage) * self.__screenResolution
-                ).toSize()
-                pageSize = pageSize.scaled(
-                    viewportSize, Qt.AspectRatioMode.KeepAspectRatio
-                )
-            zoomFactor = pageSize.width() / (
-                self.__pdfDocument.pagePointSize(curPage) * self.__screenResolution
-            ).width()
-            return zoomFactor
-
-    @pyqtSlot()
-    def __setFocusToView(self):
-        """
-        Private slot to set the focus to the PDF document view.
-        """
-        self.__view.setFocus(Qt.FocusReason.OtherFocusReason)
-
     @pyqtSlot(QAction)
     def __displayModeSelected(self, act):
         """
@@ -1285,3 +1285,47 @@
             self.__view.setPageMode(QPdfView.PageMode.MultiPage)
             self.__continuousPageAct.setChecked(True)
         return 
+
+    @pyqtSlot(bool)
+    def __toggleSideBar(self, visible):
+        """
+        Private slot to togle the sidebar (info) widget.
+
+        @param visible desired state of the sidebar
+        @type bool
+        """
+        self.sidebarAct.setChecked(visible)
+        self.__info.setVisible(visible)
+        Preferences.setPdfViewer("PdfViewerSidebarVisible", visible)
+
+    @pyqtSlot(bool)
+    def __toggleOpenRecentNew(self, on):
+        """
+        Private slot to toggle the 'Open Recent File in New Window' action.
+
+        @param on desired state of the action
+        @type bool
+        """
+        Preferences.setPdfViewer("PdfViewerOpenRecentInNewWindow", on)
+
+    @pyqtSlot()
+    def __copyAllTextOfPage(self):
+        """
+        Private slot to copy all text of the current page to the system clipboard.
+        """
+        selection = self.__pdfDocument.getAllText(
+            self.__view.pageNavigator().currentPage()
+        )
+        selection.copyToClipboard()
+
+    @pyqtSlot()
+    def __copyAllText(self):
+        """
+        Private slot to copy all text of the document to the system clipboard.
+        """
+        textPages = []
+        for page in range(self.__pdfDocument.pageCount()):
+            textPages.append(self.__pdfDocument.getAllText(page).text())
+        QGuiApplication.clipboard().setText(
+            "\r\n".join(textPages), QClipboard.Mode.Clipboard
+        )
--- a/src/eric7/Preferences/__init__.py	Mon Jan 16 11:56:23 2023 +0100
+++ b/src/eric7/Preferences/__init__.py	Wed Jan 18 14:31:55 2023 +0100
@@ -39,6 +39,7 @@
     QUrl,
 )
 from PyQt6.QtGui import QColor, QFont, QPalette
+from PyQt6.QtPdfWidgets import QPdfView
 from PyQt6.QtWidgets import QApplication
 
 try:
@@ -1635,7 +1636,12 @@
         "PdfViewerState": QByteArray(),
         "PdfViewerSplitterState": QByteArray(),
         "RecentNumber": 9,
-        "PdfViewerDisplayMode": "single"  # single or continuous
+        "PdfViewerDisplayMode": "single",  # single or continuous
+        "PdfViewerSidebarVisible": True,
+        "PdfViewerZoomMode": QPdfView.ZoomMode.Custom.value,
+        "PdfViewerZoomFactor": 1.0,
+        "PdfViewerOpenRecentInNewWindow": False,
+        "PdfSearchContextLength": 30,
     }
 
 
@@ -3854,10 +3860,24 @@
     @return the requested user setting
     @rtype Any
     """
-    if key in ("RecentNumber"):
+    if key in ("RecentNumber", "PdfSearchContextLength"):
         return int(
             Prefs.settings.value("PdfViewer/" + key, Prefs.pdfViewerDefaults[key])
         )
+    elif key in ("PdfViewerSidebarVisible", "PdfViewerOpenRecentInNewWindow"):
+        return toBool(
+            Prefs.settings.value("PdfViewer/" + key, Prefs.pdfViewerDefaults[key])
+        )
+    elif key in ("PdfViewerZoomFactor",):
+        return float(
+            Prefs.settings.value("PdfViewer/" + key, Prefs.pdfViewerDefaults[key])
+        )
+    elif key == "PdfViewerZoomMode":
+        return QPdfView.ZoomMode(
+            int(
+                Prefs.settings.value("PdfViewer/" + key, Prefs.pdfViewerDefaults[key])
+            )
+        )
     else:
         return Prefs.settings.value("PdfViewer/" + key, Prefs.pdfViewerDefaults[key])
 
@@ -3871,6 +3891,8 @@
     @param value the value to be set
     @type Any
     """
+    if key == "PdfViewerZoomMode":
+        value = value.value
     Prefs.settings.setValue("PdfViewer/" + key, value)
 
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/eric7/icons/breeze-dark/sidebarExpandLeft.svg	Wed Jan 18 14:31:55 2023 +0100
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   viewBox="0 0 22 22"
+   version="1.1"
+   id="svg9"
+   sodipodi:docname="sidebarExpandLeft.svg"
+   inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs13" />
+  <sodipodi:namedview
+     id="namedview11"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     showgrid="false"
+     inkscape:zoom="29.272727"
+     inkscape:cx="11"
+     inkscape:cy="11"
+     inkscape:window-width="2580"
+     inkscape:window-height="1080"
+     inkscape:window-x="426"
+     inkscape:window-y="146"
+     inkscape:window-maximized="0"
+     inkscape:current-layer="svg9" />
+  <style
+     type="text/css"
+     id="current-color-scheme">
+        .ColorScheme-Text {
+            color:#eff0f1;
+        }
+    </style>
+  <g
+     class="ColorScheme-Text"
+     fill="currentColor"
+     id="g7"
+     transform="matrix(1.25,0,0,1.25,-2.75,-2.75)">
+    <path
+       d="M 3,3 V 19 H 19 V 3 Z M 8,4 H 18 V 18 H 8 Z"
+       stroke-linecap="square"
+       stroke-linejoin="round"
+       id="path3" />
+    <path
+       d="M 11.353516,6.6464844 15.707031,11 11.353516,15.353516 10.646484,14.646484 14.292969,11 10.646484,7.3535156 Z"
+       id="path5" />
+  </g>
+</svg>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/eric7/icons/breeze-light/sidebarExpandLeft.svg	Wed Jan 18 14:31:55 2023 +0100
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   viewBox="0 0 22 22"
+   version="1.1"
+   id="svg9"
+   sodipodi:docname="sidebarExpandLeft.svg"
+   inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs13" />
+  <sodipodi:namedview
+     id="namedview11"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     showgrid="false"
+     inkscape:zoom="29.272727"
+     inkscape:cx="11"
+     inkscape:cy="11"
+     inkscape:window-width="2580"
+     inkscape:window-height="1080"
+     inkscape:window-x="426"
+     inkscape:window-y="146"
+     inkscape:window-maximized="0"
+     inkscape:current-layer="svg9" />
+  <style
+     type="text/css"
+     id="current-color-scheme">
+        .ColorScheme-Text {
+            color:#232629;
+        }
+    </style>
+  <g
+     class="ColorScheme-Text"
+     fill="currentColor"
+     id="g7"
+     transform="matrix(1.25,0,0,1.25,-2.75,-2.75)">
+    <path
+       d="M 3,3 V 19 H 19 V 3 Z M 8,4 H 18 V 18 H 8 Z"
+       stroke-linecap="square"
+       stroke-linejoin="round"
+       id="path3" />
+    <path
+       d="M 11.353516,6.6464844 15.707031,11 11.353516,15.353516 10.646484,14.646484 14.292969,11 10.646484,7.3535156 Z"
+       id="path5" />
+  </g>
+</svg>
Binary file src/eric7/icons/oxygen/sidebarExpandLeft.png has changed

eric ide

mercurial