1#!/usr/bin/env python3
2
3#******************************************************************************
4# treeview.py, provides a class for the indented tree view
5#
6# TreeLine, an information storage program
7# Copyright (C) 2018, 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 re
16import unicodedata
17from PyQt5.QtCore import QEvent, QPoint, QPointF, Qt, pyqtSignal
18from PyQt5.QtGui import (QContextMenuEvent, QKeySequence, QMouseEvent,
19                         QTextDocument)
20from PyQt5.QtWidgets import (QAbstractItemView, QApplication, QHeaderView,
21                             QLabel, QListWidget, QListWidgetItem, QMenu,
22                             QStyledItemDelegate, QTreeView)
23import treeselection
24import treenode
25import miscdialogs
26import globalref
27
28
29class TreeView(QTreeView):
30    """Class override for the indented tree view.
31
32    Sets view defaults and links with document for content.
33    """
34    skippedMouseSelect = pyqtSignal(treenode.TreeNode)
35    shortcutEntered = pyqtSignal(QKeySequence)
36    def __init__(self, model, allActions, parent=None):
37        """Initialize the tree view.
38
39        Arguments:
40            model -- the initial model for view data
41            allActions -- a dictionary of control actions for popup menus
42            parent -- the parent main window
43        """
44        super().__init__(parent)
45        self.resetModel(model)
46        self.allActions = allActions
47        self.incremSearchMode = False
48        self.incremSearchString = ''
49        self.noMouseSelectMode = False
50        self.mouseFocusNoEditMode = False
51        self.prevSelSpot = None   # temp, to check for edit at mouse release
52        self.setSelectionMode(QAbstractItemView.ExtendedSelection)
53        self.header().setSectionResizeMode(0, QHeaderView.ResizeToContents)
54        self.header().setStretchLastSection(False)
55        self.setHeaderHidden(True)
56        self.setItemDelegate(TreeEditDelegate(self))
57        # use mouse event for editing to avoid with multiple select
58        self.setEditTriggers(QAbstractItemView.NoEditTriggers)
59        self.updateTreeGenOptions()
60        self.setDragDropMode(QAbstractItemView.DragDrop)
61        self.setDefaultDropAction(Qt.MoveAction)
62        self.setDropIndicatorShown(True)
63        self.setUniformRowHeights(True)
64
65    def resetModel(self, model):
66        """Change the model assigned to this view.
67
68        Also assigns a new selection model.
69        Arguments:
70            model -- the new model to assign
71        """
72        self.setModel(model)
73        self.setSelectionModel(treeselection.TreeSelection(model, self))
74
75    def updateTreeGenOptions(self):
76        """Set the tree to match the current general options.
77        """
78        dragAvail = globalref.genOptions['DragTree']
79        self.setDragEnabled(dragAvail)
80        self.setAcceptDrops(dragAvail)
81        self.setIndentation(globalref.genOptions['IndentOffset'] *
82                            self.fontInfo().pixelSize())
83
84    def isSpotExpanded(self, spot):
85        """Return True if the given spot is expanded (showing children).
86
87        Arguments:
88            spot -- the spot to check
89        """
90        return self.isExpanded(spot.index(self.model()))
91
92    def expandSpot(self, spot):
93        """Expand a spot in this view.
94
95        Arguments:
96            spot -- the spot to expand
97        """
98        self.expand(spot.index(self.model()))
99
100    def collapseSpot(self, spot):
101        """Collapse a spot in this view.
102
103        Arguments:
104            spot -- the spot to collapse
105        """
106        self.collapse(spot.index(self.model()))
107
108    def expandBranch(self, parentSpot):
109        """Expand all spots in the given branch.
110
111        Collapses parentSpot first to avoid extreme slowness.
112        Arguments:
113            parentSpot -- the top spot in the branch
114        """
115        self.collapse(parentSpot.index(self.model()))
116        for spot in parentSpot.spotDescendantOnlyGen():
117            if spot.nodeRef.childList:
118                self.expand(spot.index(self.model()))
119        self.expand(parentSpot.index(self.model()))
120
121    def collapseBranch(self, parentSpot):
122        """Collapse all spots in the given branch.
123
124        Arguments:
125            parentSpot -- the top spot in the branch
126        """
127        for spot in parentSpot.spotDescendantGen():
128            if spot.nodeRef.childList:
129                self.collapse(spot.index(self.model()))
130
131    def savedExpandState(self, spots):
132        """Return a list of tuples of spots and expanded state (True/False).
133
134        Arguments:
135            spots -- an iterable of spots to save
136        """
137        return [(spot, self.isSpotExpanded(spot)) for spot in spots]
138
139    def restoreExpandState(self, expandState):
140        """Expand or collapse based on saved tuples.
141
142        Arguments:
143            expandState -- a list of tuples of spots and expanded state
144        """
145        for spot, expanded in expandState:
146            try:
147                if expanded:
148                    self.expandSpot(spot)
149                else:
150                    self.collapseSpot(spot)
151            except ValueError:
152                pass
153
154    def spotAtTop(self):
155        """If view is scrolled, return the spot at the top of the view.
156
157        If not scrolled, return None.
158        """
159        if self.verticalScrollBar().value() > 0:
160            return self.indexAt(QPoint(0, 0)).internalPointer()
161        return None
162
163    def scrollToSpot(self, spot):
164        """Scroll the view to move the spot to the top position.
165
166        Arguments:
167            spot -- the spot to move to the top
168        """
169        self.scrollTo(spot.index(self.model()),
170                      QAbstractItemView.PositionAtTop)
171
172    def scrollTo(self, index, hint=QAbstractItemView.EnsureVisible):
173        """Scroll the view to make node at index visible.
174
175        Overriden to stop autoScroll from horizontally jumping when selecting
176        nodes.
177        Arguments:
178            index -- the node to be made visible
179            hint -- where the visible item should be
180        """
181        horizPos = self.horizontalScrollBar().value()
182        super().scrollTo(index, hint)
183        self.horizontalScrollBar().setValue(horizPos)
184
185    def endEditing(self):
186        """Stop the editing of any item being renamed.
187        """
188        delegate = self.itemDelegate()
189        if delegate.editor:
190            delegate.commitData.emit(delegate.editor)
191        self.closePersistentEditor(self.selectionModel().currentIndex())
192
193    def incremSearchStart(self):
194        """Start an incremental title search.
195        """
196        self.incremSearchMode = True
197        self.incremSearchString = ''
198        globalref.mainControl.currentStatusBar().showMessage(_('Search for:'))
199
200    def incremSearchRun(self):
201        """Perform an incremental title search.
202        """
203        msg = _('Search for: {0}').format(self.incremSearchString)
204        globalref.mainControl.currentStatusBar().showMessage(msg)
205        if (self.incremSearchString and not
206            self.selectionModel().selectTitleMatch(self.incremSearchString,
207                                                 True, True)):
208            msg = _('Search for: {0}  (not found)').format(self.
209                                                           incremSearchString)
210            globalref.mainControl.currentStatusBar().showMessage(msg)
211
212    def incremSearchNext(self):
213        """Go to the next match in an incremental title search.
214        """
215        if self.incremSearchString:
216            if self.selectionModel().selectTitleMatch(self.incremSearchString):
217                msg = _('Next: {0}').format(self.incremSearchString)
218            else:
219                msg = _('Next: {0}  (not found)').format(self.
220                                                         incremSearchString)
221            globalref.mainControl.currentStatusBar().showMessage(msg)
222
223    def incremSearchPrev(self):
224        """Go to the previous match in an incremental title search.
225        """
226        if self.incremSearchString:
227            if self.selectionModel().selectTitleMatch(self.incremSearchString,
228                                                      False):
229                msg = _('Next: {0}').format(self.incremSearchString)
230            else:
231                msg = _('Next: {0}  (not found)').format(self.
232                                                         incremSearchString)
233            globalref.mainControl.currentStatusBar().showMessage(msg)
234
235    def incremSearchStop(self):
236        """End an incremental title search.
237        """
238        self.incremSearchMode = False
239        self.incremSearchString = ''
240        globalref.mainControl.currentStatusBar().clearMessage()
241
242    def showTypeMenu(self, menu):
243        """Show a popup menu for setting the item type.
244        """
245        index = self.selectionModel().currentIndex()
246        self.scrollTo(index)
247        rect = self.visualRect(index)
248        pt = self.mapToGlobal(QPoint(rect.center().x(), rect.bottom()))
249        menu.popup(pt)
250
251    def contextMenu(self):
252        """Return the context menu, creating it if necessary.
253        """
254        menu = QMenu(self)
255        menu.addAction(self.allActions['EditCut'])
256        menu.addAction(self.allActions['EditCopy'])
257        menu.addAction(self.allActions['EditPaste'])
258        menu.addAction(self.allActions['NodeRename'])
259        menu.addSeparator()
260        menu.addAction(self.allActions['NodeInsertBefore'])
261        menu.addAction(self.allActions['NodeInsertAfter'])
262        menu.addAction(self.allActions['NodeAddChild'])
263        menu.addSeparator()
264        menu.addAction(self.allActions['NodeDelete'])
265        menu.addAction(self.allActions['NodeIndent'])
266        menu.addAction(self.allActions['NodeUnindent'])
267        menu.addSeparator()
268        menu.addAction(self.allActions['NodeMoveUp'])
269        menu.addAction(self.allActions['NodeMoveDown'])
270        menu.addSeparator()
271        menu.addMenu(self.allActions['DataNodeType'].parent())
272        menu.addSeparator()
273        menu.addAction(self.allActions['ViewExpandBranch'])
274        menu.addAction(self.allActions['ViewCollapseBranch'])
275        return menu
276
277    def contextMenuEvent(self, event):
278        """Show popup context menu on mouse click or menu key.
279
280        Arguments:
281            event -- the context menu event
282        """
283        if event.reason() == QContextMenuEvent.Mouse:
284            clickedSpot = self.indexAt(event.pos()).internalPointer()
285            if not clickedSpot:
286                event.ignore()
287                return
288            if clickedSpot not in self.selectionModel().selectedSpots():
289                self.selectionModel().selectSpots([clickedSpot])
290            pos = event.globalPos()
291        else:       # shown for menu key or other reason
292            selectList = self.selectionModel().selectedSpots()
293            if not selectList:
294                event.ignore()
295                return
296            currentSpot = self.selectionModel().currentSpot()
297            if currentSpot in selectList:
298                selectList.insert(0, currentSpot)
299            position = None
300            for spot in selectList:
301                rect = self.visualRect(spot.index(self.model()))
302                pt = QPoint(rect.center().x(), rect.bottom())
303                if self.rect().contains(pt):
304                    position = pt
305                    break
306            if not position:
307                self.scrollTo(selectList[0].index(self.model()))
308                rect = self.visualRect(selectList[0].index(self.model()))
309                position = QPoint(rect.center().x(), rect.bottom())
310            pos = self.mapToGlobal(position)
311        self.contextMenu().popup(pos)
312        event.accept()
313
314    def dropEvent(self, event):
315        """Event handler for view drop actions.
316
317        Selects parent node at destination.
318        Arguments:
319            event -- the drop event
320        """
321        clickedSpot = self.indexAt(event.pos()).internalPointer()
322        # clear selection to avoid invalid multiple selection bug
323        self.selectionModel().selectSpots([], False)
324        if clickedSpot:
325            super().dropEvent(event)
326            self.selectionModel().selectSpots([clickedSpot], False)
327            self.scheduleDelayedItemsLayout()  # reqd before expand
328            self.expandSpot(clickedSpot)
329        else:
330            super().dropEvent(event)
331            self.selectionModel().selectSpots([])
332            self.scheduleDelayedItemsLayout()
333        if event.isAccepted():
334            self.model().treeModified.emit(True, True)
335
336    def toggleNoMouseSelectMode(self, active=True):
337        """Set noMouseSelectMode to active or inactive.
338
339        noMouseSelectMode will not change selection on mouse click,
340        it will just signal the clicked node for use in links, etc.
341        Arguments:
342            active -- if True, activate noMouseSelectMode
343        """
344        self.noMouseSelectMode = active
345
346    def clearHover(self):
347        """Post a mouse move event to clear the mouse hover indication.
348
349        Needed to avoid crash when deleting nodes with hovered child nodes.
350        """
351        event = QMouseEvent(QEvent.MouseMove,
352                            QPointF(0.0, self.viewport().width()),
353                            Qt.NoButton, Qt.NoButton, Qt.NoModifier)
354        QApplication.postEvent(self.viewport(), event)
355        QApplication.processEvents()
356
357    def mousePressEvent(self, event):
358        """Skip unselecting click if in noMouseSelectMode.
359
360        If in noMouseSelectMode, signal which node is under the mouse.
361        Arguments:
362            event -- the mouse click event
363        """
364        if self.incremSearchMode:
365            self.incremSearchStop()
366        self.prevSelSpot = None
367        clickedIndex = self.indexAt(event.pos())
368        clickedSpot = clickedIndex.internalPointer()
369        selectModel = self.selectionModel()
370        if self.noMouseSelectMode:
371            if clickedSpot and event.button() == Qt.LeftButton:
372                self.skippedMouseSelect.emit(clickedSpot.nodeRef)
373            event.ignore()
374            return
375        if (event.button() == Qt.LeftButton and
376            not self.mouseFocusNoEditMode and
377            selectModel.selectedCount() == 1 and
378            selectModel.currentSpot() == selectModel.selectedSpots()[0] and
379            event.pos().x() > self.visualRect(clickedIndex).left() and
380            globalref.genOptions['ClickRename']):
381            # set for edit if single select and not an expand/collapse click
382            self.prevSelSpot = selectModel.selectedSpots()[0]
383        self.mouseFocusNoEditMode = False
384        super().mousePressEvent(event)
385
386    def mouseReleaseEvent(self, event):
387        """Initiate editing if clicking on a single selected node.
388
389        Arguments:
390            event -- the mouse click event
391        """
392        clickedIndex = self.indexAt(event.pos())
393        clickedSpot = clickedIndex.internalPointer()
394        if (event.button() == Qt.LeftButton and
395            self.prevSelSpot and clickedSpot == self.prevSelSpot):
396            self.edit(clickedIndex)
397            event.ignore()
398            return
399        self.prevSelSpot = None
400        super().mouseReleaseEvent(event)
401
402    def keyPressEvent(self, event):
403        """Record characters if in incremental search mode.
404
405        Arguments:
406            event -- the key event
407        """
408        if self.incremSearchMode:
409            if event.key() in (Qt.Key_Return, Qt.Key_Enter, Qt.Key_Escape):
410                self.incremSearchStop()
411            elif event.key() == Qt.Key_Backspace and self.incremSearchString:
412                self.incremSearchString = self.incremSearchString[:-1]
413                self.incremSearchRun()
414            elif event.text() and unicodedata.category(event.text()) != 'Cc':
415                # unicode category excludes control characters
416                self.incremSearchString += event.text()
417                self.incremSearchRun()
418            event.accept()
419        elif (event.key() in (Qt.Key_Return, Qt.Key_Enter) and
420              not self.itemDelegate().editor):
421            # enter key selects current item if not selected
422            selectModel = self.selectionModel()
423            if selectModel.currentSpot() not in selectModel.selectedSpots():
424                selectModel.selectSpots([selectModel.currentSpot()])
425                event.accept()
426            else:
427                super().keyPressEvent(event)
428        else:
429            super().keyPressEvent(event)
430
431    def focusInEvent(self, event):
432        """Avoid editing a tree item with a get-focus click.
433
434        Arguments:
435            event -- the focus in event
436        """
437        if event.reason() == Qt.MouseFocusReason:
438            self.mouseFocusNoEditMode = True
439        super().focusInEvent(event)
440
441    def focusOutEvent(self, event):
442        """Stop incremental search on focus loss.
443
444        Arguments:
445            event -- the focus out event
446        """
447        if self.incremSearchMode:
448            self.incremSearchStop()
449        super().focusOutEvent(event)
450
451
452class TreeEditDelegate(QStyledItemDelegate):
453    """Class override for editing tree items to capture shortcut keys.
454    """
455    def __init__(self, parent=None):
456        """Initialize the delegate class.
457
458        Arguments:
459            parent -- the parent view
460        """
461        super().__init__(parent)
462        self.editor = None
463
464    def createEditor(self, parent, styleOption, modelIndex):
465        """Return a new text editor for an item.
466
467        Arguments:
468            parent -- the parent widget for the editor
469            styleOption -- the data for styles and geometry
470            modelIndex -- the index of the item to be edited
471        """
472        self.editor = super().createEditor(parent, styleOption, modelIndex)
473        return self.editor
474
475    def destroyEditor(self, editor, index):
476        """Reset editor storage after editing ends.
477
478        Arguments:
479            editor -- the editor that is ending
480            index -- the index of the edited item
481        """
482        self.editor = None
483        super().destroyEditor(editor, index)
484
485    def eventFilter(self, editor, event):
486        """Override to handle shortcut control keys.
487
488        Arguments:
489            editor -- the editor that Qt installed a filter on
490            event -- the key press event
491        """
492        if (event.type() == QEvent.KeyPress and
493            event.modifiers() == Qt.ControlModifier and
494            Qt.Key_A <= event.key() <= Qt.Key_Z):
495            key = QKeySequence(event.modifiers() | event.key())
496            self.parent().shortcutEntered.emit(key)
497            return True
498        return super().eventFilter(editor, event)
499
500
501class TreeFilterViewItem(QListWidgetItem):
502    """Item container for the flat list of filtered nodes.
503    """
504    def __init__(self, spot, viewParent=None):
505        """Initialize the list view item.
506
507        Arguments:
508            spot -- the spot to reference for content
509            viewParent -- the parent list view
510        """
511        super().__init__(viewParent)
512        self.spot = spot
513        self.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable |
514                      Qt.ItemIsEnabled)
515        self.update()
516
517    def update(self):
518        """Update title and icon from the stored node.
519        """
520        node = self.spot.nodeRef
521        self.setText(node.title())
522        if globalref.genOptions['ShowTreeIcons']:
523            self.setIcon(globalref.treeIcons.getIcon(node.formatRef.iconName,
524                                                     True))
525
526
527class TreeFilterView(QListWidget):
528    """View to show flat list of filtered nodes.
529    """
530    skippedMouseSelect = pyqtSignal(treenode.TreeNode)
531    shortcutEntered = pyqtSignal(QKeySequence)
532    def __init__(self, treeViewRef, allActions, parent=None):
533        """Initialize the list view.
534
535        Arguments:
536            treeViewRef -- a ref to the tree view for data
537            allActions -- a dictionary of control actions for popup menus
538            parent -- the parent main window
539        """
540        super().__init__(parent)
541        self.structure = treeViewRef.model().treeStructure
542        self.selectionModel = treeViewRef.selectionModel()
543        self.treeModel = treeViewRef.model()
544        self.allActions = allActions
545        self.menu = None
546        self.noMouseSelectMode = False
547        self.mouseFocusNoEditMode = False
548        self.prevSelSpot = None   # temp, to check for edit at mouse release
549        self.drivingSelectionChange = False
550        self.conditionalFilter = None
551        self.messageLabel = None
552        self.filterWhat = miscdialogs.FindScope.fullData
553        self.filterHow = miscdialogs.FindType.keyWords
554        self.filterStr = ''
555        self.setSelectionMode(QAbstractItemView.ExtendedSelection)
556        self.setItemDelegate(TreeEditDelegate(self))
557        # use mouse event for editing to avoid with multiple select
558        self.setEditTriggers(QAbstractItemView.NoEditTriggers)
559        self.itemSelectionChanged.connect(self.updateSelectionModel)
560        self.itemChanged.connect(self.changeTitle)
561        treeFont = QTextDocument().defaultFont()
562        treeFontName = globalref.miscOptions['TreeFont']
563        if treeFontName:
564            treeFont.fromString(treeFontName)
565            self.setFont(treeFont)
566
567    def updateItem(self, node):
568        """Update the item corresponding to the given node.
569
570        Arguments:
571            node -- the node to be updated
572        """
573        for row in range(self.count()):
574            if self.item(row).spot.nodeRef == node:
575                self.blockSignals(True)
576                self.item(row).update()
577                self.blockSignals(False)
578                return
579
580    def updateContents(self):
581        """Update filtered contents from current structure and filter criteria.
582        """
583        if self.conditionalFilter:
584            self.conditionalUpdate()
585            return
586        QApplication.setOverrideCursor(Qt.WaitCursor)
587        if self.filterHow == miscdialogs.FindType.regExp:
588            criteria = [re.compile(self.filterStr)]
589            useRegExpFilter = True
590        elif self.filterHow == miscdialogs.FindType.fullWords:
591            criteria = []
592            for word in self.filterStr.lower().split():
593                criteria.append(re.compile(r'(?i)\b{}\b'.
594                                           format(re.escape(word))))
595            useRegExpFilter = True
596        elif self.filterHow == miscdialogs.FindType.keyWords:
597            criteria = self.filterStr.lower().split()
598            useRegExpFilter = False
599        else:         # full phrase
600            criteria = [self.filterStr.lower().strip()]
601            useRegExpFilter = False
602        titlesOnly = self.filterWhat == miscdialogs.FindScope.titlesOnly
603        self.blockSignals(True)
604        self.clear()
605        if useRegExpFilter:
606            for rootSpot in self.structure.rootSpots():
607                for spot in rootSpot.spotDescendantGen():
608                    if spot.nodeRef.regExpSearch(criteria, titlesOnly):
609                        item = TreeFilterViewItem(spot, self)
610        else:
611            for rootSpot in self.structure.rootSpots():
612                for spot in rootSpot.spotDescendantGen():
613                    if spot.nodeRef.wordSearch(criteria, titlesOnly):
614                        item = TreeFilterViewItem(spot, self)
615        self.blockSignals(False)
616        self.selectItems(self.selectionModel.selectedSpots(), True)
617        if self.count() and not self.selectedItems():
618            self.item(0).setSelected(True)
619        if not self.messageLabel:
620            self.messageLabel = QLabel()
621            globalref.mainControl.currentStatusBar().addWidget(self.
622                                                               messageLabel)
623        message = _('Filtering by "{0}", found {1} nodes').format(self.
624                                                                  filterStr,
625                                                                  self.count())
626        self.messageLabel.setText(message)
627        QApplication.restoreOverrideCursor()
628
629    def conditionalUpdate(self):
630        """Update filtered contents from structure and conditional criteria.
631        """
632        QApplication.setOverrideCursor(Qt.WaitCursor)
633        self.blockSignals(True)
634        self.clear()
635        for rootSpot in self.structure.rootSpots():
636            for spot in rootSpot.spotDescendantGen():
637                if self.conditionalFilter.evaluate(spot.nodeRef):
638                    item = TreeFilterViewItem(spot, self)
639        self.blockSignals(False)
640        self.selectItems(self.selectionModel.selectedSpots(), True)
641        if self.count() and not self.selectedItems():
642            self.item(0).setSelected(True)
643        if not self.messageLabel:
644            self.messageLabel = QLabel()
645            globalref.mainControl.currentStatusBar().addWidget(self.
646                                                               messageLabel)
647        message = _('Conditional filtering, found {0} nodes').format(self.
648                                                                     count())
649        self.messageLabel.setText(message)
650        QApplication.restoreOverrideCursor()
651
652    def selectItems(self, spots, signalModel=False):
653        """Select items matching given nodes if in filtered view.
654
655        Arguments:
656            spots -- the spot list to select
657            signalModel -- signal to update the tree selection model if True
658        """
659        selectSpots = set(spots)
660        if not signalModel:
661            self.blockSignals(True)
662        for item in self.selectedItems():
663            item.setSelected(False)
664        for row in range(self.count()):
665            if self.item(row).spot in selectSpots:
666                self.item(row).setSelected(True)
667                self.setCurrentItem(self.item(row))
668        self.blockSignals(False)
669
670    def updateFromSelectionModel(self):
671        """Select items selected in the tree selection model.
672
673        Called from a signal that the tree selection model is changing.
674        """
675        if self.count() and not self.drivingSelectionChange:
676            self.selectItems(self.selectionModel.selectedSpots())
677
678    def updateSelectionModel(self):
679        """Change the selection model based on a filter list selection signal.
680        """
681        self.drivingSelectionChange = True
682        self.selectionModel.selectSpots([item.spot for item in
683                                         self.selectedItems()])
684        self.drivingSelectionChange = False
685
686    def changeTitle(self, item):
687        """Update the node title in the model based on an edit signal.
688
689        Reset to the node text if invalid.
690        Arguments:
691            item -- the filter view item that changed
692        """
693        if not self.treeModel.setData(item.spot.index(self.treeModel),
694                                      item.text()):
695            self.blockSignals(True)
696            item.setText(item.node.title())
697            self.blockSignals(False)
698
699    def nextPrevSpot(self, spot, forward=True):
700        """Return the next or previous spot in this filter list view.
701
702        Wraps around ends.  Return None if view doesn't have spot.
703        Arguments:
704            spot -- the starting spot
705            forward -- next if True, previous if False
706        """
707        for row in range(self.count()):
708            if self.item(row).spot == spot:
709                if forward:
710                    row += 1
711                    if row >= self.count():
712                        row = 0
713                else:
714                    row -= 1
715                    if row < 0:
716                        row = self.count() - 1
717                return self.item(row).spot
718        return None
719
720    def contextMenu(self):
721        """Return the context menu, creating it if necessary.
722        """
723        if not self.menu:
724            self.menu = QMenu(self)
725            self.menu.addAction(self.allActions['EditCut'])
726            self.menu.addAction(self.allActions['EditCopy'])
727            self.menu.addAction(self.allActions['NodeRename'])
728            self.menu.addSeparator()
729            self.menu.addAction(self.allActions['NodeDelete'])
730            self.menu.addSeparator()
731            self.menu.addMenu(self.allActions['DataNodeType'].parent())
732        return self.menu
733
734    def contextMenuEvent(self, event):
735        """Show popup context menu on mouse click or menu key.
736
737        Arguments:
738            event -- the context menu event
739        """
740        if event.reason() == QContextMenuEvent.Mouse:
741            clickedItem = self.itemAt(event.pos())
742            if not clickedItem:
743                event.ignore()
744                return
745            if clickedItem.spot not in self.selectionModel.selectedSpots():
746                self.selectionModel.selectSpots([clickedItem.spot])
747            pos = event.globalPos()
748        else:       # shown for menu key or other reason
749            selectList = self.selectedItems()
750            if not selectList:
751                event.ignore()
752                return
753            currentItem = self.currentItem()
754            if currentItem in selectList:
755                selectList.insert(0, currentItem)
756            posList = []
757            for item in selectList:
758                rect = self.visualItemRect(item)
759                pt = QPoint(rect.center().x(), rect.bottom())
760                if self.rect().contains(pt):
761                    posList.append(pt)
762            if not posList:
763                self.scrollTo(self.indexFromItem(selectList[0]))
764                rect = self.visualItemRect(selectList[0])
765                posList = [QPoint(rect.center().x(), rect.bottom())]
766            pos = self.mapToGlobal(posList[0])
767        self.contextMenu().popup(pos)
768        event.accept()
769
770    def toggleNoMouseSelectMode(self, active=True):
771        """Set noMouseSelectMode to active or inactive.
772
773        noMouseSelectMode will not change selection on mouse click,
774        it will just signal the clicked node for use in links, etc.
775        Arguments:
776            active -- if True, activate noMouseSelectMode
777        """
778        self.noMouseSelectMode = active
779
780    def mousePressEvent(self, event):
781        """Skip unselecting click on blank spaces.
782
783        Arguments:
784            event -- the mouse click event
785        """
786        self.prevSelSpot = None
787        clickedItem = self.itemAt(event.pos())
788        if not clickedItem:
789            event.ignore()
790            return
791        if self.noMouseSelectMode:
792            if event.button() == Qt.LeftButton:
793                self.skippedMouseSelect.emit(clickedItem.spot.nodeRef)
794            event.ignore()
795            return
796        if (event.button() == Qt.LeftButton and
797            not self.mouseFocusNoEditMode and
798            self.selectionModel.selectedCount() == 1 and
799            globalref.genOptions['ClickRename']):
800            self.prevSelSpot = self.selectionModel.selectedSpots()[0]
801        self.mouseFocusNoEditMode = False
802        super().mousePressEvent(event)
803
804    def mouseReleaseEvent(self, event):
805        """Initiate editing if clicking on a single selected node.
806
807        Arguments:
808            event -- the mouse click event
809        """
810        clickedItem = self.itemAt(event.pos())
811        if (event.button() == Qt.LeftButton and clickedItem and
812            self.prevSelSpot and clickedItem.spot == self.prevSelSpot):
813            self.editItem(clickedItem)
814            event.ignore()
815            return
816        self.prevSelSpot = None
817        super().mouseReleaseEvent(event)
818
819    def focusInEvent(self, event):
820        """Avoid editing a tree item with a get-focus click.
821
822        Arguments:
823            event -- the focus in event
824        """
825        if event.reason() == Qt.MouseFocusReason:
826            self.mouseFocusNoEditMode = True
827        super().focusInEvent(event)
828