|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2021 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the QTextBrowser based help viewer class. |
|
8 """ |
|
9 |
|
10 import contextlib |
|
11 import functools |
|
12 |
|
13 from PyQt6.QtCore import ( |
|
14 pyqtSlot, Qt, QByteArray, QUrl, QEvent, QCoreApplication, QPoint |
|
15 ) |
|
16 from PyQt6.QtGui import QDesktopServices, QImage, QGuiApplication, QClipboard |
|
17 from PyQt6.QtWidgets import QTextBrowser, QMenu |
|
18 |
|
19 from .HelpViewerImpl import HelpViewerImpl |
|
20 |
|
21 import UI.PixmapCache |
|
22 |
|
23 |
|
24 AboutBlank = QCoreApplication.translate( |
|
25 "HelpViewer", |
|
26 "<html>" |
|
27 "<head><title>about:blank</title></head>" |
|
28 "<body></body>" |
|
29 "</html>") |
|
30 |
|
31 PageNotFound = QCoreApplication.translate( |
|
32 "HelpViewer", |
|
33 """<html>""" |
|
34 """<head><title>Error 404...</title></head>""" |
|
35 """<body><div align="center"><br><br>""" |
|
36 """<h1>The page could not be found</h1><br>""" |
|
37 """<h3>'{0}'</h3></div></body>""" |
|
38 """</html>""") |
|
39 |
|
40 |
|
41 class HelpViewerImplQTB(HelpViewerImpl, QTextBrowser): |
|
42 """ |
|
43 Class implementing the QTextBrowser based help viewer class. |
|
44 """ |
|
45 def __init__(self, engine, parent=None): |
|
46 """ |
|
47 Constructor |
|
48 |
|
49 @param engine reference to the help engine |
|
50 @type QHelpEngine |
|
51 @param parent reference to the parent widget |
|
52 @type QWidget |
|
53 """ |
|
54 QTextBrowser.__init__(self, parent=parent) |
|
55 HelpViewerImpl.__init__(self, engine) |
|
56 |
|
57 self.__helpViewerWidget = parent |
|
58 |
|
59 self.__zoomCount = 0 |
|
60 |
|
61 self.__menu = QMenu(self) |
|
62 self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) |
|
63 self.customContextMenuRequested.connect(self.__showContextMenu) |
|
64 |
|
65 self.sourceChanged.connect(self.titleChanged) |
|
66 |
|
67 self.grabGesture(Qt.GestureType.PinchGesture) |
|
68 |
|
69 def setLink(self, url): |
|
70 """ |
|
71 Public method to set the URL of the document to be shown. |
|
72 |
|
73 @param url source of the document |
|
74 @type QUrl |
|
75 """ |
|
76 if url.toString() == "about:blank": |
|
77 self.setHtml(self.__helpViewerWidget.emptyDocument()) |
|
78 else: |
|
79 self.setSource(url) |
|
80 |
|
81 def link(self): |
|
82 """ |
|
83 Public method to get the URL of the shown document. |
|
84 |
|
85 @return URL of the document |
|
86 @rtype QUrl |
|
87 """ |
|
88 return self.source() |
|
89 |
|
90 def doSetSource(self, url, type_): |
|
91 """ |
|
92 Public method to load the data and show it. |
|
93 |
|
94 @param url URL of resource to load |
|
95 @type QUrl |
|
96 @param type_ type of the resource to load |
|
97 @type QTextDocument.ResourceType |
|
98 """ |
|
99 if not self.__canLoadResource(url): |
|
100 QDesktopServices.openUrl(url) |
|
101 return |
|
102 |
|
103 super().doSetSource(url, type_) |
|
104 |
|
105 self.sourceChanged.emit(url) |
|
106 self.loadFinished.emit(True) |
|
107 |
|
108 def loadResource(self, type_, name): |
|
109 """ |
|
110 Public method to load data of the specified type from the resource with |
|
111 the given name. |
|
112 |
|
113 @param type_ resource type |
|
114 @type int |
|
115 @param name resource name |
|
116 @type QUrl |
|
117 @return byte array containing the loaded data |
|
118 @rtype QByteArray |
|
119 """ |
|
120 ba = QByteArray() |
|
121 scheme = name.scheme() |
|
122 |
|
123 if type_ < 4: # QTextDocument.ResourceType.MarkdownResource |
|
124 if scheme == "about": |
|
125 if name.toString() == "about:blank": |
|
126 return QByteArray(AboutBlank.encode("utf-8")) |
|
127 elif scheme in ("file", ""): |
|
128 filePath = name.toLocalFile() |
|
129 with contextlib.suppress(OSError), open(filePath, "rb") as f: |
|
130 ba = QByteArray(f.read()) |
|
131 elif scheme == "qthelp": |
|
132 url = self._engine.findFile(name) |
|
133 if url.isValid(): |
|
134 ba = self._engine.fileData(url) |
|
135 |
|
136 if name.toString().lower().endswith(".svg"): |
|
137 image = QImage() |
|
138 image.loadFromData(ba, "svg") |
|
139 if not image.isNull(): |
|
140 return image |
|
141 |
|
142 if ba.isEmpty(): |
|
143 ba = QByteArray( |
|
144 PageNotFound.format(name.toString()).encode("utf-8") |
|
145 ) |
|
146 |
|
147 return ba |
|
148 |
|
149 def __canLoadResource(self, url): |
|
150 """ |
|
151 Private method to check, if the given resource can be loaded. |
|
152 |
|
153 @param url URL of resource to be loaded |
|
154 @type QUrl |
|
155 @return flag indicating, that the given URL can be handled |
|
156 @rtype bool |
|
157 """ |
|
158 scheme = url.scheme() |
|
159 return scheme in ("about", "qthelp", "file", "") |
|
160 |
|
161 def pageTitle(self): |
|
162 """ |
|
163 Public method get the page title. |
|
164 |
|
165 @return page title |
|
166 @rtype str |
|
167 """ |
|
168 titleStr = self.documentTitle() |
|
169 if not titleStr: |
|
170 url = self.link() |
|
171 |
|
172 titleStr = url.host() |
|
173 if not titleStr: |
|
174 titleStr = url.toString( |
|
175 QUrl.UrlFormattingOption.RemoveFragment) |
|
176 |
|
177 if not titleStr or titleStr == "about:blank": |
|
178 titleStr = self.tr("Empty Page") |
|
179 |
|
180 return titleStr |
|
181 |
|
182 def isEmptyPage(self): |
|
183 """ |
|
184 Public method to check, if the current page is the empty page. |
|
185 |
|
186 @return flag indicating an empty page is loaded |
|
187 @rtype bool |
|
188 """ |
|
189 return self.pageTitle() == self.tr("Empty Page") |
|
190 |
|
191 def mousePressEvent(self, evt): |
|
192 """ |
|
193 Protected method called by a mouse press event. |
|
194 |
|
195 @param evt reference to the mouse event |
|
196 @type QMouseEvent |
|
197 """ |
|
198 if evt.button() == Qt.MouseButton.XButton1: |
|
199 self.backward() |
|
200 evt.accept() |
|
201 elif evt.button() == Qt.MouseButton.XButton2: |
|
202 self.forward() |
|
203 evt.accept() |
|
204 else: |
|
205 super().mousePressEvent(evt) |
|
206 |
|
207 def mouseReleaseEvent(self, evt): |
|
208 """ |
|
209 Protected method called by a mouse release event. |
|
210 |
|
211 @param evt reference to the mouse event |
|
212 @type QMouseEvent |
|
213 """ |
|
214 hasModifier = evt.modifiers() != Qt.KeyboardModifier.NoModifier |
|
215 if evt.button() == Qt.MouseButton.LeftButton and hasModifier: |
|
216 |
|
217 anchor = self.anchorAt(evt.pos()) |
|
218 if anchor: |
|
219 url = self.link().resolved(QUrl(anchor)) |
|
220 if evt.modifiers() & Qt.KeyboardModifier.ControlModifier: |
|
221 self.__helpViewerWidget.openUrlNewBackgroundPage(url) |
|
222 else: |
|
223 self.__helpViewerWidget.openUrlNewPage(url) |
|
224 evt.accept() |
|
225 else: |
|
226 super().mousePressEvent(evt) |
|
227 |
|
228 def gotoHistory(self, index): |
|
229 """ |
|
230 Public method to step through the history. |
|
231 |
|
232 @param index history index (<0 backward, >0 forward) |
|
233 @type int |
|
234 """ |
|
235 if index < 0: |
|
236 # backward |
|
237 for _ind in range(-index): |
|
238 self.backward() |
|
239 else: |
|
240 # forward |
|
241 for _ind in range(index): |
|
242 self.forward() |
|
243 |
|
244 def isBackwardAvailable(self): |
|
245 """ |
|
246 Public method to check, if stepping backward through the history is |
|
247 available. |
|
248 |
|
249 @return flag indicating backward stepping is available |
|
250 @rtype bool |
|
251 """ |
|
252 return QTextBrowser.isBackwardAvailable(self) |
|
253 |
|
254 def isForwardAvailable(self): |
|
255 """ |
|
256 Public method to check, if stepping forward through the history is |
|
257 available. |
|
258 |
|
259 @return flag indicating forward stepping is available |
|
260 @rtype bool |
|
261 """ |
|
262 return QTextBrowser.isForwardAvailable(self) |
|
263 |
|
264 def scaleUp(self): |
|
265 """ |
|
266 Public method to zoom in. |
|
267 """ |
|
268 if self.__zoomCount < 10: |
|
269 self.__zoomCount += 1 |
|
270 self.zoomIn() |
|
271 self.zoomChanged.emit() |
|
272 |
|
273 def scaleDown(self): |
|
274 """ |
|
275 Public method to zoom out. |
|
276 """ |
|
277 if self.__zoomCount > -5: |
|
278 self.__zoomCount -= 1 |
|
279 self.zoomOut() |
|
280 self.zoomChanged.emit() |
|
281 |
|
282 def setScale(self, scale): |
|
283 """ |
|
284 Public method to set the zoom level. |
|
285 |
|
286 @param scale zoom level to set |
|
287 @type int |
|
288 """ |
|
289 if -5 <= scale <= 10: |
|
290 self.zoomOut(scale) |
|
291 self.__zoomCount = scale |
|
292 self.zoomChanged.emit() |
|
293 |
|
294 def resetScale(self): |
|
295 """ |
|
296 Public method to reset the zoom level. |
|
297 """ |
|
298 if self.__zoomCount != 0: |
|
299 self.zoomOut(self.__zoomCount) |
|
300 self.zoomChanged.emit() |
|
301 self.__zoomCount = 0 |
|
302 |
|
303 def scale(self): |
|
304 """ |
|
305 Public method to get the zoom level. |
|
306 |
|
307 @return current zoom level |
|
308 @rtype int |
|
309 """ |
|
310 return self.__zoomCount |
|
311 |
|
312 def isScaleUpAvailable(self): |
|
313 """ |
|
314 Public method to check, if the max. zoom level is reached. |
|
315 |
|
316 @return flag indicating scale up is available |
|
317 @rtype bool |
|
318 """ |
|
319 return self.__zoomCount < 10 |
|
320 |
|
321 def isScaleDownAvailable(self): |
|
322 """ |
|
323 Public method to check, if the min. zoom level is reached. |
|
324 |
|
325 @return flag indicating scale down is available |
|
326 @rtype bool |
|
327 """ |
|
328 return self.__zoomCount > -5 |
|
329 |
|
330 def wheelEvent(self, evt): |
|
331 """ |
|
332 Protected method to handle wheel event to zoom. |
|
333 |
|
334 @param evt reference to the event object |
|
335 @type QWheelEvent |
|
336 """ |
|
337 delta = evt.angleDelta().y() |
|
338 if evt.modifiers() == Qt.KeyboardModifier.ControlModifier: |
|
339 if delta > 0: |
|
340 self.scaleUp() |
|
341 else: |
|
342 self.scaleDown() |
|
343 evt.accept() |
|
344 |
|
345 elif evt.modifiers() & Qt.KeyboardModifier.ShiftModifier: |
|
346 if delta < 0: |
|
347 self.backward() |
|
348 elif delta > 0: |
|
349 self.forward() |
|
350 evt.accept() |
|
351 |
|
352 else: |
|
353 QTextBrowser.wheelEvent(self, evt) |
|
354 |
|
355 def keyPressEvent(self, evt): |
|
356 """ |
|
357 Protected method to handle key press events. |
|
358 |
|
359 @param evt reference to the key event |
|
360 @type QKeyEvent |
|
361 """ |
|
362 key = evt.key() |
|
363 isControlModifier = ( |
|
364 evt.modifiers() == Qt.KeyboardModifier.ControlModifier |
|
365 ) |
|
366 |
|
367 if ( |
|
368 key == Qt.Key.Key_ZoomIn or |
|
369 (key == Qt.Key.Key_Plus and isControlModifier) |
|
370 ): |
|
371 self.scaleUp() |
|
372 evt.accept() |
|
373 elif ( |
|
374 key == Qt.Key.Key_ZoomOut or |
|
375 (key == Qt.Key.Key_Minus and isControlModifier) |
|
376 ): |
|
377 self.scaleDown() |
|
378 evt.accept() |
|
379 elif key == Qt.Key.Key_0 and isControlModifier: |
|
380 self.resetScale() |
|
381 evt.accept() |
|
382 elif ( |
|
383 key == Qt.Key.Key_Backspace or |
|
384 (key == Qt.Key.Key_Left and isControlModifier) |
|
385 ): |
|
386 self.backward() |
|
387 evt.accept() |
|
388 elif key == Qt.Key.Key_Right and isControlModifier: |
|
389 self.forward() |
|
390 evt.accept() |
|
391 elif key == Qt.Key.Key_F and isControlModifier: |
|
392 self.__helpViewerWidget.showHideSearch(True) |
|
393 evt.accept() |
|
394 elif ( |
|
395 key == Qt.Key.Key_F3 and |
|
396 evt.modifiers() == Qt.KeyboardModifier.NoModifier |
|
397 ): |
|
398 self.__helpViewerWidget.searchNext() |
|
399 evt.accept() |
|
400 elif ( |
|
401 key == Qt.Key.Key_F3 and |
|
402 evt.modifiers() == Qt.KeyboardModifier.ShiftModifier |
|
403 ): |
|
404 self.__helpViewerWidget.searchPrev() |
|
405 evt.accept() |
|
406 else: |
|
407 super().keyPressEvent(evt) |
|
408 |
|
409 def event(self, evt): |
|
410 """ |
|
411 Public method handling events. |
|
412 |
|
413 @param evt reference to the event |
|
414 @type QEvent |
|
415 @return flag indicating the event was handled |
|
416 @rtype bool |
|
417 """ |
|
418 if evt.type() == QEvent.Type.Gesture: |
|
419 self.gestureEvent(evt) |
|
420 return True |
|
421 |
|
422 return super().event(evt) |
|
423 |
|
424 def gestureEvent(self, evt): |
|
425 """ |
|
426 Protected method handling gesture events. |
|
427 |
|
428 @param evt reference to the gesture event |
|
429 @type QGestureEvent |
|
430 """ |
|
431 pinch = evt.gesture(Qt.GestureType.PinchGesture) |
|
432 if pinch: |
|
433 if pinch.state() == Qt.GestureState.GestureStarted: |
|
434 zoom = (self.getZoom() + 6) / 10.0 |
|
435 pinch.setTotalScaleFactor(zoom) |
|
436 elif pinch.state() == Qt.GestureState.GestureUpdated: |
|
437 zoom = int(pinch.totalScaleFactor() * 10) - 6 |
|
438 if zoom <= -5: |
|
439 zoom = -5 |
|
440 pinch.setTotalScaleFactor(0.1) |
|
441 elif zoom >= 10: |
|
442 zoom = 10 |
|
443 pinch.setTotalScaleFactor(1.6) |
|
444 self.setScale(zoom) |
|
445 evt.accept() |
|
446 |
|
447 ####################################################################### |
|
448 ## Context menu related methods below |
|
449 ####################################################################### |
|
450 |
|
451 @pyqtSlot(QPoint) |
|
452 def __showContextMenu(self, pos): |
|
453 """ |
|
454 Private slot to show the context menu. |
|
455 |
|
456 @param pos position to show the context menu at |
|
457 @type QPoint |
|
458 """ |
|
459 self.__menu.clear() |
|
460 anchor = self.anchorAt(pos) |
|
461 linkUrl = self.link().resolved(QUrl(anchor)) if anchor else QUrl() |
|
462 selectedText = self.textCursor().selectedText() |
|
463 |
|
464 act = self.__menu.addAction( |
|
465 UI.PixmapCache.getIcon("back"), |
|
466 self.tr("Backward"), |
|
467 self.backward) |
|
468 act.setEnabled(self.isBackwardAvailable()) |
|
469 |
|
470 act = self.__menu.addAction( |
|
471 UI.PixmapCache.getIcon("forward"), |
|
472 self.tr("Forward"), |
|
473 self.forward) |
|
474 act.setEnabled(self.isForwardAvailable()) |
|
475 |
|
476 act = self.__menu.addAction( |
|
477 UI.PixmapCache.getIcon("reload"), |
|
478 self.tr("Reload"), |
|
479 self.reload) |
|
480 |
|
481 if not linkUrl.isEmpty() and linkUrl.scheme() != "javascript": |
|
482 self.__createLinkContextMenu(self.__menu, linkUrl) |
|
483 |
|
484 self.__menu.addSeparator() |
|
485 |
|
486 act = self.__menu.addAction( |
|
487 UI.PixmapCache.getIcon("editCopy"), |
|
488 self.tr("Copy Page URL to Clipboard")) |
|
489 act.setData(self.link()) |
|
490 act.triggered.connect( |
|
491 functools.partial(self.__copyLink, act)) |
|
492 |
|
493 act = self.__menu.addAction( |
|
494 UI.PixmapCache.getIcon("bookmark22"), |
|
495 self.tr("Bookmark Page")) |
|
496 act.setData({ |
|
497 "title": self.pageTitle(), |
|
498 "url": self.link() |
|
499 }) |
|
500 act.triggered.connect( |
|
501 functools.partial(self.__bookmarkPage, act)) |
|
502 |
|
503 self.__menu.addSeparator() |
|
504 |
|
505 act = self.__menu.addAction( |
|
506 UI.PixmapCache.getIcon("zoomIn"), |
|
507 self.tr("Zoom in"), |
|
508 self.scaleUp) |
|
509 act.setEnabled(self.isScaleUpAvailable()) |
|
510 |
|
511 act = self.__menu.addAction( |
|
512 UI.PixmapCache.getIcon("zoomOut"), |
|
513 self.tr("Zoom out"), |
|
514 self.scaleDown) |
|
515 act.setEnabled(self.isScaleDownAvailable()) |
|
516 |
|
517 self.__menu.addAction( |
|
518 UI.PixmapCache.getIcon("zoomReset"), |
|
519 self.tr("Zoom reset"), |
|
520 self.resetScale) |
|
521 |
|
522 self.__menu.addSeparator() |
|
523 |
|
524 act = self.__menu.addAction( |
|
525 UI.PixmapCache.getIcon("editCopy"), |
|
526 self.tr("Copy"), |
|
527 self.copy) |
|
528 act.setEnabled(bool(selectedText)) |
|
529 |
|
530 self.__menu.addAction( |
|
531 UI.PixmapCache.getIcon("editSelectAll"), |
|
532 self.tr("Select All"), |
|
533 self.selectAll) |
|
534 |
|
535 self.__menu.addSeparator() |
|
536 |
|
537 self.__menu.addAction( |
|
538 UI.PixmapCache.getIcon("tabClose"), |
|
539 self.tr('Close'), |
|
540 self.__closePage) |
|
541 |
|
542 act = self.__menu.addAction( |
|
543 UI.PixmapCache.getIcon("tabCloseOther"), |
|
544 self.tr("Close Others"), |
|
545 self.__closeOtherPages) |
|
546 act.setEnabled(self.__helpViewerWidget.openPagesCount() > 1) |
|
547 |
|
548 self.__menu.popup(self.mapToGlobal(pos)) |
|
549 |
|
550 def __createLinkContextMenu(self, menu, linkUrl): |
|
551 """ |
|
552 Private method to populate the context menu for URLs. |
|
553 |
|
554 @param menu reference to the menu to be populated |
|
555 @type QMenu |
|
556 @param linkUrl URL to create the menu part for |
|
557 @type QUrl |
|
558 """ |
|
559 if not menu.isEmpty(): |
|
560 menu.addSeparator() |
|
561 |
|
562 act = menu.addAction( |
|
563 UI.PixmapCache.getIcon("openNewTab"), |
|
564 self.tr("Open Link in New Page")) |
|
565 act.setData(linkUrl) |
|
566 act.triggered.connect( |
|
567 functools.partial(self.__openLinkInNewPage, act)) |
|
568 |
|
569 act = menu.addAction( |
|
570 UI.PixmapCache.getIcon("newWindow"), |
|
571 self.tr("Open Link in Background Page")) |
|
572 act.setData(linkUrl) |
|
573 act.triggered.connect( |
|
574 functools.partial(self.__openLinkInBackgroundPage, act)) |
|
575 |
|
576 menu.addSeparator() |
|
577 |
|
578 act = menu.addAction( |
|
579 UI.PixmapCache.getIcon("editCopy"), |
|
580 self.tr("Copy URL to Clipboard")) |
|
581 act.setData(linkUrl) |
|
582 act.triggered.connect( |
|
583 functools.partial(self.__copyLink, act)) |
|
584 |
|
585 def __openLinkInNewPage(self, act): |
|
586 """ |
|
587 Private method called by the context menu to open a link in a new page. |
|
588 |
|
589 @param act reference to the action that triggered |
|
590 @type QAction |
|
591 """ |
|
592 url = act.data() |
|
593 if url.isEmpty(): |
|
594 return |
|
595 |
|
596 self.__helpViewerWidget.openUrlNewPage(url) |
|
597 |
|
598 def __openLinkInBackgroundPage(self, act): |
|
599 """ |
|
600 Private method called by the context menu to open a link in a |
|
601 background page. |
|
602 |
|
603 @param act reference to the action that triggered |
|
604 @type QAction |
|
605 """ |
|
606 url = act.data() |
|
607 if url.isEmpty(): |
|
608 return |
|
609 |
|
610 self.__helpViewerWidget.openUrlNewBackgroundPage(url) |
|
611 |
|
612 def __bookmarkPage(self, act): |
|
613 """ |
|
614 Private method called by the context menu to bookmark the page. |
|
615 |
|
616 @param act reference to the action that triggered |
|
617 @type QAction |
|
618 """ |
|
619 data = act.data() |
|
620 if data: |
|
621 with contextlib.suppress(KeyError): |
|
622 url = data["url"] |
|
623 title = data["title"] |
|
624 |
|
625 self.__helpViewerWidget.bookmarkPage(title, url) |
|
626 |
|
627 def __copyLink(self, act): |
|
628 """ |
|
629 Private method called by the context menu to copy a link to the |
|
630 clipboard. |
|
631 |
|
632 @param act reference to the action that triggered |
|
633 @type QAction |
|
634 """ |
|
635 data = act.data() |
|
636 if isinstance(data, QUrl) and data.isEmpty(): |
|
637 return |
|
638 |
|
639 if isinstance(data, QUrl): |
|
640 data = data.toString() |
|
641 |
|
642 # copy the URL to both clipboard areas |
|
643 QGuiApplication.clipboard().setText(data, QClipboard.Mode.Clipboard) |
|
644 QGuiApplication.clipboard().setText(data, QClipboard.Mode.Selection) |
|
645 |
|
646 def __closePage(self): |
|
647 """ |
|
648 Private method called by the context menu to close the current page. |
|
649 """ |
|
650 self.__helpViewerWidget.closeCurrentPage() |
|
651 |
|
652 def __closeOtherPages(self): |
|
653 """ |
|
654 Private method called by the context menu to close all other pages. |
|
655 """ |
|
656 self.__helpViewerWidget.closeOtherPages() |