1#!/usr/bin/env python3
2
3#******************************************************************************
4# treemaincontrol.py, provides a class for global tree commands
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 sys
16import pathlib
17import os.path
18import ast
19import io
20import gzip
21import zlib
22import platform
23from PyQt5.QtCore import QIODevice, QObject, Qt, PYQT_VERSION_STR, qVersion
24from PyQt5.QtGui import QColor, QFont, QPalette
25from PyQt5.QtNetwork import QLocalServer, QLocalSocket
26from PyQt5.QtWidgets import (QAction, QApplication, QDialog, QFileDialog,
27                             QMessageBox, QStyleFactory, QSystemTrayIcon, qApp)
28import globalref
29import treelocalcontrol
30import options
31import optiondefaults
32import recentfiles
33import p3
34import icondict
35import imports
36import configdialog
37import miscdialogs
38import conditional
39import colorset
40import helpview
41try:
42    from __main__ import __version__, __author__
43except ImportError:
44    __version__ = ''
45    __author__ = ''
46try:
47    from __main__ import docPath, iconPath, templatePath, samplePath
48except ImportError:
49    docPath = None
50    iconPath = None
51    templatePath = None
52    samplePath = None
53
54encryptPrefix = b'>>TL+enc'
55
56
57class TreeMainControl(QObject):
58    """Class to handle all global controls.
59
60    Provides methods for all controls and stores local control objects.
61    """
62    def __init__(self, pathObjects, parent=None):
63        """Initialize the main tree controls
64
65        Arguments:
66            pathObjects -- a list of file objects to open
67            parent -- the parent QObject if given
68        """
69        super().__init__(parent)
70        self.localControls = []
71        self.activeControl = None
72        self.trayIcon = None
73        self.isTrayMinimized = False
74        self.configDialog = None
75        self.sortDialog = None
76        self.numberingDialog = None
77        self.findTextDialog = None
78        self.findConditionDialog = None
79        self.findReplaceDialog = None
80        self.filterTextDialog = None
81        self.filterConditionDialog = None
82        self.basicHelpView = None
83        self.passwords = {}
84        self.creatingLocalControlFlag = False
85        globalref.mainControl = self
86        self.allActions = {}
87        try:
88            # check for existing TreeLine session
89            socket = QLocalSocket()
90            socket.connectToServer('treeline3-session',
91                                   QIODevice.WriteOnly)
92            # if found, send files to open and exit TreeLine
93            if socket.waitForConnected(1000):
94                socket.write(bytes(repr([str(path) for path in pathObjects]),
95                                   'utf-8'))
96                if socket.waitForBytesWritten(1000):
97                    socket.close()
98                    sys.exit(0)
99            # start local server to listen for attempt to start new session
100            self.serverSocket = QLocalServer()
101            # remove any old servers still around after a crash in linux
102            self.serverSocket.removeServer('treeline3-session')
103            self.serverSocket.listen('treeline3-session')
104            self.serverSocket.newConnection.connect(self.getSocket)
105        except AttributeError:
106            print(_('Warning:  Could not create local socket'))
107        mainVersion = '.'.join(__version__.split('.')[:2])
108        globalref.genOptions = options.Options('general', 'TreeLine',
109                                               mainVersion, 'bellz')
110        optiondefaults.setGenOptionDefaults(globalref.genOptions)
111        globalref.miscOptions  = options.Options('misc')
112        optiondefaults.setMiscOptionDefaults(globalref.miscOptions)
113        globalref.histOptions = options.Options('history')
114        optiondefaults.setHistOptionDefaults(globalref.histOptions)
115        globalref.toolbarOptions = options.Options('toolbar')
116        optiondefaults.setToolbarOptionDefaults(globalref.toolbarOptions)
117        globalref.keyboardOptions = options.Options('keyboard')
118        optiondefaults.setKeyboardOptionDefaults(globalref.keyboardOptions)
119        try:
120            globalref.genOptions.readFile()
121            globalref.miscOptions.readFile()
122            globalref.histOptions.readFile()
123            globalref.toolbarOptions.readFile()
124            globalref.keyboardOptions.readFile()
125        except IOError:
126            errorDir = options.Options.basePath
127            if not errorDir:
128                errorDir = _('missing directory')
129            QMessageBox.warning(None, 'TreeLine',
130                                _('Error - could not write config file to {}').
131                                format(errorDir))
132            options.Options.basePath = None
133        iconPathList = self.findResourcePaths('icons', iconPath)
134        globalref.toolIcons = icondict.IconDict([path / 'toolbar' for path
135                                                 in iconPathList],
136                                                ['', '32x32', '16x16'])
137        globalref.toolIcons.loadAllIcons()
138        windowIcon = globalref.toolIcons.getIcon('treelogo')
139        if windowIcon:
140            QApplication.setWindowIcon(windowIcon)
141        globalref.treeIcons = icondict.IconDict(iconPathList, ['', 'tree'])
142        icon = globalref.treeIcons.getIcon('default')
143        qApp.setStyle(QStyleFactory.create('Fusion'))
144        self.colorSet = colorset.ColorSet()
145        if globalref.miscOptions['ColorTheme'] != 'system':
146            self.colorSet.setAppColors()
147        self.recentFiles = recentfiles.RecentFileList()
148        if globalref.genOptions['AutoFileOpen'] and not pathObjects:
149            recentPath = self.recentFiles.firstPath()
150            if recentPath:
151                pathObjects = [recentPath]
152        self.setupActions()
153        self.systemFont = QApplication.font()
154        self.updateAppFont()
155        if globalref.genOptions['MinToSysTray']:
156            self.createTrayIcon()
157        qApp.focusChanged.connect(self.updateActionsAvail)
158        if pathObjects:
159            for pathObj in pathObjects:
160                self.openFile(pathObj, True)
161        else:
162            self.createLocalControl()
163
164    def getSocket(self):
165        """Open a socket from an attempt to open a second Treeline instance.
166
167        Opens the file (or raise and focus if open) in this instance.
168        """
169        socket = self.serverSocket.nextPendingConnection()
170        if socket and socket.waitForReadyRead(1000):
171            data = str(socket.readAll(), 'utf-8')
172            try:
173                paths = ast.literal_eval(data)
174                if paths:
175                    for path in paths:
176                        pathObj = pathlib.Path(path)
177                        if pathObj != self.activeControl.filePathObj:
178                            self.openFile(pathObj, True)
179                        else:
180                            self.activeControl.activeWindow.activateAndRaise()
181                else:
182                    self.activeControl.activeWindow.activateAndRaise()
183            except(SyntaxError, ValueError, TypeError, RuntimeError):
184                pass
185
186    def findResourcePaths(self, resourceName, preferredPath=''):
187        """Return list of potential non-empty pathlib objects for the resource.
188
189        List includes preferred, module and user option paths.
190        Arguments:
191            resourceName -- the typical name of the resource directory
192            preferredPath -- add this as the second path if given
193        """
194        # use abspath() - pathlib's resolve() can be buggy with network drives
195        modPath = pathlib.Path(os.path.abspath(sys.path[0]))
196        if modPath.is_file():
197            modPath = modPath.parent    # for frozen binary
198        pathList = [modPath / '..' / resourceName, modPath / resourceName]
199        if options.Options.basePath:
200            basePath = pathlib.Path(options.Options.basePath)
201            pathList.insert(0, basePath / resourceName)
202        if preferredPath:
203            pathList.insert(1, pathlib.Path(preferredPath))
204        return [pathlib.Path(os.path.abspath(str(path))) for path in pathList
205                if path.is_dir() and list(path.iterdir())]
206
207    def findResourceFile(self, fileName, resourceName, preferredPath=''):
208        """Return a path object for a resource file.
209
210        Add a language code before the extension if it exists.
211        Arguments:
212            fileName -- the name of the file to find
213            resourceName -- the typical name of the resource directory
214            preferredPath -- search this path first if given
215        """
216        fileList = [fileName]
217        if globalref.lang and globalref.lang != 'C':
218            fileList[0:0] = [fileName.replace('.', '_{0}.'.
219                                              format(globalref.lang)),
220                             fileName.replace('.', '_{0}.'.
221                                              format(globalref.lang[:2]))]
222        for fileName in fileList:
223            for path in self.findResourcePaths(resourceName, preferredPath):
224                if (path / fileName).is_file():
225                    return path / fileName
226        return None
227
228    def defaultPathObj(self, dirOnly=False):
229        """Return a reasonable default file path object.
230
231        Used for open, save-as, import and export.
232        Arguments:
233            dirOnly -- if True, do not include basename of file
234        """
235        pathObj = None
236        if  self.activeControl:
237            pathObj = self.activeControl.filePathObj
238        if not pathObj:
239            pathObj = self.recentFiles.firstDir()
240            if not pathObj:
241                pathObj = pathlib.Path.home()
242        if dirOnly:
243            pathObj = pathObj.parent
244        return pathObj
245
246    def openFile(self, pathObj, forceNewWindow=False, checkModified=False,
247                 importOnFail=True):
248        """Open the file given by path if not already open.
249
250        If already open in a different window, focus and raise the window.
251        Arguments:
252            pathObj -- the path object to read
253            forceNewWindow -- if True, use a new window regardless of option
254            checkModified -- if True & not new win, prompt if file modified
255            importOnFail -- if True, prompts for import on non-TreeLine files
256        """
257        match = [control for control in self.localControls if
258                 pathObj == control.filePathObj]
259        if match and self.activeControl not in match:
260            control = match[0]
261            control.activeWindow.activateAndRaise()
262            self.updateLocalControlRef(control)
263            return
264        if checkModified and not (forceNewWindow or
265                                  globalref.genOptions['OpenNewWindow'] or
266                                  self.activeControl.checkSaveChanges()):
267            return
268        if not self.checkAutoSave(pathObj):
269            if not self.localControls:
270                self.createLocalControl()
271            return
272        QApplication.setOverrideCursor(Qt.WaitCursor)
273        try:
274            self.createLocalControl(pathObj, None, forceNewWindow)
275            self.recentFiles.addItem(pathObj)
276            if not (globalref.genOptions['SaveTreeStates'] and
277                    self.recentFiles.retrieveTreeState(self.activeControl)):
278                self.activeControl.expandRootNodes()
279                self.activeControl.selectRootSpot()
280            QApplication.restoreOverrideCursor()
281        except IOError:
282            QApplication.restoreOverrideCursor()
283            QMessageBox.warning(QApplication.activeWindow(), 'TreeLine',
284                                _('Error - could not read file {0}').
285                                format(pathObj))
286            self.recentFiles.removeItem(pathObj)
287        except (ValueError, KeyError, TypeError):
288            fileObj = pathObj.open('rb')
289            fileObj, encrypted = self.decryptFile(fileObj)
290            if not fileObj:
291                if not self.localControls:
292                    self.createLocalControl()
293                QApplication.restoreOverrideCursor()
294                return
295            fileObj, compressed = self.decompressFile(fileObj)
296            if compressed or encrypted:
297                try:
298                    textFileObj = io.TextIOWrapper(fileObj, encoding='utf-8')
299                    self.createLocalControl(textFileObj, None, forceNewWindow)
300                    fileObj.close()
301                    textFileObj.close()
302                    self.recentFiles.addItem(pathObj)
303                    if not (globalref.genOptions['SaveTreeStates'] and
304                            self.recentFiles.retrieveTreeState(self.
305                                                               activeControl)):
306                        self.activeControl.expandRootNodes()
307                        self.activeControl.selectRootSpot()
308                    self.activeControl.compressed = compressed
309                    self.activeControl.encrypted = encrypted
310                    QApplication.restoreOverrideCursor()
311                    return
312                except (ValueError, KeyError, TypeError):
313                    pass
314            fileObj.close()
315            importControl = imports.ImportControl(pathObj)
316            structure = importControl.importOldTreeLine()
317            if structure:
318                self.createLocalControl(pathObj, structure, forceNewWindow)
319                self.activeControl.printData.readData(importControl.
320                                                      treeLineRootAttrib)
321                self.recentFiles.addItem(pathObj)
322                self.activeControl.expandRootNodes()
323                self.activeControl.imported = True
324                QApplication.restoreOverrideCursor()
325                return
326            QApplication.restoreOverrideCursor()
327            if importOnFail:
328                importControl = imports.ImportControl(pathObj)
329                structure = importControl.interactiveImport(True)
330                if structure:
331                    self.createLocalControl(pathObj, structure, forceNewWindow)
332                    self.activeControl.imported = True
333                    return
334            else:
335                QMessageBox.warning(QApplication.activeWindow(), 'TreeLine',
336                                    _('Error - invalid TreeLine file {0}').
337                                    format(pathObj))
338                self.recentFiles.removeItem(pathObj)
339        if not self.localControls:
340            self.createLocalControl()
341
342    def decryptFile(self, fileObj):
343        """Check for encryption and decrypt the fileObj if needed.
344
345        Return a tuple of the file object and True if it was encrypted.
346        Return None for the file object if the user cancels.
347        Arguments:
348            fileObj -- the file object to check and decrypt
349        """
350        if fileObj.read(len(encryptPrefix)) != encryptPrefix:
351            fileObj.seek(0)
352            return (fileObj, False)
353        while True:
354            pathObj = pathlib.Path(fileObj.name)
355            password = self.passwords.get(pathObj, '')
356            if not password:
357                QApplication.restoreOverrideCursor()
358                dialog = miscdialogs.PasswordDialog(False, pathObj.name,
359                                                    QApplication.
360                                                    activeWindow())
361                if dialog.exec_() != QDialog.Accepted:
362                    fileObj.close()
363                    return (None, True)
364                QApplication.setOverrideCursor(Qt.WaitCursor)
365                password = dialog.password
366                if miscdialogs.PasswordDialog.remember:
367                    self.passwords[pathObj] = password
368            try:
369                text = p3.p3_decrypt(fileObj.read(), password.encode())
370                fileIO = io.BytesIO(text)
371                fileIO.name = fileObj.name
372                fileObj.close()
373                return (fileIO, True)
374            except p3.CryptError:
375                try:
376                    del self.passwords[pathObj]
377                except KeyError:
378                    pass
379
380    def decompressFile(self, fileObj):
381        """Check for compression and decompress the fileObj if needed.
382
383        Return a tuple of the file object and True if it was compressed.
384        Arguments:
385            fileObj -- the file object to check and decompress
386        """
387        prefix = fileObj.read(2)
388        fileObj.seek(0)
389        if prefix != b'\037\213':
390            return (fileObj, False)
391        try:
392            newFileObj = gzip.GzipFile(fileobj=fileObj)
393        except zlib.error:
394            return (fileObj, False)
395        newFileObj.name = fileObj.name
396        return (newFileObj, True)
397
398    def checkAutoSave(self, pathObj):
399        """Check for presence of auto save file & prompt user.
400
401        Return True if OK to contimue, False if aborting or already loaded.
402        Arguments:
403            pathObj -- the base path object to search for a backup
404        """
405        if not globalref.genOptions['AutoSaveMinutes']:
406            return True
407        basePath = pathObj
408        pathObj = pathlib.Path(str(pathObj) + '~')
409        if not pathObj.is_file():
410            return True
411        msgBox = QMessageBox(QMessageBox.Information, 'TreeLine',
412                             _('Backup file "{}" exists.\nA previous '
413                               'session may have crashed').
414                             format(pathObj), QMessageBox.NoButton,
415                             QApplication.activeWindow())
416        restoreButton = msgBox.addButton(_('&Restore Backup'),
417                                         QMessageBox.ApplyRole)
418        deleteButton = msgBox.addButton(_('&Delete Backup'),
419                                        QMessageBox.DestructiveRole)
420        cancelButton = msgBox.addButton(_('&Cancel File Open'),
421                                        QMessageBox.RejectRole)
422        msgBox.exec_()
423        if msgBox.clickedButton() == restoreButton:
424            self.openFile(pathObj)
425            if self.activeControl.filePathObj != pathObj:
426                return False
427            try:
428                basePath.unlink()
429                pathObj.rename(basePath)
430            except OSError:
431                QMessageBox.warning(QApplication.activeWindow(),
432                                  'TreeLine',
433                                  _('Error - could not rename "{0}" to "{1}"').
434                                  format(pathObj, basePath))
435                return False
436            self.activeControl.filePathObj = basePath
437            self.activeControl.updateWindowCaptions()
438            self.recentFiles.removeItem(pathObj)
439            self.recentFiles.addItem(basePath)
440            return False
441        elif msgBox.clickedButton() == deleteButton:
442            try:
443                pathObj.unlink()
444            except OSError:
445                QMessageBox.warning(QApplication.activeWindow(),
446                                  'TreeLine',
447                                  _('Error - could not remove backup file {}').
448                                  format(pathObj))
449        else:   # cancel button
450            return False
451        return True
452
453    def createLocalControl(self, pathObj=None, treeStruct=None,
454                           forceNewWindow=False):
455        """Create a new local control object and add it to the list.
456
457        Use an imported structure if given or open the file if path is given.
458        Arguments:
459            pathObj -- the path object or file object for the control to open
460            treeStruct -- the imported structure to use
461            forceNewWindow -- if True, use a new window regardless of option
462        """
463        self.creatingLocalControlFlag = True
464        localControl = treelocalcontrol.TreeLocalControl(self.allActions,
465                                                         pathObj, treeStruct,
466                                                         forceNewWindow)
467        localControl.controlActivated.connect(self.updateLocalControlRef)
468        localControl.controlClosed.connect(self.removeLocalControlRef)
469        self.localControls.append(localControl)
470        self.updateLocalControlRef(localControl)
471        self.creatingLocalControlFlag = False
472        localControl.updateRightViews()
473        localControl.updateCommandsAvail()
474
475    def updateLocalControlRef(self, localControl):
476        """Set the given local control as active.
477
478        Called by signal from a window becoming active.
479        Also updates non-modal dialogs.
480        Arguments:
481            localControl -- the new active local control
482        """
483        if localControl != self.activeControl:
484            self.activeControl = localControl
485            if self.configDialog and self.configDialog.isVisible():
486                self.configDialog.setRefs(self.activeControl)
487
488    def removeLocalControlRef(self, localControl):
489        """Remove ref to local control based on a closing signal.
490
491        Also do application exit clean ups if last control closing.
492        Arguments:
493            localControl -- the local control that is closing
494        """
495        try:
496            self.localControls.remove(localControl)
497        except ValueError:
498            return  # skip for unreporducible bug - odd race condition?
499        if globalref.genOptions['SaveTreeStates']:
500            self.recentFiles.saveTreeState(localControl)
501        if not self.localControls and not self.creatingLocalControlFlag:
502            if globalref.genOptions['SaveWindowGeom']:
503                localControl.windowList[0].saveWindowGeom()
504            else:
505                localControl.windowList[0].resetWindowGeom()
506            self.recentFiles.writeItems()
507            localControl.windowList[0].saveToolbarPosition()
508            globalref.histOptions.writeFile()
509            if self.trayIcon:
510                self.trayIcon.hide()
511            # stop listening for session connections
512            try:
513                self.serverSocket.close()
514                del self.serverSocket
515            except AttributeError:
516                pass
517        if self.localControls:
518            # make sure a window is active (may not be focused), to avoid
519            # bugs due to a deleted current window
520            newControl = self.localControls[0]
521            newControl.setActiveWin(newControl.windowList[0])
522        localControl.deleteLater()
523
524    def createTrayIcon(self):
525        """Create a new system tray icon if not already created.
526        """
527        if QSystemTrayIcon.isSystemTrayAvailable:
528            if not self.trayIcon:
529                self.trayIcon = QSystemTrayIcon(qApp.windowIcon(), qApp)
530                self.trayIcon.activated.connect(self.toggleTrayShow)
531            self.trayIcon.show()
532
533    def trayMinimize(self):
534        """Minimize to tray based on window minimize signal.
535        """
536        if self.trayIcon and QSystemTrayIcon.isSystemTrayAvailable:
537            # skip minimize to tray if not all windows minimized
538            for control in self.localControls:
539                for window in control.windowList:
540                    if not window.isMinimized():
541                        return
542            for control in self.localControls:
543                for window in control.windowList:
544                    window.hide()
545            self.isTrayMinimized = True
546
547    def toggleTrayShow(self):
548        """Toggle show and hide application based on system tray icon click.
549        """
550        if self.isTrayMinimized:
551            for control in self.localControls:
552                for window in control.windowList:
553                    window.show()
554                    window.showNormal()
555            self.activeControl.activeWindow.treeView.setFocus()
556        else:
557            for control in self.localControls:
558                for window in control.windowList:
559                    window.hide()
560        self.isTrayMinimized = not self.isTrayMinimized
561
562    def updateConfigDialog(self):
563        """Update the config dialog for changes if it exists.
564        """
565        if self.configDialog:
566            self.configDialog.reset()
567
568    def currentStatusBar(self):
569        """Return the status bar from the current main window.
570        """
571        return self.activeControl.activeWindow.statusBar()
572
573    def windowActions(self):
574        """Return a list of window menu actions from each local control.
575        """
576        actions = []
577        for control in self.localControls:
578            actions.extend(control.windowActions(len(actions) + 1,
579                                                control == self.activeControl))
580        return actions
581
582    def updateActionsAvail(self, oldWidget, newWidget):
583        """Update command availability based on focus changes.
584
585        Arguments:
586            oldWidget -- the previously focused widget
587            newWidget -- the newly focused widget
588        """
589        self.allActions['FormatSelectAll'].setEnabled(hasattr(newWidget,
590                                                              'selectAll') and
591                                                    not hasattr(newWidget,
592                                                               'editTriggers'))
593
594    def setupActions(self):
595        """Add the actions for contols at the global level.
596        """
597        fileNewAct = QAction(_('&New...'), self, toolTip=_('New File'),
598                             statusTip=_('Start a new file'))
599        fileNewAct.triggered.connect(self.fileNew)
600        self.allActions['FileNew'] = fileNewAct
601
602        fileOpenAct = QAction(_('&Open...'), self, toolTip=_('Open File'),
603                              statusTip=_('Open a file from disk'))
604        fileOpenAct.triggered.connect(self.fileOpen)
605        self.allActions['FileOpen'] = fileOpenAct
606
607        fileSampleAct = QAction(_('Open Sa&mple...'), self,
608                                      toolTip=_('Open Sample'),
609                                      statusTip=_('Open a sample file'))
610        fileSampleAct.triggered.connect(self.fileOpenSample)
611        self.allActions['FileOpenSample'] = fileSampleAct
612
613        fileImportAct = QAction(_('&Import...'), self,
614                                      statusTip=_('Open a non-TreeLine file'))
615        fileImportAct.triggered.connect(self.fileImport)
616        self.allActions['FileImport'] = fileImportAct
617
618        fileQuitAct = QAction(_('&Quit'), self,
619                              statusTip=_('Exit the application'))
620        fileQuitAct.triggered.connect(self.fileQuit)
621        self.allActions['FileQuit'] = fileQuitAct
622
623        dataConfigAct = QAction(_('&Configure Data Types...'), self,
624                       statusTip=_('Modify data types, fields & output lines'),
625                       checkable=True)
626        dataConfigAct.triggered.connect(self.dataConfigDialog)
627        self.allActions['DataConfigType'] = dataConfigAct
628
629        dataVisualConfigAct = QAction(_('Show C&onfiguration Structure...'),
630                 self,
631                 statusTip=_('Show read-only visualization of type structure'))
632        dataVisualConfigAct.triggered.connect(self.dataVisualConfig)
633        self.allActions['DataVisualConfig'] = dataVisualConfigAct
634
635        dataSortAct = QAction(_('Sor&t Nodes...'), self,
636                                    statusTip=_('Define node sort operations'),
637                                    checkable=True)
638        dataSortAct.triggered.connect(self.dataSortDialog)
639        self.allActions['DataSortNodes'] = dataSortAct
640
641        dataNumberingAct = QAction(_('Update &Numbering...'), self,
642                                   statusTip=_('Update node numbering fields'),
643                                   checkable=True)
644        dataNumberingAct.triggered.connect(self.dataNumberingDialog)
645        self.allActions['DataNumbering'] = dataNumberingAct
646
647        toolsFindTextAct = QAction(_('&Find Text...'), self,
648                                statusTip=_('Find text in node titles & data'),
649                                checkable=True)
650        toolsFindTextAct.triggered.connect(self.toolsFindTextDialog)
651        self.allActions['ToolsFindText'] = toolsFindTextAct
652
653        toolsFindConditionAct = QAction(_('&Conditional Find...'), self,
654                             statusTip=_('Use field conditions to find nodes'),
655                             checkable=True)
656        toolsFindConditionAct.triggered.connect(self.toolsFindConditionDialog)
657        self.allActions['ToolsFindCondition'] = toolsFindConditionAct
658
659        toolsFindReplaceAct = QAction(_('Find and &Replace...'), self,
660                              statusTip=_('Replace text strings in node data'),
661                              checkable=True)
662        toolsFindReplaceAct.triggered.connect(self.toolsFindReplaceDialog)
663        self.allActions['ToolsFindReplace'] = toolsFindReplaceAct
664
665        toolsFilterTextAct = QAction(_('&Text Filter...'), self,
666                         statusTip=_('Filter nodes to only show text matches'),
667                         checkable=True)
668        toolsFilterTextAct.triggered.connect(self.toolsFilterTextDialog)
669        self.allActions['ToolsFilterText'] = toolsFilterTextAct
670
671        toolsFilterConditionAct = QAction(_('C&onditional Filter...'),
672                           self,
673                           statusTip=_('Use field conditions to filter nodes'),
674                           checkable=True)
675        toolsFilterConditionAct.triggered.connect(self.
676                                                  toolsFilterConditionDialog)
677        self.allActions['ToolsFilterCondition'] = toolsFilterConditionAct
678
679        toolsGenOptionsAct = QAction(_('&General Options...'), self,
680                             statusTip=_('Set user preferences for all files'))
681        toolsGenOptionsAct.triggered.connect(self.toolsGenOptions)
682        self.allActions['ToolsGenOptions'] = toolsGenOptionsAct
683
684        toolsShortcutAct = QAction(_('Set &Keyboard Shortcuts...'), self,
685                                    statusTip=_('Customize keyboard commands'))
686        toolsShortcutAct.triggered.connect(self.toolsCustomShortcuts)
687        self.allActions['ToolsShortcuts'] = toolsShortcutAct
688
689        toolsToolbarAct = QAction(_('C&ustomize Toolbars...'), self,
690                                     statusTip=_('Customize toolbar buttons'))
691        toolsToolbarAct.triggered.connect(self.toolsCustomToolbars)
692        self.allActions['ToolsToolbars'] = toolsToolbarAct
693
694        toolsFontsAct = QAction(_('Customize Fo&nts...'), self,
695                               statusTip=_('Customize fonts in various views'))
696        toolsFontsAct.triggered.connect(self.toolsCustomFonts)
697        self.allActions['ToolsFonts'] = toolsFontsAct
698
699        toolsColorsAct = QAction(_('Custo&mize Colors...'), self,
700                                statusTip=_('Customize GUI colors and themes'))
701        toolsColorsAct.triggered.connect(self.toolsCustomColors)
702        self.allActions['ToolsColors'] = toolsColorsAct
703
704        formatSelectAllAct =  QAction(_('&Select All'), self,
705                                   statusTip=_('Select all text in an editor'))
706        formatSelectAllAct.setEnabled(False)
707        formatSelectAllAct.triggered.connect(self.formatSelectAll)
708        self.allActions['FormatSelectAll'] = formatSelectAllAct
709
710        helpBasicAct = QAction(_('&Basic Usage...'), self,
711                               statusTip=_('Display basic usage instructions'))
712        helpBasicAct.triggered.connect(self.helpViewBasic)
713        self.allActions['HelpBasic'] = helpBasicAct
714
715        helpFullAct = QAction(_('&Full Documentation...'), self,
716                   statusTip=_('Open a TreeLine file with full documentation'))
717        helpFullAct.triggered.connect(self.helpViewFull)
718        self.allActions['HelpFull'] = helpFullAct
719
720        helpAboutAct = QAction(_('&About TreeLine...'), self,
721                        statusTip=_('Display version info about this program'))
722        helpAboutAct.triggered.connect(self.helpAbout)
723        self.allActions['HelpAbout'] = helpAboutAct
724
725        for name, action in self.allActions.items():
726            icon = globalref.toolIcons.getIcon(name.lower())
727            if icon:
728                action.setIcon(icon)
729            key = globalref.keyboardOptions[name]
730            if not key.isEmpty():
731                action.setShortcut(key)
732
733    def fileNew(self):
734        """Start a new blank file.
735        """
736        if (globalref.genOptions['OpenNewWindow'] or
737            self.activeControl.checkSaveChanges()):
738            searchPaths = self.findResourcePaths('templates', templatePath)
739            if searchPaths:
740                dialog = miscdialogs.TemplateFileDialog(_('New File'),
741                                                        _('&Select Template'),
742                                                        searchPaths)
743                if dialog.exec_() == QDialog.Accepted:
744                    self.createLocalControl(dialog.selectedPath())
745                    self.activeControl.filePathObj = None
746                    self.activeControl.updateWindowCaptions()
747                    self.activeControl.expandRootNodes()
748            else:
749                self.createLocalControl()
750            self.activeControl.selectRootSpot()
751
752    def fileOpen(self):
753        """Prompt for a filename and open it.
754        """
755        if (globalref.genOptions['OpenNewWindow'] or
756            self.activeControl.checkSaveChanges()):
757            filters = ';;'.join((globalref.fileFilters['trlnopen'],
758                                 globalref.fileFilters['all']))
759            fileName, selFilter = QFileDialog.getOpenFileName(QApplication.
760                                                activeWindow(),
761                                                _('TreeLine - Open File'),
762                                                str(self.defaultPathObj(True)),
763                                                filters)
764            if fileName:
765                self.openFile(pathlib.Path(fileName))
766
767    def fileOpenSample(self):
768        """Open a sample file from the doc directories.
769        """
770        if (globalref.genOptions['OpenNewWindow'] or
771            self.activeControl.checkSaveChanges()):
772            searchPaths = self.findResourcePaths('samples', samplePath)
773            dialog = miscdialogs.TemplateFileDialog(_('Open Sample File'),
774                                                    _('&Select Sample'),
775                                                    searchPaths, False)
776            if dialog.exec_() == QDialog.Accepted:
777                self.createLocalControl(dialog.selectedPath())
778                name = dialog.selectedName() + '.trln'
779                self.activeControl.filePathObj = pathlib.Path(name)
780                self.activeControl.updateWindowCaptions()
781                self.activeControl.expandRootNodes()
782                self.activeControl.imported = True
783
784    def fileImport(self):
785        """Prompt for an import type, then a file to import.
786        """
787        importControl = imports.ImportControl()
788        structure = importControl.interactiveImport()
789        if structure:
790            self.createLocalControl(importControl.pathObj, structure)
791            if importControl.treeLineRootAttrib:
792                self.activeControl.printData.readData(importControl.
793                                                      treeLineRootAttrib)
794            self.activeControl.imported = True
795
796    def fileQuit(self):
797        """Close all windows to exit the applications.
798        """
799        for control in self.localControls[:]:
800            control.closeWindows()
801
802    def dataConfigDialog(self, show):
803        """Show or hide the non-modal data config dialog.
804
805        Arguments:
806            show -- true if dialog should be shown, false to hide it
807        """
808        if show:
809            if not self.configDialog:
810                self.configDialog = configdialog.ConfigDialog()
811                dataConfigAct = self.allActions['DataConfigType']
812                self.configDialog.dialogShown.connect(dataConfigAct.setChecked)
813            self.configDialog.setRefs(self.activeControl, True)
814            self.configDialog.show()
815        else:
816            self.configDialog.close()
817
818    def dataVisualConfig(self):
819        """Show a TreeLine file to visualize the config structure.
820        """
821        structure = (self.activeControl.structure.treeFormats.
822                     visualConfigStructure(str(self.activeControl.
823                                               filePathObj)))
824        self.createLocalControl(treeStruct=structure, forceNewWindow=True)
825        self.activeControl.filePathObj = pathlib.Path('structure.trln')
826        self.activeControl.updateWindowCaptions()
827        self.activeControl.expandRootNodes()
828        self.activeControl.imported = True
829        win = self.activeControl.activeWindow
830        win.rightTabs.setCurrentWidget(win.outputSplitter)
831
832    def dataSortDialog(self, show):
833        """Show or hide the non-modal data sort nodes dialog.
834
835        Arguments:
836            show -- true if dialog should be shown, false to hide it
837        """
838        if show:
839            if not self.sortDialog:
840                self.sortDialog = miscdialogs.SortDialog()
841                dataSortAct = self.allActions['DataSortNodes']
842                self.sortDialog.dialogShown.connect(dataSortAct.setChecked)
843            self.sortDialog.show()
844        else:
845            self.sortDialog.close()
846
847    def dataNumberingDialog(self, show):
848        """Show or hide the non-modal update node numbering dialog.
849
850        Arguments:
851            show -- true if dialog should be shown, false to hide it
852        """
853        if show:
854            if not self.numberingDialog:
855                self.numberingDialog = miscdialogs.NumberingDialog()
856                dataNumberingAct = self.allActions['DataNumbering']
857                self.numberingDialog.dialogShown.connect(dataNumberingAct.
858                                                         setChecked)
859            self.numberingDialog.show()
860            if not self.numberingDialog.checkForNumberingFields():
861                self.numberingDialog.close()
862        else:
863            self.numberingDialog.close()
864
865    def toolsFindTextDialog(self, show):
866        """Show or hide the non-modal find text dialog.
867
868        Arguments:
869            show -- true if dialog should be shown
870        """
871        if show:
872            if not self.findTextDialog:
873                self.findTextDialog = miscdialogs.FindFilterDialog()
874                toolsFindTextAct = self.allActions['ToolsFindText']
875                self.findTextDialog.dialogShown.connect(toolsFindTextAct.
876                                                        setChecked)
877            self.findTextDialog.selectAllText()
878            self.findTextDialog.show()
879        else:
880            self.findTextDialog.close()
881
882    def toolsFindConditionDialog(self, show):
883        """Show or hide the non-modal conditional find dialog.
884
885        Arguments:
886            show -- true if dialog should be shown
887        """
888        if show:
889            if not self.findConditionDialog:
890                dialogType = conditional.FindDialogType.findDialog
891                self.findConditionDialog = (conditional.
892                                            ConditionDialog(dialogType,
893                                                        _('Conditional Find')))
894                toolsFindConditionAct = self.allActions['ToolsFindCondition']
895                (self.findConditionDialog.dialogShown.
896                 connect(toolsFindConditionAct.setChecked))
897            else:
898                self.findConditionDialog.loadTypeNames()
899            self.findConditionDialog.show()
900        else:
901            self.findConditionDialog.close()
902
903    def toolsFindReplaceDialog(self, show):
904        """Show or hide the non-modal find and replace text dialog.
905
906        Arguments:
907            show -- true if dialog should be shown
908        """
909        if show:
910            if not self.findReplaceDialog:
911                self.findReplaceDialog = miscdialogs.FindReplaceDialog()
912                toolsFindReplaceAct = self.allActions['ToolsFindReplace']
913                self.findReplaceDialog.dialogShown.connect(toolsFindReplaceAct.
914                                                           setChecked)
915            else:
916                self.findReplaceDialog.loadTypeNames()
917            self.findReplaceDialog.show()
918        else:
919            self.findReplaceDialog.close()
920
921    def toolsFilterTextDialog(self, show):
922        """Show or hide the non-modal filter text dialog.
923
924        Arguments:
925            show -- true if dialog should be shown
926        """
927        if show:
928            if not self.filterTextDialog:
929                self.filterTextDialog = miscdialogs.FindFilterDialog(True)
930                toolsFilterTextAct = self.allActions['ToolsFilterText']
931                self.filterTextDialog.dialogShown.connect(toolsFilterTextAct.
932                                                          setChecked)
933            self.filterTextDialog.selectAllText()
934            self.filterTextDialog.show()
935        else:
936            self.filterTextDialog.close()
937
938    def toolsFilterConditionDialog(self, show):
939        """Show or hide the non-modal conditional filter dialog.
940
941        Arguments:
942            show -- true if dialog should be shown
943        """
944        if show:
945            if not self.filterConditionDialog:
946                dialogType = conditional.FindDialogType.filterDialog
947                self.filterConditionDialog = (conditional.
948                                              ConditionDialog(dialogType,
949                                                      _('Conditional Filter')))
950                toolsFilterConditionAct = (self.
951                                           allActions['ToolsFilterCondition'])
952                (self.filterConditionDialog.dialogShown.
953                 connect(toolsFilterConditionAct.setChecked))
954            else:
955                self.filterConditionDialog.loadTypeNames()
956            self.filterConditionDialog.show()
957        else:
958            self.filterConditionDialog.close()
959
960    def toolsGenOptions(self):
961        """Set general user preferences for all files.
962        """
963        oldAutoSaveMinutes = globalref.genOptions['AutoSaveMinutes']
964        dialog = options.OptionDialog(globalref.genOptions,
965                                      QApplication.activeWindow())
966        dialog.setWindowTitle(_('General Options'))
967        if (dialog.exec_() == QDialog.Accepted and
968            globalref.genOptions.modified):
969            globalref.genOptions.writeFile()
970            self.recentFiles.updateOptions()
971            if globalref.genOptions['MinToSysTray']:
972                self.createTrayIcon()
973            elif self.trayIcon:
974                self.trayIcon.hide()
975            autoSaveMinutes = globalref.genOptions['AutoSaveMinutes']
976            for control in self.localControls:
977                for window in control.windowList:
978                    window.updateWinGenOptions()
979                control.structure.undoList.setNumLevels()
980                control.updateAll(False)
981                if autoSaveMinutes != oldAutoSaveMinutes:
982                    control.resetAutoSave()
983
984    def toolsCustomShortcuts(self):
985        """Show dialog to customize keyboard commands.
986        """
987        actions = self.activeControl.activeWindow.allActions
988        dialog = miscdialogs.CustomShortcutsDialog(actions, QApplication.
989                                                   activeWindow())
990        dialog.exec_()
991
992    def toolsCustomToolbars(self):
993        """Show dialog to customize toolbar buttons.
994        """
995        actions = self.activeControl.activeWindow.allActions
996        dialog = miscdialogs.CustomToolbarDialog(actions, self.updateToolbars,
997                                                 QApplication.
998                                                 activeWindow())
999        dialog.exec_()
1000
1001    def updateToolbars(self):
1002        """Update toolbars after changes in custom toolbar dialog.
1003        """
1004        for control in self.localControls:
1005            for window in control.windowList:
1006                window.setupToolbars()
1007
1008    def toolsCustomFonts(self):
1009        """Show dialog to customize fonts in various views.
1010        """
1011        dialog = miscdialogs.CustomFontDialog(QApplication.
1012                                              activeWindow())
1013        dialog.updateRequired.connect(self.updateCustomFonts)
1014        dialog.exec_()
1015
1016    def toolsCustomColors(self):
1017        """Show dialog to customize GUI colors ans themes.
1018        """
1019        self.colorSet.showDialog(QApplication.activeWindow())
1020
1021    def updateCustomFonts(self):
1022        """Update fonts in all windows based on a dialog signal.
1023        """
1024        self.updateAppFont()
1025        for control in self.localControls:
1026            for window in control.windowList:
1027                window.updateFonts()
1028            control.printData.setDefaultFont()
1029        for control in self.localControls:
1030            control.updateAll(False)
1031
1032    def updateAppFont(self):
1033        """Update application default font from settings.
1034        """
1035        appFont = QFont(self.systemFont)
1036        appFontName = globalref.miscOptions['AppFont']
1037        if appFontName:
1038            appFont.fromString(appFontName)
1039        QApplication.setFont(appFont)
1040
1041    def formatSelectAll(self):
1042        """Select all text in any currently focused editor.
1043        """
1044        try:
1045            QApplication.focusWidget().selectAll()
1046        except AttributeError:
1047            pass
1048
1049    def helpViewBasic(self):
1050        """Display basic usage instructions.
1051        """
1052        if not self.basicHelpView:
1053            path = self.findResourceFile('basichelp.html', 'doc', docPath)
1054            if not path:
1055                QMessageBox.warning(QApplication.activeWindow(), 'TreeLine',
1056                                    _('Error - basic help file not found'))
1057                return
1058            self.basicHelpView = helpview.HelpView(path,
1059                                                   _('TreeLine Basic Usage'),
1060                                                   globalref.toolIcons)
1061        self.basicHelpView.show()
1062
1063    def helpViewFull(self):
1064        """Open a TreeLine file with full documentation.
1065        """
1066        path = self.findResourceFile('documentation.trln', 'doc', docPath)
1067        if not path:
1068            QMessageBox.warning(QApplication.activeWindow(), 'TreeLine',
1069                                _('Error - documentation file not found'))
1070            return
1071        self.createLocalControl(path, forceNewWindow=True)
1072        self.activeControl.filePathObj = pathlib.Path('documentation.trln')
1073        self.activeControl.updateWindowCaptions()
1074        self.activeControl.expandRootNodes()
1075        self.activeControl.imported = True
1076        win = self.activeControl.activeWindow
1077        win.rightTabs.setCurrentWidget(win.outputSplitter)
1078
1079    def helpAbout(self):
1080        """ Display version info about this program.
1081        """
1082        pyVersion = '.'.join([repr(num) for num in sys.version_info[:3]])
1083        textLines = [_('TreeLine version {0}').format(__version__),
1084                     _('written by {0}').format(__author__), '',
1085                     _('Library versions:'),
1086                     '   Python:  {0}'.format(pyVersion),
1087                     '   Qt:  {0}'.format(qVersion()),
1088                     '   PyQt:  {0}'.format(PYQT_VERSION_STR),
1089                     '   OS:  {0}'.format(platform.platform())]
1090        dialog = miscdialogs.AboutDialog('TreeLine', textLines,
1091                                         QApplication.windowIcon(),
1092                                         QApplication.activeWindow())
1093        dialog.exec_()
1094
1095
1096def setThemeColors():
1097    """Set the app colors based on options setting.
1098    """
1099    if globalref.genOptions['ColorTheme'] == optiondefaults.colorThemes[1]:
1100        # dark theme
1101        myDarkGray = QColor(53, 53, 53)
1102        myVeryDarkGray = QColor(25, 25, 25)
1103        myBlue = QColor(42, 130, 218)
1104        palette = QPalette()
1105        palette.setColor(QPalette.Window, myDarkGray)
1106        palette.setColor(QPalette.WindowText, Qt.white)
1107        palette.setColor(QPalette.Base, myVeryDarkGray)
1108        palette.setColor(QPalette.AlternateBase, myDarkGray)
1109        palette.setColor(QPalette.ToolTipBase, Qt.darkBlue)
1110        palette.setColor(QPalette.ToolTipText, Qt.lightGray)
1111        palette.setColor(QPalette.Text, Qt.white)
1112        palette.setColor(QPalette.Button, myDarkGray)
1113        palette.setColor(QPalette.ButtonText, Qt.white)
1114        palette.setColor(QPalette.BrightText, Qt.red)
1115        palette.setColor(QPalette.Link, myBlue)
1116        palette.setColor(QPalette.Highlight, myBlue)
1117        palette.setColor(QPalette.HighlightedText, Qt.black)
1118        palette.setColor(QPalette.Disabled, QPalette.Text, Qt.darkGray)
1119        palette.setColor(QPalette.Disabled, QPalette.ButtonText, Qt.darkGray)
1120        qApp.setPalette(palette)
1121