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