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