1#!/usr/bin/env python3 2 3#****************************************************************************** 4# dataeditors.py, provides classes for data editors in the data edit view 5# 6# TreeLine, an information storage program 7# Copyright (C) 2020, Douglas W. Bell 8# 9# This is free software; you can redistribute it and/or modify it under the 10# terms of the GNU General Public License, either Version 2 or any later 11# version. This program is distributed in the hope that it will be useful, 12# but WITHOUT ANY WARRANTY. See the included LICENSE file for details. 13#****************************************************************************** 14 15import xml.sax.saxutils 16import os.path 17import sys 18import re 19import math 20import enum 21import datetime 22import subprocess 23from PyQt5.QtCore import (QDate, QDateTime, QPoint, QPointF, QRect, QSize, 24 QTime, Qt, pyqtSignal) 25from PyQt5.QtGui import (QBrush, QFont, QFontMetrics, QPainter, QPainterPath, 26 QPixmap, QPen, QTextCursor, QTextDocument, QValidator) 27from PyQt5.QtWidgets import (QAbstractItemView, QAbstractSpinBox, 28 QAction, QApplication, QButtonGroup, 29 QCalendarWidget, QCheckBox, QColorDialog, 30 QComboBox, QDialog, QFileDialog, QHBoxLayout, 31 QHeaderView, QLabel, QLineEdit, QMenu, 32 QPushButton, QRadioButton, QScrollArea, 33 QSizePolicy, QSpinBox, QTextEdit, QTreeWidget, 34 QTreeWidgetItem, QVBoxLayout, QWidget) 35import dataeditview 36import fieldformat 37import urltools 38import globalref 39import optiondefaults 40 41multipleSpaceRegEx = re.compile(r' {2,}') 42 43 44class PlainTextEditor(QTextEdit): 45 """An editor widget for multi-line plain text fields. 46 """ 47 dragLinkEnabled = False 48 contentsChanged = pyqtSignal(QWidget) 49 editEnding = pyqtSignal(QWidget) 50 keyPressed = pyqtSignal(QWidget) 51 def __init__(self, parent=None): 52 """Initialize the editor class. 53 54 Arguments: 55 parent -- the parent, if given 56 """ 57 super().__init__(parent) 58 self.setAcceptRichText(False) 59 self.setPalette(QApplication.palette()) 60 self.setStyleSheet('QTextEdit {border: 2px solid palette(highlight)}') 61 self.setTabChangesFocus(True) 62 self.cursorPositionChanged.connect(self.updateActions) 63 self.selectionChanged.connect(self.updateActions) 64 self.allActions = parent.parent().allActions 65 self.modified = False 66 self.textChanged.connect(self.signalUpdate) 67 self.allActions['FormatInsertDate'].triggered.connect(self.insDate) 68 69 def setContents(self, text): 70 """Set the contents of the editor to text. 71 72 Arguments: 73 text - the new text contents for the editor 74 """ 75 self.blockSignals(True) 76 self.setPlainText(text) 77 self.blockSignals(False) 78 79 def contents(self): 80 """Return the editor text contents. 81 """ 82 return self.toPlainText() 83 84 def hasSelectedText(self): 85 """Return True if text is selected. 86 """ 87 return self.textCursor().hasSelection() 88 89 def cursorPosTuple(self): 90 """Return a tuple of the current cursor position and anchor (integers). 91 """ 92 cursor = self.textCursor() 93 return (cursor.anchor(), cursor.position()) 94 95 def setCursorPos(self, anchor, position): 96 """Set the cursor to the given anchor and position. 97 Arguments: 98 anchor -- the cursor selection start integer 99 position -- the cursor position or select end integer 100 """ 101 cursor = self.textCursor() 102 cursor.setPosition(anchor) 103 cursor.setPosition(position, QTextCursor.KeepAnchor) 104 self.setTextCursor(cursor) 105 # self.ensureCursorVisible() 106 107 def setCursorPoint(self, point): 108 """Set the cursor to the given point. 109 110 Arguments: 111 point -- the QPoint for the new cursor position 112 """ 113 self.setTextCursor(self.cursorForPosition(self.mapFromGlobal(point))) 114 115 def resetCursor(self): 116 """Set the cursor to end for tab-focus use. 117 """ 118 self.moveCursor(QTextCursor.End) 119 120 def scrollPosition(self): 121 """Return the current scrollbar position. 122 """ 123 return self.verticalScrollBar().value() 124 125 def setScrollPosition(self, value): 126 """Set the scrollbar position to value. 127 128 Arguments: 129 value -- the new scrollbar position 130 """ 131 self.verticalScrollBar().setValue(value) 132 133 def signalUpdate(self): 134 """Signal the delegate to update the model based on an editor change. 135 """ 136 self.modified = True 137 self.contentsChanged.emit(self) 138 139 def disableActions(self): 140 """Reset action availability after focus is lost. 141 """ 142 self.allActions['EditCut'].setEnabled(True) 143 self.allActions['EditCopy'].setEnabled(True) 144 mime = QApplication.clipboard().mimeData() 145 self.allActions['EditPaste'].setEnabled(len(mime.data('text/xml') or 146 mime.data('text/plain')) 147 > 0) 148 self.allActions['FormatInsertDate'].setEnabled(False) 149 150 def updateActions(self): 151 """Set availability of context menu actions. 152 """ 153 hasSelection = self.textCursor().hasSelection() 154 self.allActions['EditCut'].setEnabled(hasSelection) 155 self.allActions['EditCopy'].setEnabled(hasSelection) 156 mime = QApplication.clipboard().mimeData() 157 self.allActions['EditPaste'].setEnabled(len(mime.data('text/plain')) 158 > 0) 159 self.allActions['FormatInsertDate'].setEnabled(True) 160 161 def insDate(self): 162 """Insert the current date using the editor format. 163 """ 164 date = datetime.date.today() 165 editorFormat = fieldformat.adjOutDateFormat(globalref. 166 genOptions['EditDateFormat']) 167 dateText = date.strftime(editorFormat) 168 self.insertPlainText(dateText) 169 170 def contextMenuEvent(self, event): 171 """Override popup menu to add global actions. 172 173 Arguments: 174 event -- the menu event 175 """ 176 menu = QMenu(self) 177 menu.addAction(self.allActions['FormatSelectAll']) 178 menu.addSeparator() 179 menu.addAction(self.allActions['EditCut']) 180 menu.addAction(self.allActions['EditCopy']) 181 menu.addAction(self.allActions['EditPaste']) 182 menu.addSeparator() 183 menu.addAction(self.allActions['FormatInsertDate']) 184 menu.exec_(event.globalPos()) 185 186 def focusInEvent(self, event): 187 """Set availability and update format actions. 188 189 Arguments: 190 event -- the focus event 191 """ 192 super().focusInEvent(event) 193 self.updateActions() 194 195 def focusOutEvent(self, event): 196 """Reset format actions on focus loss if not focusing a menu. 197 198 Arguments: 199 event -- the focus event 200 """ 201 super().focusOutEvent(event) 202 if event.reason() != Qt.PopupFocusReason: 203 self.disableActions() 204 self.editEnding.emit(self) 205 206 def hideEvent(self, event): 207 """Reset format actions when the editor is hidden. 208 209 Arguments: 210 event -- the hide event 211 """ 212 self.disableActions() 213 self.editEnding.emit(self) 214 super().hideEvent(event) 215 216 def keyPressEvent(self, event): 217 """Emit a signal after every key press and handle page up/down. 218 219 Needed to adjust scroll position in unlimited height editors. 220 Arguments: 221 event -- the key press event 222 """ 223 if (event.key() in (Qt.Key_PageUp, Qt.Key_PageDown) and 224 not globalref.genOptions['EditorLimitHeight']): 225 pos = self.cursorRect().center() 226 if event.key() == Qt.Key_PageUp: 227 pos.setY(pos.y() - self.parent().height()) 228 if pos.y() < 0: 229 pos.setY(0) 230 else: 231 pos.setY(pos.y() + self.parent().height()) 232 if pos.y() > self.height(): 233 pos.setY(self.height()) 234 newCursor = self.cursorForPosition(pos) 235 if event.modifiers() == Qt.ShiftModifier: 236 cursor = self.textCursor() 237 cursor.setPosition(newCursor.position(), 238 QTextCursor.KeepAnchor) 239 self.setTextCursor(cursor) 240 else: 241 self.setTextCursor(newCursor) 242 event.accept() 243 self.keyPressed.emit(self) 244 return 245 super().keyPressEvent(event) 246 self.keyPressed.emit(self) 247 248 249class HtmlTextEditor(PlainTextEditor): 250 """An editor for HTML fields, plain text with HTML insert commands. 251 """ 252 htmlFontSizes = ('small', '', 'large', 'x-large', 'xx-large') 253 dragLinkEnabled = True 254 inLinkSelectMode = pyqtSignal(bool) 255 def __init__(self, parent=None): 256 """Initialize the editor class. 257 258 Arguments: 259 parent -- the parent, if given 260 """ 261 super().__init__(parent) 262 self.intLinkDialog = None 263 self.nodeRef = None 264 self.allActions['FormatBoldFont'].triggered.connect(self.setBoldFont) 265 self.allActions['FormatItalicFont'].triggered.connect(self. 266 setItalicFont) 267 self.allActions['FormatUnderlineFont'].triggered.connect(self. 268 setUnderlineFont) 269 self.allActions['FormatFontSize'].parent().triggered.connect(self. 270 setFontSize) 271 self.allActions['FormatFontSize'].triggered.connect(self. 272 showFontSizeMenu) 273 self.allActions['FormatFontColor'].triggered.connect(self.setFontColor) 274 self.allActions['FormatExtLink'].triggered.connect(self.setExtLink) 275 self.allActions['FormatIntLink'].triggered.connect(self.setIntLink) 276 277 def insertTagText(self, prefix, suffix): 278 """Insert given tag text and maintain the original selection. 279 280 Arguments: 281 prefix -- the opening tag 282 suffix -- the closing tag 283 """ 284 cursor = self.textCursor() 285 start = cursor.selectionStart() 286 end = cursor.selectionEnd() 287 text = '{0}{1}{2}'.format(prefix, cursor.selectedText(), suffix) 288 self.insertPlainText(text) 289 cursor.setPosition(start + len(prefix)) 290 cursor.setPosition(end + len(prefix), QTextCursor.KeepAnchor) 291 self.setTextCursor(cursor) 292 293 def setBoldFont(self, checked): 294 """Insert tags for a bold font. 295 296 Arguments: 297 checked -- current toggle state of the control 298 """ 299 try: 300 if self.hasFocus() and checked: 301 self.insertTagText('<b>', '</b>') 302 except RuntimeError: 303 pass # avoid calling a deleted C++ editor object 304 305 def setItalicFont(self, checked): 306 """Insert tags for an italic font. 307 308 Arguments: 309 checked -- current toggle state of the control 310 """ 311 try: 312 if self.hasFocus() and checked: 313 self.insertTagText('<i>', '</i>') 314 except RuntimeError: 315 pass # avoid calling a deleted C++ editor object 316 317 def setUnderlineFont(self, checked): 318 """Insert tags for an underline font. 319 320 Arguments: 321 checked -- current toggle state of the control 322 """ 323 try: 324 if self.hasFocus() and checked: 325 self.insertTagText('<u>', '</u>') 326 except RuntimeError: 327 pass # avoid calling a deleted C++ editor object 328 329 def setFontSize(self, action): 330 """Set the font size of the selection or the current setting. 331 332 Arguments: 333 action -- the sub-menu action that was picked 334 """ 335 try: 336 if self.hasFocus(): 337 actions = self.allActions['FormatFontSize'].parent().actions() 338 sizeNum = actions.index(action) 339 size = HtmlTextEditor.htmlFontSizes[sizeNum] 340 self.insertTagText('<span style="font-size:{0}">'.format(size), 341 '</span>') 342 except RuntimeError: 343 pass # avoid calling a deleted C++ editor object 344 345 def setFontColor(self): 346 """Set the font color of the selection or the current setting. 347 348 Prompt the user for a color using a dialog. 349 """ 350 try: 351 if self.hasFocus(): 352 charFormat = self.currentCharFormat() 353 oldColor = charFormat.foreground().color() 354 newColor = QColorDialog.getColor(oldColor, self) 355 if newColor.isValid(): 356 self.insertTagText('<span style="color:{0}">'. 357 format(newColor.name()), '</span>') 358 except RuntimeError: 359 pass # avoid calling a deleted C++ editor object 360 361 def setExtLink(self): 362 """Add or modify an extrnal web link at the cursor. 363 """ 364 try: 365 if self.hasFocus(): 366 dialog = ExtLinkDialog(False, self) 367 address, name = self.selectLink() 368 if address.startswith('#'): 369 address = name = '' 370 dialog.setFromComponents(address, name) 371 if dialog.exec_() == QDialog.Accepted: 372 self.insertPlainText(dialog.htmlText()) 373 except RuntimeError: 374 pass # avoid calling a deleted C++ editor object 375 376 def setIntLink(self): 377 """Show dialog to add or modify an internal node link at the cursor. 378 """ 379 try: 380 if self.hasFocus(): 381 self.intLinkDialog = EmbedIntLinkDialog(self.nodeRef. 382 treeStructureRef(), 383 self) 384 address, name = self.selectLink() 385 if address.startswith('#'): 386 address = address.lstrip('#') 387 else: 388 address = '' 389 self.intLinkDialog.setFromComponents(address, name) 390 self.intLinkDialog.finished.connect(self.insertInternalLink) 391 self.intLinkDialog.show() 392 self.inLinkSelectMode.emit(True) 393 except RuntimeError: 394 pass # avoid calling a deleted C++ editor object 395 396 def insertInternalLink(self, resultCode): 397 """Add or modify an internal node link based on dialog approval. 398 399 Arguments: 400 resultCode -- the result from the dialog (OK or cancel) 401 """ 402 if resultCode == QDialog.Accepted: 403 self.insertPlainText(self.intLinkDialog.htmlText()) 404 self.intLinkDialog = None 405 self.inLinkSelectMode.emit(False) 406 407 def setLinkFromNode(self, node): 408 """Set the current internal link from a clicked node. 409 410 Arguments: 411 node -- the node to set the unique ID from 412 """ 413 if self.intLinkDialog: 414 self.intLinkDialog.setFromNode(node) 415 416 def selectLink(self): 417 """Select the full link at the cursor, return link data. 418 419 Any links at the cursor or partially selected are fully selected. 420 Returns a tuple of the link address and name, or a tuple with empty 421 strings if none are found. 422 """ 423 cursor = self.textCursor() 424 anchor = cursor.anchor() 425 position = cursor.position() 426 for match in fieldformat.linkRegExp.finditer(self.toPlainText()): 427 start = match.start() 428 end = match.end() 429 if start < anchor < end or start < position < end: 430 address, name = match.groups() 431 cursor.setPosition(start) 432 cursor.setPosition(end, QTextCursor.KeepAnchor) 433 self.setTextCursor(cursor) 434 return (address, name) 435 return ('', cursor.selectedText()) 436 437 def addDroppedUrl(self, urlText): 438 """Add the URL link that was dropped on this editor from the view. 439 440 Arguments: 441 urlText -- the text of the link 442 """ 443 name = urltools.shortName(urlText) 444 text = '<a href="{0}">{1}</a>'.format(urlText, name) 445 self.insertPlainText(text) 446 447 def disableActions(self): 448 """Set format actions to unavailable. 449 """ 450 super().disableActions() 451 self.allActions['FormatBoldFont'].setEnabled(False) 452 self.allActions['FormatItalicFont'].setEnabled(False) 453 self.allActions['FormatUnderlineFont'].setEnabled(False) 454 self.allActions['FormatFontSize'].parent().setEnabled(False) 455 self.allActions['FormatFontColor'].setEnabled(False) 456 self.allActions['FormatExtLink'].setEnabled(False) 457 self.allActions['FormatIntLink'].setEnabled(False) 458 459 def updateActions(self): 460 """Set editor format actions to available and update toggle states. 461 """ 462 super().updateActions() 463 boldFontAct = self.allActions['FormatBoldFont'] 464 boldFontAct.setEnabled(True) 465 boldFontAct.setChecked(False) 466 italicAct = self.allActions['FormatItalicFont'] 467 italicAct.setEnabled(True) 468 italicAct.setChecked(False) 469 underlineAct = self.allActions['FormatUnderlineFont'] 470 underlineAct.setEnabled(True) 471 underlineAct.setChecked(False) 472 fontSizeSubMenu = self.allActions['FormatFontSize'].parent() 473 fontSizeSubMenu.setEnabled(True) 474 for action in fontSizeSubMenu.actions(): 475 action.setChecked(False) 476 self.allActions['FormatFontColor'].setEnabled(True) 477 self.allActions['FormatExtLink'].setEnabled(True) 478 self.allActions['FormatIntLink'].setEnabled(True) 479 480 def showFontSizeMenu(self): 481 """Show a context menu for font size at this edit box. 482 """ 483 if self.hasFocus(): 484 rect = self.rect() 485 pt = self.mapToGlobal(QPoint(rect.center().x(), 486 rect.bottom())) 487 self.allActions['FormatFontSize'].parent().popup(pt) 488 489 def contextMenuEvent(self, event): 490 """Override popup menu to add formatting and global actions. 491 492 Arguments: 493 event -- the menu event 494 """ 495 menu = QMenu(self) 496 menu.addAction(self.allActions['FormatBoldFont']) 497 menu.addAction(self.allActions['FormatItalicFont']) 498 menu.addAction(self.allActions['FormatUnderlineFont']) 499 menu.addSeparator() 500 menu.addMenu(self.allActions['FormatFontSize'].parent()) 501 menu.addAction(self.allActions['FormatFontColor']) 502 menu.addSeparator() 503 menu.addAction(self.allActions['FormatExtLink']) 504 menu.addAction(self.allActions['FormatIntLink']) 505 menu.addAction(self.allActions['FormatInsertDate']) 506 menu.addSeparator() 507 menu.addAction(self.allActions['FormatSelectAll']) 508 menu.addSeparator() 509 menu.addAction(self.allActions['EditCut']) 510 menu.addAction(self.allActions['EditCopy']) 511 menu.addAction(self.allActions['EditPaste']) 512 menu.exec_(event.globalPos()) 513 514 def hideEvent(self, event): 515 """Close the internal link dialog when the editor is hidden. 516 517 Arguments: 518 event -- the hide event 519 """ 520 if self.intLinkDialog: 521 self.intLinkDialog.close() 522 self.intLinkDialog = None 523 super().hideEvent(event) 524 525 526class RichTextEditor(HtmlTextEditor): 527 """An editor widget for multi-line wysiwyg rich text fields. 528 """ 529 fontPointSizes = [] 530 def __init__(self, parent=None): 531 """Initialize the editor class. 532 533 Arguments: 534 parent -- the parent, if given 535 """ 536 super().__init__(parent) 537 self.setAcceptRichText(True) 538 if not RichTextEditor.fontPointSizes: 539 doc = QTextDocument() 540 doc.setDefaultFont(self.font()) 541 for sizeName in HtmlTextEditor.htmlFontSizes: 542 if sizeName: 543 doc.setHtml('<span style="font-size:{0}">text</span>'. 544 format(sizeName)) 545 pointSize = (QTextCursor(doc).charFormat().font(). 546 pointSize()) 547 else: 548 pointSize = self.font().pointSize() 549 RichTextEditor.fontPointSizes.append(pointSize) 550 self.allActions['FormatClearFormat'].triggered.connect(self. 551 setClearFormat) 552 self.allActions['EditPastePlain'].triggered.connect(self.pastePlain) 553 554 def setContents(self, text): 555 """Set the contents of the editor to text. 556 557 Arguments: 558 text - the new text contents for the editor 559 """ 560 self.blockSignals(True) 561 self.setHtml(text) 562 self.blockSignals(False) 563 564 def contents(self): 565 """Return simplified HTML code for the editor contents. 566 567 Replace Unicode line feeds with HTML breaks, escape <, >, &, 568 and replace some rich formatting with HTML tags. 569 """ 570 doc = self.document() 571 block = doc.begin() 572 result = '' 573 while block.isValid(): 574 if result: 575 result += '<br />' 576 fragIter = block.begin() 577 while not fragIter.atEnd(): 578 text = xml.sax.saxutils.escape(fragIter.fragment().text()) 579 text = text.replace('\u2028', '<br />') 580 charFormat = fragIter.fragment().charFormat() 581 if charFormat.fontWeight() >= QFont.Bold: 582 text = '<b>{0}</b>'.format(text) 583 if charFormat.fontItalic(): 584 text = '<i>{0}</i>'.format(text) 585 size = charFormat.font().pointSize() 586 if size != self.font().pointSize(): 587 closeSize = min((abs(size - i), i) for i in 588 RichTextEditor.fontPointSizes)[1] 589 sizeNum = RichTextEditor.fontPointSizes.index(closeSize) 590 htmlSize = HtmlTextEditor.htmlFontSizes[sizeNum] 591 if htmlSize: 592 text = ('<span style="font-size:{0}">{1}</span>'. 593 format(htmlSize, text)) 594 if charFormat.anchorHref(): 595 text = '<a href="{0}">{1}</a>'.format(charFormat. 596 anchorHref(), text) 597 else: 598 # ignore underline and font color for links 599 if charFormat.fontUnderline(): 600 text = '<u>{0}</u>'.format(text) 601 if (charFormat.foreground().color().name() != 602 block.charFormat().foreground().color().name()): 603 text = ('<span style="color:{0}">{1}</span>'. 604 format(charFormat.foreground().color().name(), 605 text)) 606 result += text 607 fragIter += 1 608 block = block.next() 609 return result 610 611 def setBoldFont(self, checked): 612 """Set the selection or the current setting to a bold font. 613 614 Arguments: 615 checked -- current toggle state of the control 616 """ 617 try: 618 if self.hasFocus(): 619 if checked: 620 self.setFontWeight(QFont.Bold) 621 else: 622 self.setFontWeight(QFont.Normal) 623 except RuntimeError: 624 pass # avoid calling a deleted C++ editor object 625 626 def setItalicFont(self, checked): 627 """Set the selection or the current setting to an italic font. 628 629 Arguments: 630 checked -- current toggle state of the control 631 """ 632 try: 633 if self.hasFocus(): 634 self.setFontItalic(checked) 635 except RuntimeError: 636 pass # avoid calling a deleted C++ editor object 637 638 def setUnderlineFont(self, checked): 639 """Set the selection or the current setting to an underlined font. 640 641 Arguments: 642 checked -- current toggle state of the control 643 """ 644 try: 645 if self.hasFocus(): 646 self.setFontUnderline(checked) 647 except RuntimeError: 648 pass # avoid calling a deleted C++ editor object 649 650 def setFontSize(self, action): 651 """Set the font size of the selection or the current setting. 652 653 Arguments: 654 action -- the sub-menu action that was picked 655 """ 656 try: 657 if self.hasFocus(): 658 actions = self.allActions['FormatFontSize'].parent().actions() 659 sizeNum = actions.index(action) 660 pointSize = RichTextEditor.fontPointSizes[sizeNum] 661 charFormat = self.currentCharFormat() 662 charFormat.setFontPointSize(pointSize) 663 self.setCurrentCharFormat(charFormat) 664 except RuntimeError: 665 pass # avoid calling a deleted C++ editor object 666 667 def setFontColor(self): 668 """Set the font color of the selection or the current setting. 669 670 Prompt the user for a color using a dialog. 671 """ 672 try: 673 if self.hasFocus(): 674 charFormat = self.currentCharFormat() 675 oldColor = charFormat.foreground().color() 676 newColor = QColorDialog.getColor(oldColor, self) 677 if newColor.isValid(): 678 charFormat.setForeground(QBrush(newColor)) 679 self.setCurrentCharFormat(charFormat) 680 except RuntimeError: 681 pass # avoid calling a deleted C++ editor object 682 683 def setClearFormat(self): 684 """Clear the current or selected text formatting. 685 """ 686 try: 687 if self.hasFocus(): 688 self.setCurrentFont(self.font()) 689 charFormat = self.currentCharFormat() 690 charFormat.clearForeground() 691 charFormat.setAnchor(False) 692 charFormat.setAnchorHref('') 693 self.setCurrentCharFormat(charFormat) 694 except RuntimeError: 695 pass # avoid calling a deleted C++ editor object 696 697 def setExtLink(self): 698 """Add or modify an extrnal web link at the cursor. 699 """ 700 try: 701 if self.hasFocus(): 702 dialog = ExtLinkDialog(False, self) 703 address, name = self.selectLink() 704 if address.startswith('#'): 705 address = name = '' 706 dialog.setFromComponents(address, name) 707 if dialog.exec_() == QDialog.Accepted: 708 if self.textCursor().hasSelection(): 709 self.insertHtml(dialog.htmlText()) 710 else: 711 self.insertHtml(dialog.htmlText() + ' ') 712 except RuntimeError: 713 pass # avoid calling a deleted C++ editor object 714 715 def insertInternalLink(self, resultCode): 716 """Add or modify an internal node link based on dialog approval. 717 718 Arguments: 719 resultCode -- the result from the dialog (OK or cancel) 720 """ 721 if resultCode == QDialog.Accepted: 722 if self.textCursor().hasSelection(): 723 self.insertHtml(self.intLinkDialog.htmlText()) 724 else: 725 self.insertHtml(self.intLinkDialog.htmlText() + ' ') 726 self.intLinkDialog = None 727 self.inLinkSelectMode.emit(False) 728 729 def selectLink(self): 730 """Select the full link at the cursor, return link data. 731 732 Any links at the cursor or partially selected are fully selected. 733 Returns a tuple of the link address and name, or a tuple with empty 734 strings if none are found. 735 """ 736 cursor = self.textCursor() 737 if not cursor.hasSelection() and not cursor.charFormat().anchorHref(): 738 return ('', '') 739 selectText = cursor.selection().toPlainText() 740 anchorCursor = QTextCursor(self.document()) 741 anchorCursor.setPosition(cursor.anchor()) 742 cursor.clearSelection() 743 if cursor < anchorCursor: 744 anchorCursor, cursor = cursor, anchorCursor 745 position = cursor.position() 746 address = name = '' 747 if anchorCursor.charFormat().anchorHref(): 748 fragIter = anchorCursor.block().begin() 749 while not (fragIter.fragment().contains(anchorCursor.position()) or 750 fragIter.fragment().contains(anchorCursor.position() - 1)): 751 fragIter += 1 752 fragment = fragIter.fragment() 753 anchorCursor.setPosition(fragment.position()) 754 address = fragment.charFormat().anchorHref() 755 name = fragment.text() 756 if cursor.charFormat().anchorHref(): 757 fragIter = cursor.block().begin() 758 while not (fragIter.fragment().contains(cursor.position()) or 759 fragIter.fragment().contains(cursor.position() - 1)): 760 fragIter += 1 761 fragment = fragIter.fragment() 762 position = fragment.position() + fragment.length() 763 address = fragment.charFormat().anchorHref() 764 name = fragment.text() 765 if not name: 766 name = selectText.split('\n')[0] 767 cursor.setPosition(anchorCursor.position()) 768 cursor.setPosition(position, QTextCursor.KeepAnchor) 769 self.setTextCursor(cursor) 770 return (address, name) 771 772 def addDroppedUrl(self, urlText): 773 """Add the URL link that was dropped on this editor from the view. 774 775 Arguments: 776 urlText -- the text of the link 777 """ 778 name = urltools.shortName(urlText) 779 text = '<a href="{0}">{1}</a>'.format(urlText, name) 780 if not self.textCursor().hasSelection(): 781 text += ' ' 782 self.insertHtml(text) 783 784 def pastePlain(self): 785 """Paste non-formatted text from the clipboard. 786 """ 787 text = QApplication.clipboard().mimeData().text() 788 if text and self.hasFocus(): 789 self.insertPlainText(text) 790 791 def disableActions(self): 792 """Set format actions to unavailable. 793 """ 794 super().disableActions() 795 self.allActions['FormatClearFormat'].setEnabled(False) 796 self.allActions['EditPastePlain'].setEnabled(False) 797 798 def updateActions(self): 799 """Set editor format actions to available and update toggle states. 800 """ 801 super().updateActions() 802 self.allActions['FormatBoldFont'].setChecked(self.fontWeight() == 803 QFont.Bold) 804 self.allActions['FormatItalicFont'].setChecked(self.fontItalic()) 805 self.allActions['FormatUnderlineFont'].setChecked(self.fontUnderline()) 806 fontSizeSubMenu = self.allActions['FormatFontSize'].parent() 807 pointSize = int(self.fontPointSize()) 808 try: 809 sizeNum = RichTextEditor.fontPointSizes.index(pointSize) 810 except ValueError: 811 sizeNum = 1 # default size 812 fontSizeSubMenu.actions()[sizeNum].setChecked(True) 813 self.allActions['FormatClearFormat'].setEnabled(True) 814 mime = QApplication.clipboard().mimeData() 815 self.allActions['EditPastePlain'].setEnabled(len(mime. 816 data('text/plain')) 817 > 0) 818 819 def contextMenuEvent(self, event): 820 """Override popup menu to add formatting and global actions. 821 822 Arguments: 823 event -- the menu event 824 """ 825 menu = QMenu(self) 826 menu.addAction(self.allActions['FormatBoldFont']) 827 menu.addAction(self.allActions['FormatItalicFont']) 828 menu.addAction(self.allActions['FormatUnderlineFont']) 829 menu.addSeparator() 830 menu.addMenu(self.allActions['FormatFontSize'].parent()) 831 menu.addAction(self.allActions['FormatFontColor']) 832 menu.addSeparator() 833 menu.addAction(self.allActions['FormatExtLink']) 834 menu.addAction(self.allActions['FormatIntLink']) 835 menu.addAction(self.allActions['FormatInsertDate']) 836 menu.addSeparator() 837 menu.addAction(self.allActions['FormatSelectAll']) 838 menu.addAction(self.allActions['FormatClearFormat']) 839 menu.addSeparator() 840 menu.addAction(self.allActions['EditCut']) 841 menu.addAction(self.allActions['EditCopy']) 842 menu.addAction(self.allActions['EditPaste']) 843 menu.addAction(self.allActions['EditPastePlain']) 844 menu.exec_(event.globalPos()) 845 846 def mousePressEvent(self, event): 847 """Handle ctrl + click to follow links. 848 849 Arguments: 850 event -- the mouse event 851 """ 852 if (event.button() == Qt.LeftButton and 853 event.modifiers() == Qt.ControlModifier): 854 cursor = self.cursorForPosition(event.pos()) 855 address = cursor.charFormat().anchorHref() 856 if address: 857 if address.startswith('#'): 858 editView = self.parent().parent() 859 selectModel = editView.treeView.selectionModel() 860 selectModel.selectNodeById(address[1:]) 861 else: # check for relative path 862 if urltools.isRelative(address): 863 defaultPath = str(globalref.mainControl. 864 defaultPathObj(True)) 865 address = urltools.toAbsolute(address, defaultPath) 866 openExtUrl(address) 867 event.accept() 868 else: 869 super().mousePressEvent(event) 870 871 872class OneLineTextEditor(RichTextEditor): 873 """An editor widget for single-line wysiwyg rich text fields. 874 """ 875 def __init__(self, parent=None): 876 """Initialize the editor class. 877 878 Arguments: 879 parent -- the parent, if given 880 """ 881 super().__init__(parent) 882 883 def insertFromMimeData(self, mimeSource): 884 """Override to verify that only a single line is pasted or dropped. 885 886 Arguments: 887 mimeSource -- the mime source to be inserted 888 """ 889 super().insertFromMimeData(mimeSource) 890 text = self.contents() 891 if '<br />' in text: 892 text = text.split('<br />', 1)[0] 893 self.blockSignals(True) 894 self.setHtml(text) 895 self.blockSignals(False) 896 self.moveCursor(QTextCursor.End) 897 898 def keyPressEvent(self, event): 899 """Customize handling of return and control keys. 900 901 Arguments: 902 event -- the key press event 903 """ 904 if event.key() not in (Qt.Key_Enter, Qt.Key_Return): 905 super().keyPressEvent(event) 906 907 908class LineEditor(QLineEdit): 909 """An editor widget for unformatted single-line fields. 910 911 Used both stand-alone and as part of the combo box editor. 912 """ 913 dragLinkEnabled = False 914 contentsChanged = pyqtSignal(QWidget) 915 editEnding = pyqtSignal(QWidget) 916 contextMenuPrep = pyqtSignal() 917 def __init__(self, parent=None, subControl=False): 918 """Initialize the editor class. 919 920 Includes a colored triangle error flag for non-matching formats. 921 Arguments: 922 parent -- the parent, if given 923 subcontrol -- true if used inside a combo box (no border or signal) 924 """ 925 super().__init__(parent) 926 self.setPalette(QApplication.palette()) 927 self.cursorPositionChanged.connect(self.updateActions) 928 self.selectionChanged.connect(self.updateActions) 929 try: 930 self.allActions = parent.parent().allActions 931 except AttributeError: # view is a level up if embedded in a combo 932 self.allActions = parent.parent().parent().allActions 933 self.modified = False 934 self.errorFlag = False 935 self.savedCursorPos = None 936 self.extraMenuActions = [] 937 if not subControl: 938 self.setStyleSheet('QLineEdit {border: 2px solid ' 939 'palette(highlight)}') 940 self.textEdited.connect(self.signalUpdate) 941 942 def setContents(self, text): 943 """Set the contents of the editor to text. 944 945 Arguments: 946 text - the new text contents for the editor 947 """ 948 self.setText(text) 949 950 def contents(self): 951 """Return the editor text contents. 952 """ 953 return self.text() 954 955 def signalUpdate(self): 956 """Signal the delegate to update the model based on an editor change. 957 """ 958 self.modified = True 959 self.errorFlag = False 960 self.contentsChanged.emit(self) 961 962 def setErrorFlag(self): 963 """Set the error flag to True and repaint the widget. 964 """ 965 self.errorFlag = True 966 self.update() 967 968 def cursorPosTuple(self): 969 """Return a tuple of the current cursor position and anchor (integers). 970 """ 971 pos = start = self.cursorPosition() 972 if self.hasSelectedText(): 973 start = self.selectionStart() 974 return (start, pos) 975 976 def setCursorPos(self, anchor, position): 977 """Set the cursor to the given anchor and position. 978 Arguments: 979 anchor -- the cursor selection start integer 980 position -- the cursor position or select end integer 981 """ 982 if anchor == position: 983 self.deselect() 984 self.setCursorPosition(position) 985 else: 986 self.setSelection(anchor, position - anchor) 987 988 def setCursorPoint(self, point): 989 """Set the cursor to the given point. 990 991 Arguments: 992 point -- the QPoint for the new cursor position 993 """ 994 self.savedCursorPos = self.cursorPositionAt(self.mapFromGlobal(point)) 995 self.setCursorPosition(self.savedCursorPos) 996 997 def resetCursor(self): 998 """Set the cursor to select all for tab-focus use. 999 """ 1000 self.selectAll() 1001 1002 def scrollPosition(self): 1003 """Return the current scrollbar position. 1004 """ 1005 return 0 1006 1007 def setScrollPosition(self, value): 1008 """Set the scrollbar position to value. 1009 1010 No operation with single line editor. 1011 Arguments: 1012 value -- the new scrollbar position 1013 """ 1014 pass 1015 1016 def paintEvent(self, event): 1017 """Add painting of the error flag to the paint event. 1018 1019 Arguments: 1020 event -- the paint event 1021 """ 1022 super().paintEvent(event) 1023 if self.errorFlag: 1024 painter = QPainter(self) 1025 path = QPainterPath(QPointF(0, 0)) 1026 path.lineTo(0, 10) 1027 path.lineTo(10, 0) 1028 path.closeSubpath() 1029 painter.fillPath(path, QApplication.palette().highlight()) 1030 1031 def disableActions(self): 1032 """Reset action availability after focus is lost. 1033 """ 1034 self.allActions['EditCut'].setEnabled(True) 1035 self.allActions['EditCopy'].setEnabled(True) 1036 mime = QApplication.clipboard().mimeData() 1037 self.allActions['EditPaste'].setEnabled(len(mime.data('text/xml') or 1038 mime.data('text/plain')) 1039 > 0) 1040 1041 def updateActions(self): 1042 """Set availability of context menu actions. 1043 """ 1044 hasSelection = self.hasSelectedText() 1045 self.allActions['EditCut'].setEnabled(hasSelection) 1046 self.allActions['EditCopy'].setEnabled(hasSelection) 1047 mime = QApplication.clipboard().mimeData() 1048 self.allActions['EditPaste'].setEnabled(len(mime.data('text/plain')) 1049 > 0) 1050 1051 def contextMenuEvent(self, event): 1052 """Override popup menu to add formatting actions. 1053 1054 Arguments: 1055 event -- the menu event 1056 """ 1057 self.contextMenuPrep.emit() 1058 menu = QMenu(self) 1059 if self.extraMenuActions: 1060 for action in self.extraMenuActions: 1061 menu.addAction(action) 1062 menu.addSeparator() 1063 menu.addAction(self.allActions['FormatSelectAll']) 1064 menu.addSeparator() 1065 menu.addAction(self.allActions['EditCut']) 1066 menu.addAction(self.allActions['EditCopy']) 1067 menu.addAction(self.allActions['EditPaste']) 1068 menu.exec_(event.globalPos()) 1069 1070 def focusInEvent(self, event): 1071 """Restore a saved cursor position for new editors. 1072 1073 Arguments: 1074 event -- the focus event 1075 """ 1076 super().focusInEvent(event) 1077 if (event.reason() == Qt.OtherFocusReason and 1078 self.savedCursorPos != None): 1079 self.setCursorPosition(self.savedCursorPos) 1080 self.savedCursorPos = None 1081 self.updateActions() 1082 1083 def focusOutEvent(self, event): 1084 """Reset format actions on focus loss if not focusing a menu. 1085 1086 Arguments: 1087 event -- the focus event 1088 """ 1089 super().focusOutEvent(event) 1090 if event.reason() != Qt.PopupFocusReason: 1091 self.disableActions() 1092 self.editEnding.emit(self) 1093 1094 def hideEvent(self, event): 1095 """Reset format actions when the editor is hidden. 1096 1097 Arguments: 1098 event -- the hide event 1099 """ 1100 self.disableActions() 1101 self.editEnding.emit(self) 1102 super().hideEvent(event) 1103 1104 1105class ReadOnlyEditor(LineEditor): 1106 """An editor widget that doesn't allow any edits. 1107 """ 1108 def __init__(self, parent=None): 1109 """Initialize the editor class. 1110 1111 Includes a colored triangle error flag for non-matching formats. 1112 Arguments: 1113 parent -- the parent, if given 1114 """ 1115 super().__init__(parent, True) 1116 self.setReadOnly(True) 1117 self.setStyleSheet('QLineEdit {border: 2px solid palette(highlight); ' 1118 'background-color: palette(button)}') 1119 1120 1121class ComboEditor(QComboBox): 1122 """A general combo box editor widget. 1123 1124 Uses the LineEditor class to paint the error flag. 1125 """ 1126 dragLinkEnabled = False 1127 contentsChanged = pyqtSignal(QWidget) 1128 editEnding = pyqtSignal(QWidget) 1129 def __init__(self, parent=None): 1130 """Initialize the editor class. 1131 1132 The self.fieldRef and self.nodeRef must be set after creation. 1133 Arguments: 1134 parent -- the parent, if given 1135 """ 1136 super().__init__(parent) 1137 self.setPalette(QApplication.palette()) 1138 self.setStyleSheet('QComboBox {border: 2px solid palette(highlight)}') 1139 self.setEditable(True) 1140 self.setLineEdit(LineEditor(self, True)) 1141 self.listView = QTreeWidget() 1142 self.listView.setColumnCount(2) 1143 self.listView.header().hide() 1144 self.listView.setRootIsDecorated(False) 1145 self.listView.setSelectionBehavior(QAbstractItemView.SelectRows) 1146 self.listView.header().setSectionResizeMode(QHeaderView. 1147 ResizeToContents) 1148 self.setModel(self.listView.model()) 1149 self.setView(self.listView) 1150 self.setModelColumn(0) 1151 self.modified = False 1152 self.fieldRef = None 1153 self.nodeRef = None 1154 self.editTextChanged.connect(self.signalUpdate) 1155 self.lineEdit().editEnding.connect(self.signalEditEnd) 1156 1157 def setContents(self, text): 1158 """Set the contents of the editor to text. 1159 1160 Arguments: 1161 text - the new text contents for the editor 1162 """ 1163 self.blockSignals(True) 1164 self.setEditText(text) 1165 self.blockSignals(False) 1166 1167 def contents(self): 1168 """Return the editor text contents. 1169 """ 1170 return self.currentText() 1171 1172 def showPopup(self): 1173 """Load combo box with choices before showing it. 1174 """ 1175 self.listView.setColumnCount(self.fieldRef.numChoiceColumns) 1176 text = self.currentText() 1177 if self.fieldRef.autoAddChoices: 1178 self.fieldRef.clearChoices() 1179 for node in self.nodeRef.treeStructureRef().nodeDict.values(): 1180 if node.formatRef == self.nodeRef.formatRef: 1181 self.fieldRef.addChoice(node.data.get(self.fieldRef.name, 1182 '')) 1183 self.blockSignals(True) 1184 self.clear() 1185 if self.fieldRef.numChoiceColumns == 1: 1186 choices = self.fieldRef.comboChoices() 1187 self.addItems(choices) 1188 else: 1189 annotatedChoices = self.fieldRef.annotatedComboChoices(text) 1190 for choice, annot in annotatedChoices: 1191 QTreeWidgetItem(self.listView, [choice, annot]) 1192 choices = [choice for (choice, annot) in annotatedChoices] 1193 try: 1194 self.setCurrentIndex(choices.index(text)) 1195 except ValueError: 1196 self.setEditText(text) 1197 self.blockSignals(False) 1198 super().showPopup() 1199 1200 def signalUpdate(self): 1201 """Signal the delegate to update the model based on an editor change. 1202 """ 1203 self.modified = True 1204 self.lineEdit().errorFlag = False 1205 self.contentsChanged.emit(self) 1206 1207 def setErrorFlag(self): 1208 """Set the error flag to True and repaint the widget. 1209 """ 1210 self.lineEdit().errorFlag = True 1211 self.update() 1212 1213 def hasSelectedText(self): 1214 """Return True if text is selected. 1215 """ 1216 return self.lineEdit().hasSelectedText() 1217 1218 def selectAll(self): 1219 """Select all text in the line editor. 1220 """ 1221 self.lineEdit().selectAll() 1222 1223 def cursorPosTuple(self): 1224 """Return a tuple of the current cursor position and anchor (integers). 1225 """ 1226 return self.lineEdit().cursorPosTuple() 1227 1228 def setCursorPos(self, anchor, position): 1229 """Set the cursor to the given anchor and position. 1230 Arguments: 1231 anchor -- the cursor selection start integer 1232 position -- the cursor position or select end integer 1233 """ 1234 self.lineEdit().setCursorPos(anchor, position) 1235 1236 def setCursorPoint(self, point): 1237 """Set the cursor to the given point. 1238 1239 Arguments: 1240 point -- the QPoint for the new cursor position 1241 """ 1242 self.lineEdit().setCursorPoint(point) 1243 1244 def resetCursor(self): 1245 """Set the cursor to select all for tab-focus use. 1246 """ 1247 self.lineEdit().selectAll() 1248 1249 def scrollPosition(self): 1250 """Return the current scrollbar position. 1251 """ 1252 return 0 1253 1254 def setScrollPosition(self, value): 1255 """Set the scrollbar position to value. 1256 1257 No operation with single line editor. 1258 Arguments: 1259 value -- the new scrollbar position 1260 """ 1261 pass 1262 1263 def copy(self): 1264 """Copy text selected in the line editor. 1265 """ 1266 self.lineEdit().copy() 1267 1268 def cut(self): 1269 """Cut text selected in the line editor. 1270 """ 1271 self.lineEdit().cut() 1272 1273 def paste(self): 1274 """Paste from the clipboard into the line editor. 1275 """ 1276 self.lineEdit().paste() 1277 1278 def signalEditEnd(self): 1279 """Emit editEnding signal based on line edit signal. 1280 """ 1281 self.editEnding.emit(self) 1282 1283 1284class CombinationEditor(ComboEditor): 1285 """An editor widget for combination and auto-combination fields. 1286 1287 Uses a combo box with a list of checkboxes in place of the list popup. 1288 """ 1289 def __init__(self, parent=None): 1290 """Initialize the editor class. 1291 1292 Arguments: 1293 parent -- the parent, if given 1294 """ 1295 super().__init__(parent) 1296 self.checkBoxDialog = None 1297 1298 def showPopup(self): 1299 """Override to show a popup entry widget in place of a list view. 1300 """ 1301 if self.fieldRef.autoAddChoices: 1302 self.fieldRef.clearChoices() 1303 for node in self.nodeRef.treeStructureRef().nodeDict.values(): 1304 if node.formatRef == self.nodeRef.formatRef: 1305 self.fieldRef.addChoice(node.data.get(self.fieldRef.name, 1306 '')) 1307 selectList = self.fieldRef.comboActiveChoices(self.currentText()) 1308 self.checkBoxDialog = CombinationDialog(self.fieldRef.comboChoices(), 1309 selectList, self) 1310 self.checkBoxDialog.setMinimumWidth(self.width()) 1311 self.checkBoxDialog.buttonChanged.connect(self.updateText) 1312 self.checkBoxDialog.show() 1313 pos = self.mapToGlobal(self.rect().bottomRight()) 1314 pos.setX(pos.x() - self.checkBoxDialog.width() + 1) 1315 screenBottom = (QApplication.desktop().screenGeometry(self). 1316 bottom()) 1317 if pos.y() + self.checkBoxDialog.height() > screenBottom: 1318 pos.setY(pos.y() - self.rect().height() - 1319 self.checkBoxDialog.height()) 1320 self.checkBoxDialog.move(pos) 1321 1322 def hidePopup(self): 1323 """Override to hide the popup entry widget. 1324 """ 1325 if self.checkBoxDialog: 1326 self.checkBoxDialog.hide() 1327 super().hidePopup() 1328 1329 def updateText(self): 1330 """Update the text based on a changed signal. 1331 """ 1332 if self.checkBoxDialog: 1333 self.setEditText(self.fieldRef.joinText(self.checkBoxDialog. 1334 selectList())) 1335 1336 1337class CombinationDialog(QDialog): 1338 """A popup dialog box for combination and auto-combination fields. 1339 """ 1340 buttonChanged = pyqtSignal() 1341 def __init__(self, choiceList, selectList, parent=None): 1342 """Initialize the combination dialog. 1343 1344 Arguments: 1345 choiceList -- a list of text choices 1346 selectList -- a lit of choices to preselect 1347 parent -- the parent, if given 1348 """ 1349 super().__init__(parent) 1350 self.setWindowFlags(Qt.Popup) 1351 topLayout = QVBoxLayout(self) 1352 topLayout.setContentsMargins(0, 0, 0, 0) 1353 scrollArea = QScrollArea() 1354 scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 1355 topLayout.addWidget(scrollArea) 1356 innerWidget = QWidget() 1357 innerLayout = QVBoxLayout(innerWidget) 1358 selected = set(selectList) 1359 self.buttonGroup = QButtonGroup(self) 1360 self.buttonGroup.setExclusive(False) 1361 self.buttonGroup.buttonClicked.connect(self.buttonChanged) 1362 for text in choiceList: 1363 button = QCheckBox(text, innerWidget) 1364 if text in selected: 1365 button.setChecked(True) 1366 self.buttonGroup.addButton(button) 1367 innerLayout.addWidget(button) 1368 scrollArea.setWidget(innerWidget) 1369 buttons = self.buttonGroup.buttons() 1370 if buttons: 1371 buttons[0].setFocus() 1372 1373 def selectList(self): 1374 """Return a list of currently checked text. 1375 """ 1376 result = [] 1377 for button in self.buttonGroup.buttons(): 1378 if button.isChecked(): 1379 result.append(button.text()) 1380 return result 1381 1382 1383class DateEditor(ComboEditor): 1384 """An editor widget for date fields. 1385 1386 Uses a combo box with a calendar widget in place of the list popup. 1387 """ 1388 def __init__(self, parent=None): 1389 """Initialize the editor class. 1390 1391 Arguments: 1392 parent -- the parent, if given 1393 """ 1394 super().__init__(parent) 1395 self.calendar = None 1396 nowAction = QAction(_('Today\'s &Date'), self) 1397 nowAction.triggered.connect(self.setNow) 1398 self.lineEdit().extraMenuActions = [nowAction] 1399 1400 def editorDate(self): 1401 """Return the date (as a QDate) set in the line editor. 1402 1403 If none or invalid, return an invalid date. 1404 """ 1405 try: 1406 dateStr = self.fieldRef.storedText(self.currentText()) 1407 except ValueError: 1408 return QDate() 1409 return QDate.fromString(dateStr, Qt.ISODate) 1410 1411 def showPopup(self): 1412 """Override to show a calendar widget in place of a list view. 1413 """ 1414 if not self.calendar: 1415 self.calendar = QCalendarWidget(self) 1416 self.calendar.setWindowFlags(Qt.Popup) 1417 weekStart = optiondefaults.daysOfWeek.index(globalref. 1418 genOptions['WeekStart']) 1419 self.calendar.setFirstDayOfWeek(weekStart + 1) 1420 self.calendar.setVerticalHeaderFormat(QCalendarWidget. 1421 NoVerticalHeader) 1422 self.calendar.clicked.connect(self.setDate) 1423 date = self.editorDate() 1424 if date.isValid(): 1425 self.calendar.setSelectedDate(date) 1426 self.calendar.show() 1427 pos = self.mapToGlobal(self.rect().bottomRight()) 1428 pos.setX(pos.x() - self.calendar.width()) 1429 screenBottom = (QApplication.desktop().screenGeometry(self). 1430 bottom()) 1431 if pos.y() + self.calendar.height() > screenBottom: 1432 pos.setY(pos.y() - self.rect().height() - self.calendar.height()) 1433 self.calendar.move(pos) 1434 1435 def hidePopup(self): 1436 """Override to hide the calendar widget. 1437 """ 1438 if self.calendar: 1439 self.calendar.hide() 1440 super().hidePopup() 1441 1442 def setDate(self, date): 1443 """Set the date based on a signal from the calendar popup. 1444 1445 Arguments: 1446 date -- the QDate to be set 1447 """ 1448 dateStr = date.toString(Qt.ISODate) 1449 self.setEditText(self.fieldRef.formatEditorText(dateStr)) 1450 self.calendar.hide() 1451 1452 def setNow(self): 1453 """Set to today's date. 1454 """ 1455 dateStr = QDate.currentDate().toString(Qt.ISODate) 1456 self.setEditText(self.fieldRef.formatEditorText(dateStr)) 1457 1458 1459class TimeEditor(ComboEditor): 1460 """An editor widget for time fields. 1461 1462 Adds a clock popup dialog and a "now" right-click menu action. 1463 """ 1464 def __init__(self, parent=None): 1465 """Initialize the editor class. 1466 1467 Arguments: 1468 parent -- the parent, if given 1469 """ 1470 super().__init__(parent) 1471 self.dialog = None 1472 nowAction = QAction(_('Set to &Now'), self) 1473 nowAction.triggered.connect(self.setNow) 1474 self.lineEdit().extraMenuActions = [nowAction] 1475 1476 def showPopup(self): 1477 """Override to show a popup entry widget in place of a list view. 1478 """ 1479 if not self.dialog: 1480 self.dialog = TimeDialog(self) 1481 self.dialog.contentsChanged.connect(self.setTime) 1482 self.dialog.show() 1483 pos = self.mapToGlobal(self.rect().bottomRight()) 1484 pos.setX(pos.x() - self.dialog.width() + 1) 1485 screenBottom = QApplication.desktop().screenGeometry(self).bottom() 1486 if pos.y() + self.dialog.height() > screenBottom: 1487 pos.setY(pos.y() - self.rect().height() - self.dialog.height()) 1488 self.dialog.move(pos) 1489 try: 1490 storedText = self.fieldRef.storedText(self.currentText()) 1491 except ValueError: 1492 storedText = '' 1493 if storedText: 1494 self.dialog.setTimeFromText(storedText) 1495 1496 def hidePopup(self): 1497 """Override to hide the popup entry widget. 1498 """ 1499 if self.dialog: 1500 self.dialog.hide() 1501 super().hidePopup() 1502 1503 def setTime(self): 1504 """Set the time fom the dialog. 1505 """ 1506 if self.dialog: 1507 timeStr = self.dialog.timeObject().isoformat() + '.000' 1508 self.setEditText(self.fieldRef.formatEditorText(timeStr)) 1509 1510 def setNow(self): 1511 """Set to the current time. 1512 """ 1513 timeStr = QTime.currentTime().toString('hh:mm:ss.zzz') 1514 self.setEditText(self.fieldRef.formatEditorText(timeStr)) 1515 1516 1517TimeElem = enum.Enum('TimeElem', 'hour minute second') 1518 1519class TimeDialog(QDialog): 1520 """A popup clock dialog for time editing. 1521 """ 1522 contentsChanged = pyqtSignal() 1523 def __init__(self, addCalendar=False, parent=None): 1524 """Initialize the dialog widgets. 1525 1526 Arguments: 1527 parent -- the dialog's parent widget 1528 """ 1529 super().__init__(parent) 1530 self.focusElem = None 1531 self.setWindowFlags(Qt.Popup) 1532 horizLayout = QHBoxLayout(self) 1533 if addCalendar: 1534 self.calendar = QCalendarWidget() 1535 horizLayout.addWidget(self.calendar) 1536 weekStart = optiondefaults.daysOfWeek.index(globalref. 1537 genOptions['WeekStart']) 1538 self.calendar.setFirstDayOfWeek(weekStart + 1) 1539 self.calendar.setVerticalHeaderFormat(QCalendarWidget. 1540 NoVerticalHeader) 1541 self.calendar.clicked.connect(self.contentsChanged) 1542 vertLayout = QVBoxLayout() 1543 horizLayout.addLayout(vertLayout) 1544 upperLayout = QHBoxLayout() 1545 vertLayout.addLayout(upperLayout) 1546 upperLayout.addStretch(0) 1547 self.hourBox = TimeSpinBox(TimeElem.hour, 1, 12, False) 1548 upperLayout.addWidget(self.hourBox) 1549 self.hourBox.valueChanged.connect(self.signalUpdate) 1550 self.hourBox.focusChanged.connect(self.handleFocusChange) 1551 colon = QLabel('<b>:</b>') 1552 upperLayout.addWidget(colon) 1553 self.minuteBox = TimeSpinBox(TimeElem.minute, 0, 59, True) 1554 upperLayout.addWidget(self.minuteBox) 1555 self.minuteBox.valueChanged.connect(self.signalUpdate) 1556 self.minuteBox.focusChanged.connect(self.handleFocusChange) 1557 colon = QLabel('<b>:</b>') 1558 upperLayout.addWidget(colon) 1559 self.secondBox = TimeSpinBox(TimeElem.second, 0, 59, True) 1560 upperLayout.addWidget(self.secondBox) 1561 self.secondBox.valueChanged.connect(self.signalUpdate) 1562 self.secondBox.focusChanged.connect(self.handleFocusChange) 1563 self.amPmBox = AmPmSpinBox() 1564 upperLayout.addSpacing(4) 1565 upperLayout.addWidget(self.amPmBox) 1566 self.amPmBox.valueChanged.connect(self.signalUpdate) 1567 upperLayout.addStretch(0) 1568 lowerLayout = QHBoxLayout() 1569 vertLayout.addLayout(lowerLayout) 1570 self.clock = ClockWidget() 1571 lowerLayout.addWidget(self.clock, Qt.AlignCenter) 1572 self.clock.numClicked.connect(self.setFromClock) 1573 if addCalendar: 1574 self.calendar.setFocus() 1575 self.updateClock() 1576 else: 1577 self.hourBox.setFocus() 1578 self.hourBox.selectAll() 1579 1580 def setTimeFromText(self, text): 1581 """Set the time dialog from a string. 1582 1583 Arguments: 1584 text -- the time in ISO format 1585 """ 1586 time = (datetime.datetime. 1587 strptime(text, fieldformat.TimeField.isoFormat).time()) 1588 hour = time.hour if time.hour <= 12 else time.hour - 12 1589 self.blockSignals(True) 1590 self.hourBox.setValue(hour) 1591 self.minuteBox.setValue(time.minute) 1592 self.secondBox.setValue(time.second) 1593 amPm = 'AM' if time.hour < 12 else 'PM' 1594 self.amPmBox.setValue(amPm) 1595 self.blockSignals(False) 1596 self.updateClock() 1597 1598 def setDateFromText(self, text): 1599 """Set the date dialog from a string. 1600 1601 Arguments: 1602 text -- the date in ISO format 1603 """ 1604 date = QDate.fromString(text, Qt.ISODate) 1605 if date.isValid(): 1606 self.calendar.setSelectedDate(date) 1607 1608 def timeObject(self): 1609 """Return a datetime time object for the current dialog setting. 1610 """ 1611 hour = self.hourBox.value() 1612 if self.amPmBox.value == 'PM': 1613 if hour < 12: 1614 hour += 12 1615 elif hour == 12: 1616 hour = 0 1617 return datetime.time(hour, self.minuteBox.value(), 1618 self.secondBox.value()) 1619 1620 def updateClock(self): 1621 """Update the clock based on the current time and focused widget. 1622 """ 1623 hands = [self.focusElem] if self.focusElem else [TimeElem.hour, 1624 TimeElem.minute, 1625 TimeElem.second] 1626 self.clock.setDisplay(self.timeObject(), hands) 1627 1628 def handleFocusChange(self, elemType, isFocused): 1629 """Update clock based on focus changes. 1630 1631 Arguments: 1632 elemType -- the TimeElem of the focus change 1633 isFocused -- True if focus is gained 1634 """ 1635 if isFocused: 1636 if elemType != self.focusElem: 1637 self.focusElem = elemType 1638 self.updateClock() 1639 elif elemType == self.focusElem: 1640 self.focusElem = None 1641 self.updateClock() 1642 1643 def setFromClock(self, num): 1644 """Set the active spin box value from a clock click. 1645 1646 Arguments: 1647 num -- the number clicked 1648 """ 1649 spinBox = getattr(self, self.focusElem.name + 'Box') 1650 spinBox.setValue(num) 1651 spinBox.selectAll() 1652 1653 def signalUpdate(self): 1654 """Signal a time change and update the clock. 1655 """ 1656 self.updateClock() 1657 self.contentsChanged.emit() 1658 1659 1660class TimeSpinBox(QSpinBox): 1661 """A spin box for time values with optional leading zero. 1662 """ 1663 focusChanged = pyqtSignal(TimeElem, bool) 1664 def __init__(self, elemType, minValue, maxValue, leadZero=True, 1665 parent=None): 1666 """Initialize the spin box. 1667 1668 Arguments: 1669 elemType -- the TimeElem of this box 1670 minValue -- the minimum allowed value 1671 maxValue -- the maximum allowed value 1672 leadZero -- true if a leading zero used with single digit values 1673 parent -- the box's parent widget 1674 """ 1675 self.elemType = elemType 1676 self.leadZero = leadZero 1677 super().__init__(parent) 1678 self.setMinimum(minValue) 1679 self.setMaximum(maxValue) 1680 self.setWrapping(True) 1681 self.setAlignment(Qt.AlignRight) 1682 1683 def textFromValue(self, value): 1684 """Override to optionally add leading zero. 1685 1686 Arguments: 1687 value -- the int value to convert 1688 """ 1689 if self.leadZero and value < 10: 1690 return '0' + repr(value) 1691 return repr(value) 1692 1693 def focusInEvent(self, event): 1694 """Emit a signal when focused. 1695 1696 Arguments: 1697 event -- the focus event 1698 """ 1699 super().focusInEvent(event) 1700 self.focusChanged.emit(self.elemType, True) 1701 1702 def focusOutEvent(self, event): 1703 """Emit a signal if focus is lost. 1704 1705 Arguments: 1706 event -- the focus event 1707 """ 1708 super().focusOutEvent(event) 1709 self.focusChanged.emit(self.elemType, False) 1710 1711 1712class AmPmSpinBox(QAbstractSpinBox): 1713 """A spin box for AM/PM values. 1714 """ 1715 valueChanged = pyqtSignal() 1716 def __init__(self, parent=None): 1717 """Initialize the spin box. 1718 1719 Arguments: 1720 parent -- the box's parent widget 1721 """ 1722 super().__init__(parent) 1723 self.value = 'AM' 1724 self.setDisplay() 1725 1726 def stepBy(self, steps): 1727 """Step the spin box to the alternate value. 1728 1729 Arguments: 1730 steps -- number of steps (ignored) 1731 """ 1732 self.value = 'PM' if self.value == 'AM' else 'AM' 1733 self.setDisplay() 1734 1735 def stepEnabled(self): 1736 """Return enabled to show that stepping is always enabled. 1737 """ 1738 return (QAbstractSpinBox.StepUpEnabled | 1739 QAbstractSpinBox.StepDownEnabled) 1740 1741 def setValue(self, value): 1742 """Set to text value if valid. 1743 1744 Arguments: 1745 value -- the text value to set 1746 """ 1747 if value in ('AM', 'PM'): 1748 self.value = value 1749 self.setDisplay() 1750 1751 def setDisplay(self): 1752 """Update display to match value. 1753 """ 1754 self.lineEdit().setText(self.value) 1755 self.valueChanged.emit() 1756 if self.hasFocus(): 1757 self.selectAll() 1758 1759 def validate(self, inputStr, pos): 1760 """Check if the input string is acceptable. 1761 1762 Arguments: 1763 inputStr -- the string to check 1764 pos -- the pos in the string (ignored) 1765 """ 1766 inputStr = inputStr.upper() 1767 if inputStr in ('AM', 'A'): 1768 self.value = 'AM' 1769 self.setDisplay() 1770 return (QValidator.Acceptable, 'AM', 2) 1771 if inputStr in ('PM', 'P'): 1772 self.value = 'PM' 1773 self.setDisplay() 1774 return (QValidator.Acceptable, 'PM', 2) 1775 return (QValidator.Invalid, 'xx', 2) 1776 1777 def sizeHint(self): 1778 """Set prefered size. 1779 """ 1780 return super().sizeHint() + QSize(QFontMetrics(self.font()). 1781 width('AM'), 0) 1782 1783 def focusInEvent(self, event): 1784 """Set select all when focused. 1785 1786 Arguments: 1787 event -- the focus event 1788 """ 1789 super().focusInEvent(event) 1790 self.selectAll() 1791 1792 def focusOutEvent(self, event): 1793 """Remove selection if focus is lost. 1794 1795 Arguments: 1796 event -- the focus event 1797 """ 1798 super().focusOutEvent(event) 1799 self.lineEdit().deselect() 1800 1801 1802class ClockWidget(QWidget): 1803 """A widget showing a clickable clock face. 1804 """ 1805 radius = 80 1806 margin = 10 1807 handLengths = {TimeElem.hour: int(radius * 0.5), 1808 TimeElem.minute: int(radius * 0.9), 1809 TimeElem.second: int(radius * 0.95)} 1810 handWidths = {TimeElem.hour: 7, TimeElem.minute: 5, TimeElem.second: 2} 1811 divisor = {TimeElem.hour: 120, TimeElem.minute: 10, TimeElem.second: 1 / 6} 1812 numClicked = pyqtSignal(int) 1813 def __init__(self, parent=None): 1814 """Initialize the clock. 1815 1816 Arguments: 1817 parent -- the dialog's parent widget 1818 """ 1819 super().__init__(parent) 1820 self.time = datetime.time() 1821 self.hands = [] 1822 self.highlightAngle = None 1823 self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) 1824 self.setMouseTracking(True) 1825 1826 def setDisplay(self, time, hands): 1827 """Set the clock display. 1828 1829 Arguments: 1830 time -- a datetime time value 1831 hands -- a list of TimeElem clock hands to show 1832 """ 1833 self.time = time 1834 self.hands = hands 1835 self.highlightAngle = None 1836 self.update() 1837 1838 def paintEvent(self, event): 1839 """Paint the clock face. 1840 1841 Arguments: 1842 event -- the paint event 1843 """ 1844 painter = QPainter(self) 1845 painter.save() 1846 painter.setBrush(QApplication.palette().base()) 1847 painter.setPen(Qt.NoPen) 1848 painter.drawEllipse(self.rect()) 1849 painter.translate(ClockWidget.radius + ClockWidget.margin, 1850 ClockWidget.radius + ClockWidget.margin) 1851 for timeElem in self.hands: 1852 painter.save() 1853 painter.setBrush(QApplication.palette().windowText()) 1854 painter.setPen(Qt.NoPen) 1855 seconds = (self.time.hour * 3600 + self.time.minute * 60 + 1856 self.time.second) 1857 angle = seconds / ClockWidget.divisor[timeElem] % 360 1858 if len(self.hands) == 1: 1859 painter.setBrush(QApplication.palette().highlight()) 1860 if self.hands[0] == TimeElem.hour: 1861 angle = int(angle // 30 * 30) # truncate to whole hour 1862 else: 1863 angle = int(angle // 6 * 6) # truncate to whole min/sec 1864 painter.rotate(angle) 1865 points = (QPoint(0, -ClockWidget.handLengths[timeElem]), 1866 QPoint(ClockWidget.handWidths[timeElem], 8), 1867 QPoint(-ClockWidget.handWidths[timeElem], 8)) 1868 painter.drawConvexPolygon(*points) 1869 painter.restore() 1870 rect = QRect(0, 0, 20, 20) 1871 if len(self.hands) != 1 or self.hands[0] == TimeElem.hour: 1872 labels = [repr(num) for num in range(1, 13)] 1873 else: 1874 labels = ['{0:0>2}'.format(num) for num in range(5, 56, 5)] 1875 labels.append('00') 1876 for ang in range(30, 361, 30): 1877 rect.moveCenter(self.pointOnRadius(ang)) 1878 painter.setPen(QPen()) 1879 if len(self.hands) == 1 and (ang == angle or 1880 ang == self.highlightAngle): 1881 painter.setPen(QPen(QApplication.palette().highlight(), 1)) 1882 painter.drawText(rect, Qt.AlignCenter, labels.pop(0)) 1883 painter.restore() 1884 super().paintEvent(event) 1885 1886 def sizeHint(self): 1887 """Set prefered size. 1888 """ 1889 width = (ClockWidget.radius + ClockWidget.margin) * 2 1890 return QSize(width, width) 1891 1892 def pointOnRadius(self, angle): 1893 """Return a QPoint on the radius at the given angle. 1894 1895 Arguments: 1896 angle -- the angle in dgrees from vertical (clockwise) 1897 """ 1898 angle = math.radians(angle) 1899 x = round(ClockWidget.radius * math.sin(angle)) 1900 y = 0 - round(ClockWidget.radius * math.cos(angle)) 1901 return QPoint(x, y) 1902 1903 def pointToPosition(self, point): 1904 """Return a position (1 to 12) based on a screen point. 1905 1906 Return None if not on a position. 1907 Arguments: 1908 point -- a QPoint screen position 1909 """ 1910 x = point.x() - ClockWidget.radius - ClockWidget.margin 1911 y = point.y() - ClockWidget.radius - ClockWidget.margin 1912 radius = math.sqrt(x**2 + y**2) 1913 if (ClockWidget.radius - 2 * ClockWidget.margin <= radius <= 1914 ClockWidget.radius + 2 * ClockWidget.margin): 1915 angle = math.degrees(math.atan2(-x, y)) + 180 1916 if angle % 30 <= 10 or angle % 30 >= 20: 1917 pos = round(angle / 30) 1918 if pos == 0: 1919 pos = 12 1920 return pos 1921 return None 1922 1923 def mousePressEvent(self, event): 1924 """Signal user clicks on clock numbers if in single hand mode. 1925 1926 Arguments: 1927 event -- the mouse press event 1928 """ 1929 if len(self.hands) == 1 and event.button() == Qt.LeftButton: 1930 pos = self.pointToPosition(event.pos()) 1931 if pos: 1932 if self.hands[0] != TimeElem.hour: 1933 if pos == 12: 1934 pos = 0 1935 pos *= 5 1936 self.numClicked.emit(pos) 1937 super().mousePressEvent(event) 1938 1939 def mouseMoveEvent(self, event): 1940 """Highlight clickable numbers if in single hand mode. 1941 1942 Arguments: 1943 event -- the mouse move event 1944 """ 1945 if len(self.hands) == 1: 1946 pos = self.pointToPosition(event.pos()) 1947 if pos: 1948 self.highlightAngle = pos * 30 1949 self.update() 1950 elif self.highlightAngle != None: 1951 self.highlightAngle = None 1952 self.update() 1953 super().mouseMoveEvent(event) 1954 1955 1956class DateTimeEditor(ComboEditor): 1957 """An editor widget for DateTimeFields. 1958 1959 Uses a combo box with a clandar widget in place of the list popup. 1960 """ 1961 def __init__(self, parent=None): 1962 """Initialize the editor class. 1963 1964 Arguments: 1965 parent -- the parent, if given 1966 """ 1967 super().__init__(parent) 1968 self.dialog = None 1969 nowAction = QAction(_('Set to &Now'), self) 1970 nowAction.triggered.connect(self.setNow) 1971 self.lineEdit().extraMenuActions = [nowAction] 1972 1973 def showPopup(self): 1974 """Override to show a popup entry widget in place of a list view. 1975 """ 1976 if not self.dialog: 1977 self.dialog = TimeDialog(True, self) 1978 self.dialog.contentsChanged.connect(self.setDateTime) 1979 self.dialog.show() 1980 pos = self.mapToGlobal(self.rect().bottomRight()) 1981 pos.setX(pos.x() - self.dialog.width() + 1) 1982 screenBottom = QApplication.desktop().screenGeometry(self).bottom() 1983 if pos.y() + self.dialog.height() > screenBottom: 1984 pos.setY(pos.y() - self.rect().height() - self.dialog.height()) 1985 self.dialog.move(pos) 1986 try: 1987 storedText = self.fieldRef.storedText(self.currentText()) 1988 except ValueError: 1989 storedText = '' 1990 if storedText: 1991 dateText, timeText = storedText.split(' ', 1) 1992 self.dialog.setDateFromText(dateText) 1993 self.dialog.setTimeFromText(timeText) 1994 1995 def hidePopup(self): 1996 """Override to hide the popup entry widget. 1997 """ 1998 if self.dialog: 1999 self.dialog.hide() 2000 super().hidePopup() 2001 2002 def setDateTime(self): 2003 """Set the date and time based on a signal from the dialog calendar. 2004 """ 2005 if self.dialog: 2006 dateStr = self.dialog.calendar.selectedDate().toString(Qt.ISODate) 2007 timeStr = self.dialog.timeObject().isoformat() + '.000' 2008 self.setEditText(self.fieldRef.formatEditorText(dateStr + ' ' + 2009 timeStr)) 2010 2011 def setNow(self): 2012 """Set to the current date and time. 2013 """ 2014 dateTime = QDateTime.currentDateTime() 2015 dateTimeStr = dateTime.toString('yyyy-MM-dd HH:mm:ss.zzz') 2016 self.setEditText(self.fieldRef.formatEditorText(dateTimeStr)) 2017 2018 2019class ExtLinkEditor(ComboEditor): 2020 """An editor widget for external link fields. 2021 2022 Uses a combo box with a link entry box in place of the list popup. 2023 """ 2024 dragLinkEnabled = True 2025 def __init__(self, parent=None): 2026 """Initialize the editor class. 2027 2028 Arguments: 2029 parent -- the parent, if given 2030 """ 2031 super().__init__(parent) 2032 self.setAcceptDrops(True) 2033 self.dialog = None 2034 openAction = QAction(_('&Open Link'), self) 2035 openAction.triggered.connect(self.openLink) 2036 folderAction = QAction(_('Open &Folder'), self) 2037 folderAction.triggered.connect(self.openFolder) 2038 self.lineEdit().extraMenuActions = [openAction, folderAction] 2039 self.lineEdit().contextMenuPrep.connect(self.updateActions) 2040 2041 def showPopup(self): 2042 """Override to show a popup entry widget in place of a list view. 2043 """ 2044 if not self.dialog: 2045 self.dialog = ExtLinkDialog(True, self) 2046 self.dialog.contentsChanged.connect(self.setLink) 2047 self.dialog.show() 2048 pos = self.mapToGlobal(self.rect().bottomRight()) 2049 pos.setX(pos.x() - self.dialog.width() + 1) 2050 screenBottom = QApplication.desktop().screenGeometry(self).bottom() 2051 if pos.y() + self.dialog.height() > screenBottom: 2052 pos.setY(pos.y() - self.rect().height() - self.dialog.height()) 2053 self.dialog.move(pos) 2054 self.dialog.setFromEditor(self.currentText()) 2055 2056 def hidePopup(self): 2057 """Override to hide the popup entry widget. 2058 """ 2059 if self.dialog: 2060 self.dialog.hide() 2061 super().hidePopup() 2062 2063 def setLink(self): 2064 """Set the current link from the popup dialog. 2065 """ 2066 self.setEditText(self.dialog.editorText()) 2067 2068 def openLink(self): 2069 """Open the link in a web browser. 2070 """ 2071 text = self.currentText() 2072 if text: 2073 nameMatch = fieldformat.linkSeparateNameRegExp.match(text) 2074 if nameMatch: 2075 address = nameMatch.group(1).strip() 2076 else: 2077 address = text.strip() 2078 if address: 2079 if urltools.isRelative(address): 2080 defaultPath = globalref.mainControl.defaultPathObj(True) 2081 address = urltools.toAbsolute(address, str(defaultPath)) 2082 openExtUrl(address) 2083 2084 def openFolder(self): 2085 """Open the link in a file manager/explorer. 2086 """ 2087 text = self.currentText() 2088 if text: 2089 nameMatch = fieldformat.linkSeparateNameRegExp.match(text) 2090 if nameMatch: 2091 address = nameMatch.group(1).strip() 2092 else: 2093 address = text.strip() 2094 if address and urltools.extractScheme(address) in ('', 'file'): 2095 if urltools.isRelative(address): 2096 defaultPath = globalref.mainControl.defaultPathObj(True) 2097 address = urltools.toAbsolute(address, str(defaultPath)) 2098 address = os.path.dirname(address) 2099 openExtUrl(address) 2100 2101 def updateActions(self): 2102 """Set availability of custom context menu actions. 2103 """ 2104 address = self.currentText() 2105 if address: 2106 nameMatch = fieldformat.linkSeparateNameRegExp.match(address) 2107 if nameMatch: 2108 address = nameMatch.group(1).strip() 2109 else: 2110 address = address.strip() 2111 openAction, folderAction = self.lineEdit().extraMenuActions 2112 openAction.setEnabled(len(address) > 0) 2113 folderAction.setEnabled(len(address) > 0 and 2114 urltools.extractScheme(address) in ('', 'file')) 2115 2116 def addDroppedUrl(self, urlText): 2117 """Add the URL link that was dropped on this editor from the view. 2118 2119 Arguments: 2120 urlText -- the text of the link 2121 """ 2122 self.setEditText(urlText) 2123 2124 def dragEnterEvent(self, event): 2125 """Accept drags of files to this widget. 2126 2127 Arguments: 2128 event -- the drag event object 2129 """ 2130 if event.mimeData().hasUrls(): 2131 event.accept() 2132 2133 def dropEvent(self, event): 2134 """Open a file dropped onto this widget. 2135 2136 Arguments: 2137 event -- the drop event object 2138 """ 2139 fileList = event.mimeData().urls() 2140 if fileList: 2141 self.setEditText(fileList[0].toLocalFile()) 2142 2143 2144_extLinkSchemes = ('http://', 'https://', 'mailto:', 'file://') 2145_extLinkSchemeDict = {proto.split(':', 1)[0]: proto for proto in 2146 _extLinkSchemes} 2147 2148class ExtLinkDialog(QDialog): 2149 """A popup or normal dialog box for external link editing. 2150 """ 2151 contentsChanged = pyqtSignal() 2152 def __init__(self, popupDialog=False, parent=None): 2153 """Initialize the dialog widgets. 2154 2155 Arguments: 2156 popupDialog -- add OK and cancel buttons if False 2157 parent -- the dialog's parent widget 2158 """ 2159 super().__init__(parent) 2160 self.setWindowFlags(Qt.Dialog | Qt.WindowTitleHint | 2161 Qt.WindowCloseButtonHint) 2162 self.setWindowTitle(_('External Link')) 2163 vertLayout = QVBoxLayout(self) 2164 vertLayout.setSpacing(1) 2165 schemeLabel = QLabel(_('Scheme')) 2166 vertLayout.addWidget(schemeLabel) 2167 schemeLayout = QHBoxLayout() 2168 vertLayout.addLayout(schemeLayout) 2169 schemeLayout.setSpacing(8) 2170 self.schemeButtons = QButtonGroup(self) 2171 self.schemeButtonDict = {} 2172 for scheme in _extLinkSchemes: 2173 scheme = scheme.split(':', 1)[0] 2174 button = QRadioButton(scheme) 2175 self.schemeButtons.addButton(button) 2176 self.schemeButtonDict[scheme] = button 2177 schemeLayout.addWidget(button) 2178 self.schemeButtonDict['http'].setChecked(True) 2179 self.schemeButtons.buttonClicked.connect(self.updateScheme) 2180 vertLayout.addSpacing(8) 2181 2182 self.browseButton = QPushButton(_('&Browse for File')) 2183 self.browseButton.setAutoDefault(False) 2184 self.browseButton.clicked.connect(self.fileBrowse) 2185 vertLayout.addWidget(self.browseButton) 2186 vertLayout.addSpacing(8) 2187 2188 self.pathTypeLabel = QLabel(_('File Path Type')) 2189 vertLayout.addWidget(self.pathTypeLabel) 2190 pathTypeLayout = QHBoxLayout() 2191 vertLayout.addLayout(pathTypeLayout) 2192 pathTypeLayout.setSpacing(8) 2193 pathTypeButtons = QButtonGroup(self) 2194 self.absoluteButton = QRadioButton(_('Absolute')) 2195 pathTypeButtons.addButton(self.absoluteButton) 2196 pathTypeLayout.addWidget(self.absoluteButton) 2197 self.relativeButton = QRadioButton(_('Relative')) 2198 pathTypeButtons.addButton(self.relativeButton) 2199 pathTypeLayout.addWidget(self.relativeButton) 2200 self.absoluteButton.setChecked(True) 2201 pathTypeButtons.buttonClicked.connect(self.updatePathType) 2202 vertLayout.addSpacing(8) 2203 2204 addressLabel = QLabel(_('Address')) 2205 vertLayout.addWidget(addressLabel) 2206 self.addressEdit = QLineEdit() 2207 self.addressEdit.textEdited.connect(self.checkAddress) 2208 vertLayout.addWidget(self.addressEdit) 2209 vertLayout.addSpacing(8) 2210 2211 nameLabel = QLabel(_('Display Name')) 2212 vertLayout.addWidget(nameLabel) 2213 self.nameEdit = QLineEdit() 2214 self.nameEdit.textEdited.connect(self.contentsChanged) 2215 vertLayout.addWidget(self.nameEdit) 2216 if popupDialog: 2217 self.setWindowFlags(Qt.Popup) 2218 else: 2219 vertLayout.addSpacing(8) 2220 ctrlLayout = QHBoxLayout() 2221 vertLayout.addLayout(ctrlLayout) 2222 ctrlLayout.addStretch(0) 2223 okButton = QPushButton(_('&OK')) 2224 ctrlLayout.addWidget(okButton) 2225 okButton.clicked.connect(self.accept) 2226 cancelButton = QPushButton(_('&Cancel')) 2227 ctrlLayout.addWidget(cancelButton) 2228 cancelButton.clicked.connect(self.reject) 2229 self.addressEdit.setFocus() 2230 2231 def setFromEditor(self, editorText): 2232 """Set the dialog contents from a string in editor format. 2233 2234 Arguments: 2235 editorText -- string in "link [name]" format 2236 """ 2237 name = address = '' 2238 editorText = editorText.strip() 2239 if editorText: 2240 nameMatch = fieldformat.linkSeparateNameRegExp.match(editorText) 2241 if nameMatch: 2242 address, name = nameMatch.groups() 2243 address = address.strip() 2244 else: 2245 address = editorText 2246 name = urltools.shortName(address) 2247 self.setFromComponents(address, name) 2248 2249 def setFromComponents(self, address, name): 2250 """Set the dialog contents from separate address and name. 2251 2252 Arguments: 2253 address -- the link address, including the scheme prefix 2254 name -- the displayed name for the link 2255 """ 2256 scheme = urltools.extractScheme(address) 2257 if scheme not in _extLinkSchemeDict: 2258 if not scheme: 2259 address = urltools.replaceScheme('file', address) 2260 scheme = 'file' 2261 self.schemeButtonDict[scheme].setChecked(True) 2262 if address and urltools.isRelative(address): 2263 self.relativeButton.setChecked(True) 2264 else: 2265 self.absoluteButton.setChecked(True) 2266 self.addressEdit.setText(address) 2267 self.nameEdit.setText(name) 2268 self.updateFileControls() 2269 2270 def editorText(self): 2271 """Return the dialog contents in data editor format ("link [name]"). 2272 """ 2273 address = self.currentAddress() 2274 if not address: 2275 return '' 2276 name = self.nameEdit.text().strip() 2277 if not name: 2278 name = urltools.shortName(address) 2279 return '{0} [{1}]'.format(address, name) 2280 2281 def htmlText(self): 2282 """Return the dialog contents in HTML link format. 2283 """ 2284 address = self.currentAddress() 2285 if not address: 2286 return '' 2287 name = self.nameEdit.text().strip() 2288 if not name: 2289 name = urltools.shortName(address) 2290 return '<a href="{0}">{1}</a>'.format(address, name) 2291 2292 def currentAddress(self): 2293 """Return current address with the selected scheme prefix. 2294 """ 2295 scheme = self.schemeButtons.checkedButton().text() 2296 address = self.addressEdit.text().strip() 2297 return urltools.replaceScheme(scheme, address) 2298 2299 def checkAddress(self): 2300 """Update controls based on a change to the address field. 2301 2302 Makes minimum changes to scheme and absolute controls, 2303 since the address may be incomplete. 2304 """ 2305 address = self.addressEdit.text().strip() 2306 scheme = urltools.extractScheme(address) 2307 if scheme in _extLinkSchemeDict: 2308 self.schemeButtonDict[scheme].setChecked(True) 2309 if scheme != 'file': 2310 self.absoluteButton.setChecked(True) 2311 self.updateFileControls() 2312 self.contentsChanged.emit() 2313 2314 def updateScheme(self): 2315 """Update scheme in the address due to scheme button change. 2316 """ 2317 scheme = self.schemeButtons.checkedButton().text() 2318 address = self.addressEdit.text().strip() 2319 address = urltools.replaceScheme(scheme, address) 2320 self.addressEdit.setText(address) 2321 if urltools.isRelative(address): 2322 self.relativeButton.setChecked(True) 2323 else: 2324 self.absoluteButton.setChecked(True) 2325 self.updateFileControls() 2326 self.contentsChanged.emit() 2327 2328 def updatePathType(self): 2329 """Update file path based on a change in the absolute/relative control. 2330 """ 2331 absolute = self.absoluteButton.isChecked() 2332 defaultPath = globalref.mainControl.defaultPathObj(True) 2333 address = self.addressEdit.text().strip() 2334 if absolute: 2335 address = urltools.toAbsolute(address, str(defaultPath)) 2336 else: 2337 address = urltools.toRelative(address, str(defaultPath)) 2338 self.addressEdit.setText(address) 2339 self.contentsChanged.emit() 2340 2341 def updateFileControls(self): 2342 """Set file browse & type controls available based on current scheme. 2343 """ 2344 enable = self.schemeButtons.checkedButton().text() == 'file' 2345 self.browseButton.setEnabled(enable) 2346 self.pathTypeLabel.setEnabled(enable) 2347 self.absoluteButton.setEnabled(enable) 2348 self.relativeButton.setEnabled(enable) 2349 2350 def fileBrowse(self): 2351 """Show dialog to browse for a file to be linked. 2352 2353 Adjust based on absolute or relative path settings. 2354 """ 2355 refPath = str(globalref.mainControl.defaultPathObj(True)) 2356 defaultPath = refPath 2357 oldAddress = self.addressEdit.text().strip() 2358 oldScheme = urltools.extractScheme(oldAddress) 2359 if oldAddress and not oldScheme or oldScheme == 'file': 2360 if urltools.isRelative(oldAddress): 2361 oldAddress = urltools.toAbsolute(oldAddress, refPath) 2362 oldAddress = urltools.extractAddress(oldAddress) 2363 if os.access(oldAddress, os.F_OK): 2364 defaultPath = oldAddress 2365 address, selFltr = QFileDialog.getOpenFileName(self, 2366 _('TreeLine - External Link File'), 2367 defaultPath, 2368 globalref.fileFilters['all']) 2369 if address: 2370 if self.relativeButton.isChecked(): 2371 address = urltools.toRelative(address, refPath) 2372 self.setFromComponents(address, urltools.shortName(address)) 2373 self.show() 2374 self.contentsChanged.emit() 2375 2376 2377class IntLinkEditor(ComboEditor): 2378 """An editor widget for internal link fields. 2379 2380 Uses a combo box with a link select dialog in place of the list popup. 2381 """ 2382 inLinkSelectMode = pyqtSignal(bool) 2383 def __init__(self, parent=None): 2384 """Initialize the editor class. 2385 2386 Arguments: 2387 parent -- the parent, if given 2388 """ 2389 super().__init__(parent) 2390 self.address = '' 2391 self.intLinkDialog = None 2392 self.setLineEdit(PartialLineEditor(self)) 2393 openAction = QAction(_('&Go to Target'), self) 2394 openAction.triggered.connect(self.openLink) 2395 clearAction = QAction(_('Clear &Link'), self) 2396 clearAction.triggered.connect(self.clearLink) 2397 self.lineEdit().extraMenuActions = [openAction, clearAction] 2398 2399 def setContents(self, text): 2400 """Set the contents of the editor to text. 2401 2402 Arguments: 2403 text - the new text contents for the editor 2404 """ 2405 super().setContents(text) 2406 if not text: 2407 self.lineEdit().staticLength = 0 2408 self.address = '' 2409 return 2410 try: 2411 self.address, name = self.fieldRef.addressAndName(self.nodeRef. 2412 data.get(self.fieldRef.name, '')) 2413 except ValueError: 2414 self.address = '' 2415 self.address = self.address.lstrip('#') 2416 nameMatch = fieldformat.linkSeparateNameRegExp.match(text) 2417 if nameMatch: 2418 link = nameMatch.group(1) 2419 self.lineEdit().staticLength = len(link) + 1 2420 else: 2421 self.lineEdit().staticLength = 0 2422 2423 def contents(self): 2424 """Return the editor contents in "address [name]" format. 2425 """ 2426 if not self.address: 2427 return self.currentText() 2428 nameMatch = fieldformat.linkSeparateNameRegExp.match(self. 2429 currentText()) 2430 if nameMatch: 2431 name = nameMatch.group(2) 2432 else: 2433 name = '' 2434 return '{0} [{1}]'.format(self.address, name.strip()) 2435 2436 def clearLink(self): 2437 """Clear the contents of the editor. 2438 """ 2439 self.setContents('') 2440 self.signalUpdate() 2441 2442 def showPopup(self): 2443 """Override to show a popup entry widget in place of a list view. 2444 """ 2445 if not self.intLinkDialog: 2446 self.intLinkDialog = IntLinkDialog(True, self) 2447 self.intLinkDialog.show() 2448 pos = self.mapToGlobal(self.rect().bottomRight()) 2449 pos.setX(pos.x() - self.intLinkDialog.width() + 1) 2450 screenBottom = (QApplication.desktop().screenGeometry(self). 2451 bottom()) 2452 if pos.y() + self.intLinkDialog.height() > screenBottom: 2453 pos.setY(pos.y() - self.rect().height() - 2454 self.intLinkDialog.height()) 2455 self.intLinkDialog.move(pos) 2456 self.inLinkSelectMode.emit(True) 2457 2458 def hidePopup(self): 2459 """Override to hide the popup entry widget. 2460 """ 2461 if self.intLinkDialog: 2462 self.intLinkDialog.hide() 2463 self.inLinkSelectMode.emit(False) 2464 super().hidePopup() 2465 2466 def setLinkFromNode(self, node): 2467 """Set the current link from a clicked node. 2468 2469 Arguments: 2470 node -- the node to set the unique ID from 2471 """ 2472 self.hidePopup() 2473 self.address = node.uId 2474 linkTitle = node.title() 2475 nameMatch = fieldformat.linkSeparateNameRegExp.match(self. 2476 currentText()) 2477 if nameMatch: 2478 name = nameMatch.group(2) 2479 else: 2480 name = linkTitle 2481 self.setEditText('LinkTo: {0} [{1}]'.format(linkTitle, name)) 2482 self.lineEdit().staticLength = len(linkTitle) + 9 2483 2484 def openLink(self): 2485 """Open the link in a web browser. 2486 """ 2487 if self.address: 2488 editView = self.parent().parent() 2489 editView.treeView.selectionModel().selectNodeById(self.address) 2490 2491 def setCursorPoint(self, point): 2492 """Set the cursor to the given point. 2493 2494 Arguments: 2495 point -- the QPoint for the new cursor position 2496 """ 2497 self.lineEdit().setCursorPoint(point) 2498 self.lineEdit().fixSelection() 2499 2500 2501class PartialLineEditor(LineEditor): 2502 """A line used in internal link combo editors. 2503 2504 Only allows the name portion to be selected or editd. 2505 """ 2506 def __init__(self, parent=None): 2507 """Initialize the editor class. 2508 2509 Arguments: 2510 parent -- the parent, if given 2511 """ 2512 super().__init__(parent, True) 2513 self.staticLength = 0 2514 2515 def fixSelection(self): 2516 """Fix the selection and cursor to not include static portion of text. 2517 """ 2518 cursorPos = self.cursorPosition() 2519 if -1 < self.selectionStart() < self.staticLength: 2520 endPos = self.selectionStart() + len(self.selectedText()) 2521 if endPos > self.staticLength: 2522 if cursorPos >= self.staticLength: 2523 self.setSelection(self.staticLength, 2524 endPos - self.staticLength) 2525 else: 2526 # reverse select to get cursor at selection start 2527 self.setSelection(endPos, self.staticLength - endPos) 2528 return 2529 self.deselect() 2530 if cursorPos < self.staticLength: 2531 self.setCursorPosition(self.staticLength) 2532 2533 def selectAll(self): 2534 """Select all editable text. 2535 """ 2536 self.setSelection(self.staticLength, len(self.text())) 2537 2538 def mouseReleaseEvent(self, event): 2539 """Fix selection if required after mouse release. 2540 2541 Arguments: 2542 event -- the mouse release event 2543 """ 2544 super().mouseReleaseEvent(event) 2545 self.fixSelection() 2546 2547 def keyPressEvent(self, event): 2548 """Avoid edits or cursor movements to the static portion of the text. 2549 2550 Arguments: 2551 event -- the mouse release event 2552 """ 2553 if (event.key() == Qt.Key_Backspace and 2554 (self.cursorPosition() <= self.staticLength and 2555 not self.hasSelectedText())): 2556 return 2557 if event.key() in (Qt.Key_Left, Qt.Key_Home): 2558 super().keyPressEvent(event) 2559 self.fixSelection() 2560 return 2561 super().keyPressEvent(event) 2562 2563 2564class IntLinkDialog(QDialog): 2565 """A popup dialog box for internal link editing. 2566 """ 2567 contentsChanged = pyqtSignal() 2568 def __init__(self, popupDialog=False, parent=None): 2569 """Initialize the dialog widgets. 2570 2571 Arguments: 2572 popupDialog -- add OK and cancel buttons if False 2573 parent -- the dialog's parent widget 2574 """ 2575 super().__init__(parent) 2576 self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) 2577 layout = QVBoxLayout(self) 2578 label = QLabel(_('(Click link target in tree)')) 2579 layout.addWidget(label) 2580 2581 2582class EmbedIntLinkDialog(QDialog): 2583 """A popup or normal dialog box for internal link editing. 2584 """ 2585 contentsChanged = pyqtSignal() 2586 targetClickDialogRef = None 2587 def __init__(self, structRef, parent=None): 2588 """Initialize the dialog widgets. 2589 2590 Arguments: 2591 structRef -- a ref to the tree structure 2592 parent -- the dialog's parent widget 2593 """ 2594 super().__init__(parent) 2595 self.structRef = structRef 2596 self.address = '' 2597 self.setWindowFlags(Qt.Dialog | Qt.WindowTitleHint | 2598 Qt.WindowCloseButtonHint) 2599 self.setWindowTitle(_('Internal Link')) 2600 vertLayout = QVBoxLayout(self) 2601 vertLayout.setSpacing(1) 2602 self.linkLabel = QLabel() 2603 vertLayout.addWidget(self.linkLabel) 2604 infoLabel = QLabel(_('(Click link target in tree)')) 2605 vertLayout.addWidget(infoLabel) 2606 vertLayout.addSpacing(8) 2607 nameLabel = QLabel(_('Display Name')) 2608 vertLayout.addWidget(nameLabel) 2609 self.nameEdit = QLineEdit() 2610 self.nameEdit.textEdited.connect(self.contentsChanged) 2611 vertLayout.addWidget(self.nameEdit) 2612 vertLayout.addSpacing(8) 2613 ctrlLayout = QHBoxLayout() 2614 vertLayout.addLayout(ctrlLayout) 2615 ctrlLayout.addStretch(0) 2616 self.okButton = QPushButton(_('&OK')) 2617 ctrlLayout.addWidget(self.okButton) 2618 self.okButton.setDefault(True) 2619 self.okButton.clicked.connect(self.accept) 2620 cancelButton = QPushButton(_('&Cancel')) 2621 ctrlLayout.addWidget(cancelButton) 2622 cancelButton.clicked.connect(self.reject) 2623 2624 def updateLinkText(self): 2625 """Update the link label using the current address. 2626 """ 2627 title = '' 2628 name = self.nameEdit.text().strip() 2629 if self.address: 2630 targetNode = self.structRef.nodeDict.get(self.address, None) 2631 if targetNode: 2632 title = targetNode.title() 2633 if not name: 2634 self.nameEdit.setText(title) 2635 self.linkLabel.setText('LinkTo: {0}'.format(title)) 2636 self.okButton.setEnabled(len(self.address) > 0) 2637 2638 def setFromNode(self, node): 2639 """Set the dialog contents from a clicked node. 2640 2641 Arguments: 2642 node -- the node to set the unique ID from 2643 """ 2644 self.address = node.uId 2645 self.updateLinkText() 2646 2647 def setFromComponents(self, address, name): 2648 """Set the dialog contents from separate address and name. 2649 2650 Arguments: 2651 address -- the link address, including the protocol prefix 2652 name -- the displayed name for the link 2653 """ 2654 self.address = address 2655 self.nameEdit.setText(name) 2656 self.updateLinkText() 2657 2658 def htmlText(self): 2659 """Return the dialog contents in HTML link format. 2660 """ 2661 name = self.nameEdit.text().strip() 2662 if not name: 2663 name = _('link') 2664 return '<a href="#{0}">{1}</a>'.format(self.address, name) 2665 2666 2667class PictureLinkEditor(ComboEditor): 2668 """An editor widget for picture link fields. 2669 2670 Uses a combo box with a link entry box in place of the list popup. 2671 """ 2672 dragLinkEnabled = True 2673 def __init__(self, parent=None): 2674 """Initialize the editor class. 2675 2676 Arguments: 2677 parent -- the parent, if given 2678 """ 2679 super().__init__(parent) 2680 self.dialog = None 2681 openAction = QAction(_('&Open Picture'), self) 2682 openAction.triggered.connect(self.openPicture) 2683 self.lineEdit().extraMenuActions = [openAction] 2684 2685 def showPopup(self): 2686 """Override to show a popup entry widget in place of a list view. 2687 """ 2688 if not self.dialog: 2689 self.dialog = PictureLinkDialog(True, self) 2690 self.dialog.contentsChanged.connect(self.setLink) 2691 self.dialog.show() 2692 pos = self.mapToGlobal(self.rect().bottomRight()) 2693 pos.setX(pos.x() - self.dialog.width() + 1) 2694 screenBottom = (QApplication.desktop().screenGeometry(self). 2695 bottom()) 2696 if pos.y() + self.dialog.height() > screenBottom: 2697 pos.setY(pos.y() - self.rect().height() - self.dialog.height()) 2698 self.dialog.move(pos) 2699 self.dialog.setAddress(self.currentText()) 2700 2701 def hidePopup(self): 2702 """Override to hide the popup entry widget. 2703 """ 2704 if self.dialog: 2705 self.dialog.hide() 2706 super().hidePopup() 2707 2708 def setLink(self): 2709 """Set the current link from the popup dialog. 2710 """ 2711 self.setEditText(self.dialog.currentAddress()) 2712 2713 def openPicture(self): 2714 """Open the link in a web browser. 2715 """ 2716 address = self.currentText() 2717 if address: 2718 if urltools.isRelative(address): 2719 defaultPath = globalref.mainControl.defaultPathObj(True) 2720 address = urltools.toAbsolute(address, str(defaultPath)) 2721 openExtUrl(address) 2722 2723 def addDroppedUrl(self, urlText): 2724 """Add the URL link that was dropped on this editor from the view. 2725 2726 Arguments: 2727 urlText -- the text of the link 2728 """ 2729 self.setEditText(urlText) 2730 2731 2732class PictureLinkDialog(QDialog): 2733 """A popup or normal dialog box for picture link editing. 2734 """ 2735 thumbnailSize = QSize(250, 100) 2736 contentsChanged = pyqtSignal() 2737 def __init__(self, popupDialog=False, parent=None): 2738 """Initialize the dialog widgets. 2739 2740 Arguments: 2741 popupDialog -- add OK and cancel buttons if False 2742 parent -- the dialog's parent widget 2743 """ 2744 super().__init__(parent) 2745 self.setWindowFlags(Qt.Dialog | Qt.WindowTitleHint | 2746 Qt.WindowCloseButtonHint) 2747 self.setWindowTitle(_('Picture Link')) 2748 self.setMinimumWidth(self.thumbnailSize.width()) 2749 vertLayout = QVBoxLayout(self) 2750 vertLayout.setSpacing(1) 2751 self.thumbnail = QLabel() 2752 pixmap = QPixmap(self.thumbnailSize) 2753 pixmap.fill() 2754 self.thumbnail.setPixmap(pixmap) 2755 vertLayout.addWidget(self.thumbnail, 0, Qt.AlignHCenter) 2756 vertLayout.addSpacing(8) 2757 2758 self.browseButton = QPushButton(_('&Browse for File')) 2759 self.browseButton.setAutoDefault(False) 2760 self.browseButton.clicked.connect(self.fileBrowse) 2761 vertLayout.addWidget(self.browseButton) 2762 vertLayout.addSpacing(8) 2763 2764 self.pathTypeLabel = QLabel(_('File Path Type')) 2765 vertLayout.addWidget(self.pathTypeLabel) 2766 pathTypeLayout = QHBoxLayout() 2767 vertLayout.addLayout(pathTypeLayout) 2768 pathTypeLayout.setSpacing(8) 2769 pathTypeButtons = QButtonGroup(self) 2770 self.absoluteButton = QRadioButton(_('Absolute')) 2771 pathTypeButtons.addButton(self.absoluteButton) 2772 pathTypeLayout.addWidget(self.absoluteButton) 2773 self.relativeButton = QRadioButton(_('Relative')) 2774 pathTypeButtons.addButton(self.relativeButton) 2775 pathTypeLayout.addWidget(self.relativeButton) 2776 self.absoluteButton.setChecked(True) 2777 pathTypeButtons.buttonClicked.connect(self.updatePathType) 2778 vertLayout.addSpacing(8) 2779 2780 addressLabel = QLabel(_('Address')) 2781 vertLayout.addWidget(addressLabel) 2782 self.addressEdit = QLineEdit() 2783 self.addressEdit.textEdited.connect(self.checkAddress) 2784 vertLayout.addWidget(self.addressEdit) 2785 vertLayout.addSpacing(8) 2786 2787 if popupDialog: 2788 self.setWindowFlags(Qt.Popup) 2789 else: 2790 vertLayout.addSpacing(8) 2791 ctrlLayout = QHBoxLayout() 2792 vertLayout.addLayout(ctrlLayout) 2793 ctrlLayout.addStretch(0) 2794 okButton = QPushButton(_('&OK')) 2795 ctrlLayout.addWidget(okButton) 2796 okButton.clicked.connect(self.accept) 2797 cancelButton = QPushButton(_('&Cancel')) 2798 ctrlLayout.addWidget(cancelButton) 2799 cancelButton.clicked.connect(self.reject) 2800 self.addressEdit.setFocus() 2801 2802 def setAddress(self, address): 2803 """Set the dialog contents from a string in editor format. 2804 2805 Arguments: 2806 address -- URL string for the address 2807 """ 2808 if address and urltools.isRelative(address): 2809 self.relativeButton.setChecked(True) 2810 else: 2811 self.absoluteButton.setChecked(True) 2812 self.addressEdit.setText(address) 2813 self.updateThumbnail() 2814 2815 def setFromHtml(self, htmlStr): 2816 """Set the dialog contents from an HTML link. 2817 2818 Arguments: 2819 htmlStr -- string in HTML link format 2820 """ 2821 linkMatch = imageRegExp.search(htmlStr) 2822 if linkMatch: 2823 address = linkMatch.group(1) 2824 self.setAddress(address.strip()) 2825 2826 def htmlText(self): 2827 """Return the dialog contents in HTML link format. 2828 """ 2829 address = self.currentAddress() 2830 if not address: 2831 return '' 2832 return '<img src="{0}" />'.format(address) 2833 2834 def currentAddress(self): 2835 """Return current address with the selected scheme prefix. 2836 """ 2837 return self.addressEdit.text().strip() 2838 2839 def checkAddress(self): 2840 """Update absolute controls based on a change to the address field. 2841 """ 2842 address = self.addressEdit.text().strip() 2843 if address: 2844 if urltools.isRelative(address): 2845 self.relativeButton.setChecked(True) 2846 else: 2847 self.absoluteButton.setChecked(True) 2848 self.updateThumbnail() 2849 self.contentsChanged.emit() 2850 2851 def updatePathType(self): 2852 """Update path based on a change in the absolute/relative control. 2853 """ 2854 absolute = self.absoluteButton.isChecked() 2855 defaultPath = globalref.mainControl.defaultPathObj(True) 2856 address = self.addressEdit.text().strip() 2857 if absolute: 2858 address = urltools.toAbsolute(address, str(defaultPath), False) 2859 else: 2860 address = urltools.toRelative(address, str(defaultPath)) 2861 self.addressEdit.setText(address) 2862 self.updateThumbnail() 2863 self.contentsChanged.emit() 2864 2865 def updateThumbnail(self): 2866 """Update the thumbnail with an image from the current address. 2867 """ 2868 address = self.addressEdit.text().strip() 2869 if urltools.isRelative(address): 2870 refPath = str(globalref.mainControl.defaultPathObj(True)) 2871 address = urltools.toAbsolute(address, refPath, False) 2872 pixmap = QPixmap(address) 2873 if pixmap.isNull(): 2874 pixmap = QPixmap(self.thumbnailSize) 2875 pixmap.fill() 2876 else: 2877 pixmap = pixmap.scaled(self.thumbnailSize, 2878 Qt.KeepAspectRatio) 2879 self.thumbnail.setPixmap(pixmap) 2880 2881 def fileBrowse(self): 2882 """Show dialog to browse for a file to be linked. 2883 2884 Adjust based on absolute or relative path settings. 2885 """ 2886 refPath = str(globalref.mainControl.defaultPathObj(True)) 2887 defaultPath = refPath 2888 oldAddress = self.addressEdit.text().strip() 2889 if oldAddress: 2890 if urltools.isRelative(oldAddress): 2891 oldAddress = urltools.toAbsolute(oldAddress, refPath) 2892 oldAddress = urltools.extractAddress(oldAddress) 2893 if os.access(oldAddress, os.F_OK): 2894 defaultPath = oldAddress 2895 address, selFltr = QFileDialog.getOpenFileName(self, 2896 _('TreeLine - Picture File'), 2897 defaultPath, 2898 globalref.fileFilters['all']) 2899 if address: 2900 if self.relativeButton.isChecked(): 2901 address = urltools.toRelative(address, refPath) 2902 self.setAddress(address) 2903 self.updateThumbnail() 2904 self.show() 2905 self.contentsChanged.emit() 2906 2907 2908 #### Utility Functions #### 2909 2910def openExtUrl(path): 2911 """Open a web browser or a application for a directory or file. 2912 2913 Arguments: 2914 path -- the path to open 2915 """ 2916 if sys.platform.startswith('win'): 2917 os.startfile(path) 2918 elif sys.platform.startswith('darwin'): 2919 subprocess.call(['open', path]) 2920 else: 2921 subprocess.call(['xdg-open', path]) 2922