diff --git a/.gitignore b/.gitignore index 2f93aff..d243eb1 100644 --- a/.gitignore +++ b/.gitignore @@ -198,3 +198,6 @@ com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties + +# VSCode +.vscode/ diff --git a/docs/installation.md b/docs/installation.md index 0516ade..f9647ea 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -47,8 +47,14 @@ and this will depend on your setup.py install settings. ### Mac (OSX) +#### Installation +1. Launch Terminal +2. ```cd``` to the downloaded usdmanager folder (you should see a setup.py file in here). +3. Customize usdmanager/config.json if needed. +4. Run ```python setup.py install``` (may need to prepend the command with ```sudo``` and/or add the ```--user``` flag) +5. Depending on where you installed it (e.g. /Users/username/Library/Python/3.7/bin), update your $PATH to include the relevant bin directory by editing /etc/paths or ~/.zshrc. + #### Known Issues -- User preferences may not preserve between sessions. - Since this is not installed as an entirely self-contained package, the application name (and icon) will by Python, not USD Manager. ### Windows @@ -56,7 +62,8 @@ and this will depend on your setup.py install settings. #### Installation 1. Launch Command Prompt 2. ```cd``` to the downloaded usdmanager folder (you should see a setup.py file in here). -3. Run ```python setup.py install``` (may need the ```--user``` flag) +3. Customize usdmanager/config.json if needed. +4. Run ```python setup.py install``` (may need the ```--user``` flag) If setup.py complains about missing setuptools, you can install it via pip. If you installed a new enough python-2 version, pip should already be handled for you, but you may still need to add it to your PATH. pip should already live somewhere like this (C:\Python27\Scripts\pip.exe), and you can permanently add it to your environment with: ```setx PATH "%PATH%;C:\Python27\Scripts"``` diff --git a/usdmanager/__init__.py b/usdmanager/__init__.py old mode 100755 new mode 100644 index e73dbe2..f40459a --- a/usdmanager/__init__.py +++ b/usdmanager/__init__.py @@ -29,10 +29,14 @@ - TabBar - BrowserTab (one tab per file) - - LineNumbers - TextBrowser + + - LineNumbers + - TextEdit + - PlainTextLineNumbers + """ from __future__ import absolute_import, division, print_function @@ -49,6 +53,7 @@ import sys import tempfile import traceback +import warnings from contextlib import contextmanager from functools import partial from glob import glob @@ -71,12 +76,12 @@ from . import highlighter, images_rc, utils from .constants import ( - LINE_LIMIT, FILE_FILTER, FILE_FORMAT_NONE, FILE_FORMAT_USD, FILE_FORMAT_USDA, - FILE_FORMAT_USDC, FILE_FORMAT_USDZ, HTML_BODY, RECENT_FILES, RECENT_TABS, USD_EXTS) + LINE_LIMIT, FILE_FILTER, FILE_FORMAT_NONE, FILE_FORMAT_USD, FILE_FORMAT_USDA, FILE_FORMAT_USDC, FILE_FORMAT_USDZ, + HTML_BODY, RECENT_FILES, RECENT_TABS, USD_AMBIGUOUS_EXTS, USD_ASCII_EXTS, USD_CRATE_EXTS, USD_ZIP_EXTS, USD_EXTS) from .file_dialog import FileDialog from .file_status import FileStatus from .find_dialog import FindDialog -from .linenumbers import LineNumbers +from .linenumbers import LineNumbers, PlainTextLineNumbers from .include_panel import IncludePanel from .parser import FileParser, AbstractExtParser from .plugins import images_rc as plugins_rc @@ -89,12 +94,29 @@ logging.basicConfig() -# Qt.py compatibility: HACK for missing QUrl.path in PySide2 build. -if Qt.IsPySide2 and not hasattr(QtCore.QUrl, "path"): - def qUrlPath(self): - return self.toString(QtCore.QUrl.PrettyDecoded | QtCore.QUrl.RemoveQuery) +# Qt.py compatibility. +if Qt.IsPySide2: + # Add QUrl.path missing in PySide2 build. + if not hasattr(QtCore.QUrl, "path"): + def qUrlPath(self): + return self.toString(QtCore.QUrl.PrettyDecoded | QtCore.QUrl.RemoveQuery) + + QtCore.QUrl.path = qUrlPath +elif Qt.IsPySide or Qt.IsPyQt4: + # Add basic support for QUrl.setQuery to PySide and PyQt4 (added in Qt5). + def qUrlSetQuery(self, query, mode=QtCore.QUrl.TolerantMode): + """ + :Parameters: + query : `str` + Query string (without the leading '?' character) + mode : `QtCore.QUrl.ParsingMode` + Ignored for now. For Qt5 method signature compatibility only. + """ + return self.setQueryItems( + [x.split(self.queryValueDelimiter(), 1) for x in query.split(self.queryPairDelimiter())] + ) - QtCore.QUrl.path = qUrlPath + QtCore.QUrl.setQuery = qUrlSetQuery class UsdMngrWindow(QtWidgets.QMainWindow): @@ -133,6 +155,10 @@ class UsdMngrWindow(QtWidgets.QMainWindow): - AddressBar file completer has problems occasionally. - Figure out why network printers aren't showing up. Linux or DWA issue? macOS and Windows are fine. - Save As... doesn't add file to recent files or history menus. + - Find with text containing a new line character does not work due to QTextDocument storing these as separate + blocks. + - Line numbers width not always immediately updated after switching to new class. + - If app temp dir is removed while open and later tries to use it, it fails. - Qt.py problems: - PyQt5 @@ -463,8 +489,7 @@ def setupUi(self): self.statusbar.addWidget(self.loadingProgressLabel) # Add one of our special tabs. - self.newTab() - self.currTab = self.tabWidget.currentWidget() + self.currTab = self.newTab() self.setNavigationMenus() # Adjust tab order. @@ -487,6 +512,12 @@ def setupUi(self): # OSX likes to add its own Enter/Exit Full Screen item, not recognizing we already have one. self.actionFullScreen.setEnabled(False) self.menuView.removeAction(self.actionFullScreen) + + # Make things look more cohesive on Mac (Qt5). + try: + self.setUnifiedTitleAndToolBarOnMac(True) + except AttributeError: + pass def createHighlighter(self, highlighterClass): """ Create a language-specific master highlighter to be used for any file of that language. @@ -504,21 +535,24 @@ def createHighlighter(self, highlighterClass): self.masterHighlighters[ext] = h return h - def setHighlighter(self, ext=None): + def setHighlighter(self, ext=None, tab=None): """ Set the current tab's highlighter based on the current file extension. :Parameters: ext : `str` | None File extension (language) to highlight. + tab : `BrowserTab` | None + Tab to set highlighter on. Defaults to current tab. """ if ext not in self.masterHighlighters: logger.debug("Using default highlighter") ext = None master = self.masterHighlighters[ext] - if type(self.currTab.highlighter.master) is not master: + tab = tab or self.currTab + if type(tab.highlighter.master) is not master: logger.debug("Setting highlighter to %s", ext) - self.currTab.highlighter.deleteLater() - self.currTab.highlighter = highlighter.Highlighter(self.currTab.getCurrentTextWidget().document(), master) + tab.highlighter.deleteLater() + tab.highlighter = highlighter.Highlighter(tab.getCurrentTextWidget().document(), master) @Slot(QtCore.QPoint) def customTextBrowserContextMenu(self, pos): @@ -533,7 +567,7 @@ def customTextBrowserContextMenu(self, pos): # Right now, you may see the open in new tab action even if you aren't # hovering over a link. Ideally, because of imperfection with the hovering # signal, we would check if the cursor is hovering over a link here. - if self.linkHighlighted.toString(): + if self.linkHighlighted.isValid(): menu.insertAction(actions[0], self.actionOpenLinkNewWindow) menu.insertAction(actions[0], self.actionOpenLinkNewTab) menu.insertAction(actions[0], self.actionOpenLinkWith) @@ -611,6 +645,11 @@ def customTabWidgetContextMenu(self, pos): self.contextMenuPos = self.tabWidget.tabBar.mapFromParent(pos) indexOfClickedTab = self.tabWidget.tabBar.tabAt(self.contextMenuPos) + + # Save the original state so we don't mess with the menu action, since this one action is re-used. + # TODO: Maybe make a new action instead of reusing this. + state = self.actionCloseTab.isEnabled() + if indexOfClickedTab == -1: self.actionCloseTab.setEnabled(False) self.actionCloseOther.setEnabled(False) @@ -627,6 +666,9 @@ def customTabWidgetContextMenu(self, pos): menu.exec_(self.tabWidget.mapToGlobal(pos)) del menu self.contextMenuPos = None + + # Restore previous action state. + self.actionCloseTab.setEnabled(state) def readSettings(self): """ Read in user config settings. @@ -875,20 +917,19 @@ def connectSignals(self): self.buttonFindPrev.clicked.connect(self.findPrev) self.buttonFindNext.clicked.connect(self.find) self.buttonHighlightAll.clicked.connect(self.findHighlightAll) - self.checkBoxMatchCase.stateChanged[int].connect(self.updatePreference_findMatchCase) + self.checkBoxMatchCase.clicked.connect(self.updatePreference_findMatchCase) def closeEvent(self, event): """ Override the default closeEvent called on exit. """ # Check if we want to save any dirty tabs. self.quitting = True - for i in range(self.tabWidget.count()): - self.tabWidget.setCurrentIndex(0) - if not self.closeTab(): + for _ in range(self.tabWidget.count()): + if not self.closeTab(index=0): # Don't quit. event.ignore() self.quitting = False - self.findHighlightAll() + self.findRehighlightAll() return # Ok to quit. @@ -911,13 +952,18 @@ def newWindow(self): :Returns: New main window widget :Rtype: - `QtGui.QWidget` + `QtWidgets.QWidget` """ return self.app.newWindow() @Slot(bool) def newTab(self, *args): """ Create a new tab. + + :Returns: + New tab + :Rtype: + `BrowserTab` """ newTab = BrowserTab(self.tabWidget) newTab.highlighter = highlighter.Highlighter(newTab.getCurrentTextWidget().document(), @@ -930,12 +976,13 @@ def newTab(self, *args): # Add to menu of tabs. self.menuTabList.addAction(newTab.action) self.connectTabSignals(newTab) + return newTab def connectTabSignals(self, tab): """ Connect signals for a new tab. :Parameters: - tab : `TabWidget` + tab : `BrowserTab` Tab widget """ # Keep in sync with signals in disconnectTabSignals. @@ -948,7 +995,7 @@ def connectTabSignals(self, tab): tab.textBrowser.customContextMenuRequested.connect(self.customTextBrowserContextMenu) tab.textEditor.customContextMenuRequested.connect(self.customTextEditorContextMenu) tab.textBrowser.copyAvailable.connect(self.actionCopy.setEnabled) - tab.textEditor.document().modificationChanged.connect(self.setDirtyTab) + tab.tabNameChanged.connect(self._changeTabName) tab.textEditor.undoAvailable.connect(self.actionUndo.setEnabled) tab.textEditor.redoAvailable.connect(self.actionRedo.setEnabled) tab.textEditor.copyAvailable.connect(self.actionCopy.setEnabled) @@ -958,7 +1005,7 @@ def disconnectTabSignals(self, tab): """ Disconnect signals for a tab. :Parameters: - tab : `TabWidget` + tab : `BrowserTab` Tab widget """ # Keep in sync with signals in connectTabSignals. @@ -971,20 +1018,23 @@ def disconnectTabSignals(self, tab): tab.textBrowser.customContextMenuRequested.disconnect(self.customTextBrowserContextMenu) tab.textEditor.customContextMenuRequested.disconnect(self.customTextEditorContextMenu) tab.textBrowser.copyAvailable.disconnect(self.actionCopy.setEnabled) - tab.textEditor.document().modificationChanged.disconnect(self.setDirtyTab) + tab.tabNameChanged.disconnect(self._changeTabName) tab.textEditor.undoAvailable.disconnect(self.actionUndo.setEnabled) tab.textEditor.redoAvailable.disconnect(self.actionRedo.setEnabled) tab.textEditor.copyAvailable.disconnect(self.actionCopy.setEnabled) tab.textEditor.copyAvailable.disconnect(self.actionCut.setEnabled) - def openFileDialog(self, path=None): + def openFileDialog(self, path=None, tab=None): """ Show the Open File dialog and open any selected files. :Parameters: path : `str` | None File path to pre-select on open + tab : `BrowserTab` | None + Tab to open files for. Defaults to current tab. """ - startFilter = FILE_FILTER[self.currTab.fileFormat] + tab = tab or self.currTab + startFilter = FILE_FILTER[tab.fileFormat] fd = FileDialog(self, "Open File(s)", self.lastOpenFileDir, FILE_FILTER, startFilter, self.preferences['showHiddenFiles']) fd.setFileMode(fd.ExistingFiles) @@ -994,7 +1044,7 @@ def openFileDialog(self, path=None): paths = fd.selectedFiles() if paths: self.lastOpenFileDir = QtCore.QFileInfo(paths[0]).absoluteDir().path() - self.setSources(paths) + self.setSources(paths, tab=tab) @Slot() def openFileDialogToCurrentPath(self): @@ -1012,7 +1062,7 @@ def openRecent(self, url): """ self.setSource(url, newTab=True) - def saveFile(self, filePath, fileFormat=FILE_FORMAT_NONE, _checkUsd=True): + def saveFile(self, filePath, fileFormat=FILE_FORMAT_NONE, tab=None, _checkUsd=True): """ Save the current file as the given filePath. :Parameters: @@ -1020,6 +1070,8 @@ def saveFile(self, filePath, fileFormat=FILE_FORMAT_NONE, _checkUsd=True): Path to save file as. fileFormat : `int` File format when saving as a generic extension + tab : `BrowserTab` | None + Tab to save. Defaults to current tab. _checkUsd : `bool` Check if this needs to be written as a binary USD file instead of a text file :Returns: @@ -1034,21 +1086,22 @@ def saveFile(self, filePath, fileFormat=FILE_FORMAT_NONE, _checkUsd=True): return False logger.debug("Writing file") self.setOverrideCursor() + tab = tab or self.currTab # If the file is originally a usd crate file or the user is saving it with the .usdc extension, or the user is # saving it with .usd but fileFormat is set to usdc, save to a temp file then usdcat back to a binary file. crate = False _, ext = os.path.splitext(filePath) if _checkUsd: - if ext == ".usdc": + if ext[1:] in USD_CRATE_EXTS: crate = True - elif ext == ".usd" and (fileFormat == FILE_FORMAT_USDC or (fileFormat == FILE_FORMAT_NONE and self.currTab.fileFormat == FILE_FORMAT_USDC)): + elif ext[1:] in USD_AMBIGUOUS_EXTS and (fileFormat == FILE_FORMAT_USDC or (fileFormat == FILE_FORMAT_NONE and tab.fileFormat == FILE_FORMAT_USDC)): crate = True if crate: - fd, tmpPath = tempfile.mkstemp(suffix=".usd", dir=self.app.tmpDir) + fd, tmpPath = tempfile.mkstemp(suffix="." + USD_AMBIGUOUS_EXTS[0], dir=self.app.tmpDir) os.close(fd) status = False - if self.saveFile(tmpPath, fileFormat, False): + if self.saveFile(tmpPath, fileFormat, tab=tab, _checkUsd=False): try: logger.debug("Converting back to USD crate file") utils.usdcat(tmpPath, QtCore.QDir.toNativeSeparators(filePath), format="usdc") @@ -1058,7 +1111,7 @@ def saveFile(self, filePath, fileFormat=FILE_FORMAT_NONE, _checkUsd=True): self.showCriticalMessage("The file could not be saved due to a usdcat error!", traceback.format_exc(), "Save File") else: status = True - self.currTab.fileFormat = FILE_FORMAT_USDC + tab.fileFormat = FILE_FORMAT_USDC self.restoreOverrideCursor() QtCore.QTimer.singleShot(10, partial(self.fileSystemWatcher.addPath, filePath)) else: @@ -1075,16 +1128,16 @@ def saveFile(self, filePath, fileFormat=FILE_FORMAT_NONE, _checkUsd=True): self.fileSystemWatcher.removePath(filePath) try: out = QtCore.QTextStream(path) - out << self.currTab.textEditor.toPlainText() + out << tab.textEditor.toPlainText() except Exception: self.restoreOverrideCursor() self.showCriticalMessage("The file could not be saved!", traceback.format_exc(), "Save File") return False else: - if ext in [".usd", ".usda"]: - self.currTab.fileFormat = FILE_FORMAT_USDA + if ext[1:] in USD_AMBIGUOUS_EXTS + USD_ASCII_EXTS: + tab.fileFormat = FILE_FORMAT_USDA else: - self.currTab.fileFormat = FILE_FORMAT_NONE + tab.fileFormat = FILE_FORMAT_NONE self.restoreOverrideCursor() finally: path.close() @@ -1095,19 +1148,21 @@ def saveFile(self, filePath, fileFormat=FILE_FORMAT_NONE, _checkUsd=True): if _checkUsd: QtCore.QTimer.singleShot(10, partial(self.fileSystemWatcher.addPath, filePath)) - self.setDirtyTab(False) + tab.setDirty(False) return True else: self.restoreOverrideCursor() self.showCriticalMessage("The file could not be opened for saving!", title="Save File") return False - def getSaveAsPath(self, path=None): + def getSaveAsPath(self, path=None, tab=None): """ Get a path from the user to save an arbitrary file as. :Parameters: path : `str` | None Path to use for selecting default file extension filter. + tab : `BrowserTab` | None + Tab that path is for. :Returns: Tuple of the absolute path user wants to save file as (or None if no file was selected or an error occurred) and the file format if explicitly set for USD files (e.g. usda) @@ -1118,8 +1173,9 @@ def getSaveAsPath(self, path=None): if path: startFilter = FILE_FILTER[FILE_FORMAT_USD if utils.isUsdFile(path) else FILE_FORMAT_NONE] else: - path = self.currTab.getCurrentPath() - startFilter = FILE_FILTER[self.currTab.fileFormat] + tab = tab or self.currTab + path = tab.getCurrentPath() + startFilter = FILE_FILTER[tab.fileFormat] dlg = FileDialog(self, "Save File As", path or self.lastOpenFileDir, FILE_FILTER, startFilter, self.preferences['showHiddenFiles']) @@ -1135,41 +1191,46 @@ def getSaveAsPath(self, path=None): filePath = filePaths[0] selectedFilter = dlg.selectedNameFilter() - # TODO: Is there a more generic way to enforce this? modifiedExt = False validExts = [x.lstrip("*") for x in selectedFilter.rsplit("(", 1)[1].rsplit(")", 1)[0].split()] _, ext = os.path.splitext(filePath) if selectedFilter == FILE_FILTER[FILE_FORMAT_USD]: - if ext not in validExts: - self.showCriticalMessage("Please enter a valid extension for a usd file") - return self.getSaveAsPath(filePath) - if ext == ".usda": + if ext[1:] in USD_AMBIGUOUS_EXTS + USD_ASCII_EXTS: + # Default .usd to ASCII for now. + # TODO: Make that a user preference? usdcat defaults .usd to usdc. fileFormat = FILE_FORMAT_USDA - elif ext == ".usdc": + elif ext[1:] in USD_CRATE_EXTS: fileFormat = FILE_FORMAT_USDC - elif ext == ".usdz": + elif ext[1:] in USD_ZIP_EXTS: fileFormat = FILE_FORMAT_USDZ + else: + self.showCriticalMessage("Please enter a valid extension for a usd file") + return self.getSaveAsPath(filePath, tab) elif selectedFilter == FILE_FILTER[FILE_FORMAT_USDA]: fileFormat = FILE_FORMAT_USDA if ext not in validExts: self.showCriticalMessage("Please enter a valid extension for a usda file") - return self.getSaveAsPath(filePath) + return self.getSaveAsPath(filePath, tab) elif selectedFilter == FILE_FILTER[FILE_FORMAT_USDC]: fileFormat = FILE_FORMAT_USDC if ext not in validExts: self.showCriticalMessage("Please enter a valid extension for a usdc file") - return self.getSaveAsPath(filePath) + return self.getSaveAsPath(filePath, tab) elif selectedFilter == FILE_FILTER[FILE_FORMAT_USDZ]: fileFormat = FILE_FORMAT_USDZ if ext not in validExts: - # Sanity check in case we ever allow more extensions. if len(validExts) == 1: - # Just add the .usdz extension since it can't be anything else. - filePath += ".usdz" + # Just add the extension since it can't be anything else. + filePath += "." + validExts[0] modifiedExt = True else: + # Fallback in case we ever allow more extensions. self.showCriticalMessage("Please enter a valid extension for a usdz file") - return self.getSaveAsPath(filePath) + return self.getSaveAsPath(filePath, tab) + elif len(validExts) == 1 and ext not in validExts: + # Just add the extension since it can't be anything else. + filePath += "." + validExts[0] + modifiedExt = True info = QtCore.QFileInfo(filePath) self.lastOpenFileDir = info.absoluteDir().path() @@ -1183,42 +1244,47 @@ def getSaveAsPath(self, path=None): QtWidgets.QMessageBox.Save | QtWidgets.QMessageBox.Cancel) if dlg != QtWidgets.QMessageBox.Save: # Re-open this dialog to get a new path. - return self.getSaveAsPath(path) + return self.getSaveAsPath(path, tab) # Now we have a valid path to save as. return filePath, fileFormat @Slot() - def saveFileAs(self): + def saveFileAs(self, tab=None): """ Save the current file with a new filename. + :Parameters: + tab : `BrowserTab` | None + Tab to save. Defaults to current tab. :Returns: If saved or not. :Rtype: `bool` """ - filePath, fileFormat = self.getSaveAsPath() + tab = tab or self.currTab + filePath, fileFormat = self.getSaveAsPath(tab=tab) if filePath is not None: # Save file and apply new name where needed. - if self.saveFile(filePath, fileFormat): - idx = self.tabWidget.currentIndex() + if self.saveFile(filePath, fileFormat, tab=tab): + idx = self.tabWidget.indexOf(tab) fileInfo = QtCore.QFileInfo(filePath) fileName = fileInfo.fileName() ext = fileInfo.suffix() self.tabWidget.setTabText(idx, fileName) - if self.currTab.fileFormat == FILE_FORMAT_USDC: + if tab.fileFormat == FILE_FORMAT_USDC: self.tabWidget.setTabIcon(idx, self.binaryIcon) self.tabWidget.setTabToolTip(idx, "{} - {} (binary)".format(fileName, filePath)) - elif self.currTab.fileFormat == FILE_FORMAT_USDZ: + elif tab.fileFormat == FILE_FORMAT_USDZ: self.tabWidget.setTabIcon(idx, self.zipIcon) self.tabWidget.setTabToolTip(idx, "{} - {} (zip)".format(fileName, filePath)) else: self.tabWidget.setTabIcon(idx, QtGui.QIcon()) self.tabWidget.setTabToolTip(idx, "{} - {}".format(fileName, filePath)) - self.currTab.updateHistory(QtCore.QUrl.fromLocalFile(filePath)) - self.currTab.updateFileStatus() - self.setHighlighter(ext) - self.updateButtons() + tab.updateHistory(QtCore.QUrl.fromLocalFile(filePath)) + tab.updateFileStatus() + self.setHighlighter(ext, tab=tab) + if tab == self.currTab: + self.updateButtons() return True return False @@ -1255,18 +1321,22 @@ def saveLinkAs(self): self.showWarningMessage("Selected file does not exist.") @Slot() - def saveTab(self): + def saveTab(self, tab=None): """ If the file already has a name, save it; otherwise, get a filename and save it. + :Parameters: + tab : `BrowserTab` | None + Tab to save. Defaults to current tab. :Returns: If saved or not. :Rtype: `bool` """ - filePath = self.currTab.getCurrentPath() + tab = tab or self.currTab + filePath = tab.getCurrentPath() if filePath: - return self.saveFile(filePath) - return self.saveFileAs() + return self.saveFile(filePath, tab=tab) + return self.saveFileAs(tab=tab) @Slot(bool) def printDialog(self, checked=False): @@ -1352,7 +1422,7 @@ def closeTab(self, checked=False, index=None): self.removeTab(self.tabWidget.currentIndex()) # Switch back to the previous tab. if prevTab is not None: - self.tabWidget.setCurrentWidget(prevTab) + self.changeTab(prevTab) return True @Slot(bool) @@ -1478,7 +1548,7 @@ def moveTabAcrossWindows(self, fromIndex, toIndex, fromWindow, toWindow): # Use the new window's syntax highlighter. fileInfo = QtCore.QFileInfo(tab.getCurrentPath()) ext = fileInfo.suffix() - dstWindow.setHighlighter(ext) + dstWindow.setHighlighter(ext, tab=tab) def removeTab(self, index): """ Stores as recently closed tab, then closes it. @@ -1517,7 +1587,8 @@ def removeTab(self, index): """ @Slot() - def toggleEdit(self): + # TODO: Test this doesn't get "False" as the first param from using triggered signal on an action. Probably need to test on Mac. + def toggleEdit(self, tab=None): """ Switch between Browse mode and Edit mode. :Returns: @@ -1526,48 +1597,52 @@ def toggleEdit(self): :Rtype: `bool` """ + logger.debug("toggleEdit: %s", tab) # TEMP for TODO + tab = tab or self.currTab + # Don't change between browse and edit mode if dirty. Saves if needed. - if not self.dirtySave(): + if not self.dirtySave(tab=tab): return False refreshed = False # Toggle edit mode - self.currTab.inEditMode = not self.currTab.inEditMode - if self.currTab.inEditMode: + tab.inEditMode = not tab.inEditMode + if tab.inEditMode: # Set editor's scroll position to browser's position. - hScrollPos = self.currTab.textBrowser.horizontalScrollBar().value() - vScrollPos = self.currTab.textBrowser.verticalScrollBar().value() - self.currTab.textBrowser.setVisible(False) - self.currTab.textEditor.setVisible(True) - self.currTab.lineNumbers.setTextWidget(self.currTab.textEditor) - self.currTab.textEditor.setFocus() - self.currTab.textEditor.horizontalScrollBar().setValue(hScrollPos) - self.currTab.textEditor.verticalScrollBar().setValue(vScrollPos) + hScrollPos = tab.textBrowser.horizontalScrollBar().value() + vScrollPos = tab.textBrowser.verticalScrollBar().value() + tab.textBrowser.setVisible(False) + tab.textEditor.setVisible(True) + tab.textEditor.setFocus() + tab.textEditor.horizontalScrollBar().setValue(hScrollPos) + tab.textEditor.verticalScrollBar().setValue(vScrollPos) else: # Set browser's scroll position to editor's position. - hScrollPos = self.currTab.textEditor.horizontalScrollBar().value() - vScrollPos = self.currTab.textEditor.verticalScrollBar().value() + hScrollPos = tab.textEditor.horizontalScrollBar().value() + vScrollPos = tab.textEditor.verticalScrollBar().value() # TODO: If we edited the file (or it changed on disk since we loaded it, even if the user ignored the # prompt to reload it?), make sure we reload it in the browser tab. Currently, we just always reload it to # be safe, but this can be slow. - refreshed = self.refreshTab() + refreshed = self.refreshTab(tab=tab) - self.currTab.textEditor.setVisible(False) - self.currTab.textBrowser.setVisible(True) - self.currTab.lineNumbers.setTextWidget(self.currTab.textBrowser) - self.currTab.textBrowser.setFocus() - self.currTab.textBrowser.horizontalScrollBar().setValue(hScrollPos) - self.currTab.textBrowser.verticalScrollBar().setValue(vScrollPos) + tab.textEditor.setVisible(False) + tab.textBrowser.setVisible(True) + tab.textBrowser.setFocus() + tab.textBrowser.horizontalScrollBar().setValue(hScrollPos) + tab.textBrowser.verticalScrollBar().setValue(vScrollPos) # Don't double-up the below commands if we already refreshed the tab. if not refreshed: # Update highlighter. - self.currTab.highlighter.setDocument(self.currTab.getCurrentTextWidget().document()) - self.updateEditButtons() + tab.highlighter.setDocument(tab.getCurrentTextWidget().document()) + if tab == self.currTab: + self.updateEditButtons() - self.editModeChanged.emit(self.currTab.inEditMode) + self.findRehighlightAll() + if tab == self.currTab: + self.editModeChanged.emit(tab.inEditMode) return True @Slot() @@ -1624,6 +1699,15 @@ def toggleFind(self): self.findWidget.setVisible(True) self.findBar.selectAll() self.findBar.setFocus() + + # Pre-populate the Find field with the current selection, if any. + text = self.currTab.getCurrentTextWidget().textCursor().selectedText() + if text: + # Currently, find doesn't work with line breaks, so use the last line that contains any text. + text = [x for x in text.split(u'\u2029') if x][-1] + self.findBar.setText(text) + self.findBar.selectAll() + self.validateFindBar(text) @Slot() def toggleFindClose(self): @@ -1710,18 +1794,45 @@ def findPrev(self): """ self.find(flags=QtGui.QTextDocument.FindBackward, startPos=2) - @Slot() - def findHighlightAll(self): + @Slot(bool) + def findHighlightAll(self, checked=True): """ Highlight all hits for the search text. + + :Parameters: + checked : `bool` + If True, highlight all occurrences of the current find phrase. """ - phrase = self.findBar.text() if self.buttonHighlightAll.isChecked() else highlighter.DONT_MATCH_PHRASE - if phrase != self.masterHighlighters[None].findPhrase: - for lang, h in self.masterHighlighters.iteritems(): - h.setFindPhrase(phrase) - if self.currTab.highlighter.dirty: - with self.overrideCursor(): - self.currTab.highlighter.rehighlight() + findText = self.findBar.text() + textWidget = self.currTab.getCurrentTextWidget() + extras = [x for x in textWidget.extraSelections() if + x.format.property(QtGui.QTextFormat.UserProperty) != "find"] + if checked and findText: + flags = QtGui.QTextDocument.FindFlags() + if self.preferences['findMatchCase']: + flags |= QtGui.QTextDocument.FindCaseSensitively + doc = textWidget.document() + cursor = QtGui.QTextCursor(doc) + lineColor = QtGui.QColor(QtCore.Qt.yellow) + count = 0 + while True: + cursor = doc.find(findText, cursor, flags) + if cursor.isNull(): + break + selection = QtWidgets.QTextEdit.ExtraSelection() + selection.format.setBackground(lineColor) + selection.format.setProperty(QtGui.QTextFormat.UserProperty, "find") + selection.cursor = cursor + extras.append(selection) + count += 1 + logger.debug("Find text found %s time%s", count, '' if count == 1 else 's') + textWidget.setExtraSelections(extras) + def findRehighlightAll(self): + """ Rehighlight all occurrences of the find phrase when the active document changes. + """ + if self.buttonHighlightAll.isEnabled() and self.buttonHighlightAll.isChecked(): + self.findHighlightAll() + @Slot() def showFindReplaceDlg(self): """ Show the Find/Replace dialog. @@ -1745,7 +1856,7 @@ def showFindReplaceDlg(self): self.findDlg.show() @Slot() - def find2(self, startPos=3, loop=True, findText=None): + def find2(self, startPos=3, loop=True, findText=None, tab=None): """ Find functionality for find/replace dialog. @@ -1762,19 +1873,17 @@ def find2(self, startPos=3, loop=True, findText=None): False limits that behavior so that we don't endlessly loop. findText : `str` Text to find. + tab : `BrowserTab` | None + Tab. Defaults to current tab if None. """ + tab = tab or self.currTab + # Set options. - searchFlags = QtGui.QTextDocument.FindFlags() - if self.findDlg.caseSensitiveCheck.isChecked(): - searchFlags |= QtGui.QTextDocument.FindCaseSensitively - if self.findDlg.wholeWordsCheck.isChecked(): - searchFlags |= QtGui.QTextDocument.FindWholeWords - if self.findDlg.searchBackwardsCheck.isChecked(): - searchFlags |= QtGui.QTextDocument.FindBackward - if startPos == 3: - startPos = 2 + searchFlags = self.findDlg.searchFlags() + if self.findDlg.searchBackwardsCheck.isChecked() and startPos == 3: + startPos = 2 - currTextWidget = self.currTab.getCurrentTextWidget() + currTextWidget = tab.getCurrentTextWidget() # Set where to start searching from. if startPos == 0: @@ -1804,29 +1913,41 @@ def find2(self, startPos=3, loop=True, findText=None): elif loop: # loop = False because we don't want to create an infinite recursive search. startPos = 1 if (searchFlags & QtGui.QTextDocument.FindBackward) else 0 - return self.find2(startPos=startPos, loop=False) + return self.find2(startPos=startPos, loop=False, tab=tab) else: self.findDlg.statusBar.showMessage("Phrase not found") self.findDlg.setStyleSheet("QLineEdit#findLineEdit{background:salmon}") return False @Slot() - def replace(self, findText=None, replaceText=None): + def replace(self, findText=None, replaceText=None, tab=None): """ Replace next hit for the search text. - """ + + :Parameters: + findText : `str` | None + Text to find. + Defaults to getting text from Find/Replace dialog. + replaceText : `str` | None + Text to replace it with. + Defaults to getting text from Find/Replace dialog. + tab : `BrowserTab` | None + Tab to replace text in. Defaults to current tab if None. + """ + tab = tab or self.currTab + if findText is None: findText = self.getFindText() if replaceText is None: replaceText = self.getReplaceText() # If we already have a selection. - cursor = self.currTab.textEditor.textCursor() + cursor = tab.textEditor.textCursor() if cursor.hasSelection() and cursor.selectedText() == findText: - self.currTab.textEditor.insertPlainText(replaceText) + tab.textEditor.insertPlainText(replaceText) self.findDlg.statusBar.showMessage("1 occurrence replaced.") # If we don't have a selection, try to get a new one. - elif self.find2(findText): - self.replace(findText, replaceText) + elif self.find2(findText, tab=tab): + self.replace(findText, replaceText, tab=tab) @Slot() def replaceFind(self): @@ -1836,24 +1957,50 @@ def replaceFind(self): self.find2() @Slot() - def replaceAll(self): + def replaceAll(self, findText=None, replaceText=None, tab=None, report=True): """ Replace all occurrences of the search text. + + :Parameters: + findText : `str` | None + Text to find. If None, get the value from the Find/Replace dialog. + replaceText : `str` | None + Text to replace. If None, get the value from the Find/Replace dialog. + tab : `TabWidget` | None + Tab to replace text in. Defaults to current tab. + report : `bool` + If True, report replace statistics; otherwise, just return the number of replacements. + :Returns: + Number of replacements + :Rtype: + `int` """ count = 0 - with self.overrideCursor(): - findText = self.getFindText() - replaceText = self.getReplaceText() - found = self.findAndReplace(findText, replaceText) - while found: + findText = findText if findText is not None else self.getFindText() + replaceText = replaceText if replaceText is not None else self.getReplaceText() + tab = tab or self.currTab + # Make sure we don't use the FindBackward flag. While direction doesn't really matter since all occurrences + # are getting replaced, it doesn't work with this simplified, fast logic. + flags = self.findDlg.searchFlags() & ~QtGui.QTextDocument.FindBackward + editor = tab.getCurrentTextWidget() + editor.textCursor().beginEditBlock() + doc = editor.document() + cursor = QtGui.QTextCursor(doc) + while True: + cursor = doc.find(findText, cursor, flags) + if cursor.isNull(): + break + cursor.insertText(replaceText) count += 1 - found = self.findAndReplace(findText, replaceText, startPos=3) - - if count > 0: - self.findDlg.setStyleSheet("QLineEdit#findLineEdit{background:none}") - self.findDlg.statusBar.showMessage("{} occurrence{} replaced.".format(count, '' if count == 1 else 's')) - else: - self.findDlg.statusBar.showMessage("Phrase not found. 0 occurrences replaced.") + editor.textCursor().endEditBlock() + + if report: + if count > 0: + self.findDlg.setStyleSheet("QLineEdit#findLineEdit{background:none}") + self.findDlg.statusBar.showMessage("{} occurrence{} replaced.".format(count, '' if count == 1 else 's')) + else: + self.findDlg.statusBar.showMessage("Phrase not found. 0 occurrences replaced.") + return count @Slot() def replaceAllInOpenFiles(self): @@ -1864,27 +2011,16 @@ def replaceAllInOpenFiles(self): with self.overrideCursor(): findText = self.getFindText() replaceText = self.getReplaceText() - origTab = self.tabWidget.currentIndex() - - for i in range(self.tabWidget.count()): - self.tabWidget.setCurrentIndex(i) - status = self.currTab.getFileStatus() + for tab in self.tabIterator(): + status = tab.getFileStatus() if not status.writable: continue - if not self.currTab.inEditMode: - self.toggleEdit() - fileChanged = False - found = self.findAndReplace(findText, replaceText) - while found: - fileChanged = True - count += 1 - found = self.findAndReplace(findText, replaceText, startPos=3) - if fileChanged: + if not tab.inEditMode: + self.toggleEdit(tab) + thisCount = self.replaceAll(findText, replaceText, tab, report=False) + if thisCount: files += 1 - - # End on the original tab. - if self.tabWidget.currentIndex() != origTab: - self.tabWidget.setCurrentIndex(origTab) + count += thisCount if count > 0: self.findDlg.setStyleSheet("QLineEdit#findLineEdit{background:none}") @@ -1893,7 +2029,7 @@ def replaceAllInOpenFiles(self): else: self.findDlg.statusBar.showMessage("Phrase not found. 0 occurrences replaced.") - def findAndReplace(self, findText, replaceText, startPos=0): + def findAndReplace(self, findText, replaceText, tab, startPos=0): """ Replace a single occurrence of a phrase. :Parameters: @@ -1915,11 +2051,11 @@ def findAndReplace(self, findText, replaceText, startPos=0): `bool` """ # Find one occurrence... - if self.find2(startPos=startPos, loop=False, findText=findText): + if self.find2(startPos=startPos, loop=False, findText=findText, tab=tab): # ...and replace it. - cursor = self.currTab.textEditor.textCursor() + cursor = tab.textEditor.textCursor() if cursor.hasSelection() and cursor.selectedText() == findText: - self.currTab.textEditor.insertPlainText(replaceText) + tab.textEditor.insertPlainText(replaceText) return True return False @@ -1954,7 +2090,7 @@ def goToLineNumberDlg(self): # Get line number. Current = current line, min = 1, max = number of lines. line = QtWidgets.QInputDialog.getInt(self, "Go To Line Number", "Line number:", currLine, 1, maxLine) if line[1]: - self.goToLineNumber(line[0]) + self.currTab.goToLineNumber(line[0]) def goToLineNumber(self, line=1): """ Go to the given line number @@ -1963,16 +2099,11 @@ def goToLineNumber(self, line=1): line : `int` Line number to scroll to. Defaults to 1 (top of document). """ - textWidget = self.currTab.getCurrentTextWidget() - block = textWidget.document().findBlockByNumber(line - 1) - cursor = textWidget.textCursor() - cursor.setPosition(block.position()) - # Highlight entire line. - pos = block.position() + block.length() - 1 - if pos != -1: - cursor.setPosition(pos, QtGui.QTextCursor.KeepAnchor) - textWidget.setTextCursor(cursor) - textWidget.ensureCursorVisible() + warnings.warn( + "goToLineNumber has been deprecated. Call this same method on the BrowserTab object itself.", + DeprecationWarning + ) + self.currTab.goToLineNumber(line) def tabIterator(self): """ Iterator through the tab widgets. """ @@ -2012,9 +2143,10 @@ def editPreferences(self): for w in self.tabIterator(): w.textBrowser.setFont(self.preferences['font']) w.textBrowser.zoomIn(self.preferences['fontSizeAdjust']) + w.textBrowser.lineNumbers.setVisible(self.preferences['lineNumbers']) w.textEditor.setFont(self.preferences['font']) w.textEditor.zoomIn(self.preferences['fontSizeAdjust']) - w.lineNumbers.setVisible(self.preferences['lineNumbers']) + w.textEditor.lineNumbers.setVisible(self.preferences['lineNumbers']) w.setIndentSettings(self.preferences['useSpaces'], self.preferences['tabSpaces'], self.preferences['autoIndent']) programs = dlg.getPrefPrograms() @@ -2023,10 +2155,10 @@ def editPreferences(self): # Update regex used for searching links. self.compileLinkRegEx.emit() # Update highlighter. - for lang, h in self.masterHighlighters.iteritems(): + for h in self.masterHighlighters.values(): h.setLinkPattern(self.programs) - for lang, h in self.masterHighlighters.iteritems(): + for h in self.masterHighlighters.values(): h.setSyntaxHighlighting(self.preferences['syntaxHighlighting']) # Enable/Disable completer on address bar. @@ -2050,21 +2182,16 @@ def updatePreference(self, key, value): self.preferences[key] = value self.config.setValue(key, value) - @Slot(int) + @Slot(bool) def updatePreference_findMatchCase(self, checked): """ Stores a bool representation of checkbox's state. :Parameters: - checked : `int` + checked : `bool` State of checkbox. """ - checked = checked == QtCore.Qt.Checked - if checked != self.preferences['findMatchCase']: - self.updatePreference('findMatchCase', checked) - for lang, h in self.masterHighlighters.iteritems(): - h.setFindCase(checked) - with self.overrideCursor(): - self.currTab.highlighter.rehighlight() + self.updatePreference('findMatchCase', checked) + self.findRehighlightAll() @Slot(str) def validateFindBar(self, text): @@ -2074,7 +2201,7 @@ def validateFindBar(self, text): text : `str` Current text in the find bar. """ - if text != "": + if text: self.buttonFindPrev.setEnabled(True) self.buttonFindNext.setEnabled(True) self.actionFindPrev.setEnabled(True) @@ -2144,20 +2271,17 @@ def duplicateTab(self): def refreshSelectedTab(self): """ Refresh the tab that was right-clicked. """ - currIndex = self.tabWidget.currentIndex() selectedIndex = self.tabWidget.tabBar.tabAt(self.contextMenuPos) selectedTab = self.tabWidget.widget(selectedIndex) - self.tabWidget.setCurrentIndex(selectedIndex) - - self.setSource(selectedTab.getCurrentUrl(), isNewFile=False, - hScrollPos=selectedTab.getCurrentTextWidget().horizontalScrollBar().value(), - vScrollPos=selectedTab.getCurrentTextWidget().verticalScrollBar().value()) - self.tabWidget.setCurrentIndex(currIndex) + self.refreshTab(selectedTab) @Slot() - def refreshTab(self): - """ Refresh the current tab. + def refreshTab(self, tab=None): + """ Reload the file for a tab. + :Parameters: + tab : `BrowserTab` | None + Tab to refresh. Defaults to current tab if None. :Returns: If the tab was reloaded successfully. :Rtype: @@ -2166,28 +2290,36 @@ def refreshTab(self): # Only refresh the tab if the refresh action is enabled, since the F5 shortcut is never disabled, # and methods sometimes call this directly even though we do not want to refresh. if self.actionRefresh.isEnabled(): - return self.setSource(self.currTab.getCurrentUrl(), isNewFile=False, - hScrollPos=self.currTab.getCurrentTextWidget().horizontalScrollBar().value(), - vScrollPos=self.currTab.getCurrentTextWidget().verticalScrollBar().value()) + tab = tab or self.currTab + status = self.setSource(tab.getCurrentUrl(), isNewFile=False, + hScrollPos=tab.getCurrentTextWidget().horizontalScrollBar().value(), + vScrollPos=tab.getCurrentTextWidget().verticalScrollBar().value(), + tab=tab) + return status return False @Slot() def increaseFontSize(self): """ Increase font size in the text browser and editor. """ + self.updatePreference('fontSizeAdjust', self.preferences['fontSizeAdjust'] + 1) for w in self.tabIterator(): w.textBrowser.zoomIn() w.textEditor.zoomIn() - self.updatePreference('fontSizeAdjust', self.preferences['fontSizeAdjust'] + 1) @Slot() def decreaseFontSize(self): """ Decrease font size in the text browser and editor. """ + # Don't allow zooming to zero or lower. While the widgets already block this safely, we don't want our font + # size adjustment to get larger even when the document font size isn't getting smaller than 1. + size = self.currTab.getCurrentTextWidget().document().defaultFont().pointSize() + if size + self.preferences['fontSizeAdjust'] <= 1: + return + self.updatePreference('fontSizeAdjust', self.preferences['fontSizeAdjust'] - 1) for w in self.tabIterator(): w.textBrowser.zoomOut() w.textEditor.zoomOut() - self.updatePreference('fontSizeAdjust', self.preferences['fontSizeAdjust'] - 1) @Slot() def defaultFontSize(self): @@ -2261,7 +2393,7 @@ def restoreTab(self, tab): self.menuRecentlyClosedTabs.removeAction(tab.action) # Re-activate the restored tab. - self.currTab.isActive = True + tab.isActive = True # Disable menu if there are no more recent tabs. if not self.menuRecentlyClosedTabs.actions(): @@ -2271,7 +2403,8 @@ def restoreTab(self, tab): if self.preferences['font'] != self.app.DEFAULTS['font']: tab.textBrowser.setFont(self.preferences['font']) tab.textEditor.setFont(self.preferences['font']) - tab.lineNumbers.setVisible(self.preferences['lineNumbers']) + tab.textBrowser.lineNumbers.setVisible(self.preferences['lineNumbers']) + tab.textEditor.lineNumbers.setVisible(self.preferences['lineNumbers']) # TODO: If this file doesn't exist or has changed on disk, reload it or warn the user? @@ -2539,7 +2672,7 @@ def currentlyOpenFiles(self): :Rtype: `list` """ - return [self.tabWidget.widget(i).getCurrentPath() for i in range(self.tabWidget.count())] + return [x.getCurrentPath() for x in self.tabIterator()] @Slot(QtCore.QUrl) def hoverUrl(self, link): @@ -2617,8 +2750,8 @@ def promptOnFileChange(self, path): # Make sure the tab appears as dirty so the user is prompted on exit to do so if they still haven't up to # that point. if not self.currTab.inEditMode: - self.toggleEdit() - self.setDirtyTab() + self.toggleEdit(self.currTab) + self.currTab.setDirty() return False def setOverrideCursor(self, cursor=QtCore.Qt.WaitCursor): @@ -2730,14 +2863,14 @@ def readUsdzFile(self, fileStr, layer=None): if layer and '[' in layer: # Get the next level of .usdz file and unzip it. layer1, layer2 = layer.split('[', 1) - dest = utils.getUsdzLayer(usdPath, layer1) + dest = utils.getUsdzLayer(usdPath, layer1, fileStr) return self.readUsdzFile(dest, layer2) args = "?extractedDir={}".format(usdPath) - return utils.getUsdzLayer(usdPath, layer) + args + return utils.getUsdzLayer(usdPath, layer, fileStr) + args @Slot(QtCore.QUrl) - def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos=0): + def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos=0, tab=None): """ Create a new tab or update the current one. Process a file to add links. Send the formatted text to the appropriate tab. @@ -2745,6 +2878,9 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos :Parameters: link : `QtCore.QUrl` File to open. + If link contains a fragment (e.g. #text), no new file will be loaded. The current file (if any) will + remain and the portion after the # will be treated as a query string. Useful for jumping to line + numbers without reloading the current file. isNewFile : `bool` Optional bool for if this is a new file or an item from history. newTab : `bool` @@ -2753,6 +2889,8 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos Horizontal scroll bar position. vScrollPos : `int` Vertical scroll bar position. + tab : `BrowserTab` | None + Existing tab to load in. Defaults to current tab. Ignored if newTab=True. :Returns: True if the file was loaded successfully (or was dirty but the user cancelled the save prompt). :Rtype: @@ -2760,7 +2898,7 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos """ # If we're staying in the current tab, check if the tab is dirty before doing anything. # Perform save operations if necessary. - if not newTab and not self.dirtySave(): + if not newTab and not self.dirtySave(tab=tab): return True # TODO: When given a relative path here, this expands based on the directory the tool was launched from. @@ -2768,14 +2906,33 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos fileInfo = QtCore.QFileInfo(link.toLocalFile()) absFilePath = fileInfo.absoluteFilePath() if not absFilePath: - self.closeTab() - return self.setSourceFinish() + tab = tab or self.currTab + self.closeTab(index=self.tabWidget.indexOf(tab)) + return self.setSourceFinish(tab=tab) nativeAbsPath = QtCore.QDir.toNativeSeparators(absFilePath) fullUrlStr = link.toString() fileExists = True # Assume the file exists for now. logger.debug("Setting source to %s (local file path: %s) %s %s", fullUrlStr, link.toLocalFile(), nativeAbsPath, link) + # Handle self-referential links, where we just want to do something to the current file based on input query + # parameters instead of reloading the file. + if link.hasFragment(): + queryLink = utils.urlFragmentToQuery(link) + tab = tab or self.currTab + + if newTab: + return self.setSource(queryLink, isNewFile, newTab, hScrollPos, vScrollPos, tab) + + if queryLink.hasQuery(): + # Scroll to line number. + line = utils.queryItemValue(queryLink, "line") + if line is not None: + tab.goToLineNumber(line) + # TODO: It would be nice to store the "clicked" position in history, so going back would take us to + # the object we just clicked (as opposed to where we first loaded the file from). + return self.setSourceFinish(tab=tab) + self.setOverrideCursor() try: # If the filename contains an asterisk, make sure there is at least one valid file. @@ -2783,7 +2940,8 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos if '*' in nativeAbsPath or "" in nativeAbsPath: multFiles = glob(nativeAbsPath.replace("", "[1-9][0-9][0-9][0-9]")) if not multFiles: - return self.setSourceFinish(False, "The file(s) could not be found:\n{}".format(nativeAbsPath)) + return self.setSourceFinish(False, "The file(s) could not be found:\n{}".format(nativeAbsPath), + tab=tab) # If we're opening any files, avoid also opening directories that a glob might have picked up. isFile = {x: QtCore.QFileInfo(x).isFile() for x in multFiles} @@ -2798,17 +2956,17 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos else: if fileInfo.isDir(): logger.debug("Set source to directory. Opening file dialog to %s", nativeAbsPath) - status = self.setSourceFinish() + status = self.setSourceFinish(tab=tab) # Instead of failing with a message that you can't open a directory, open the "Open File" dialog to # this directory instead. self.lastOpenFileDir = absFilePath - self.openFileDialog() + self.openFileDialog(tab=tab) return status if not fileInfo.exists(): logger.debug("The file could not be found: %s", nativeAbsPath) fileExists = False elif not fileInfo.isReadable(): - return self.setSourceFinish(False, "The file could not be read:\n{}".format(nativeAbsPath)) + return self.setSourceFinish(False, "The file could not be read:\n{}".format(nativeAbsPath), tab=tab) # Get extension (minus beginning .) to determine which program to # launch, or if the textBrowser should try to display the file. @@ -2821,15 +2979,16 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos else: args = shlex.split(self.programs[ext]) + [nativeAbsPath] self.launchProcess(args) - return self.setSourceFinish() + return self.setSourceFinish(tab=tab) if multFiles is not None: - self.setSources(multFiles) - return self.setSourceFinish() + self.setSources(multFiles, tab=tab) + return self.setSourceFinish(tab=tab) # Open this in a new tab or not? - if (newTab or (isNewFile and self.preferences['newTab'])) and not self.currTab.isNewTab: - self.newTab() + tab = tab or self.currTab + if (newTab or (isNewFile and self.preferences['newTab'])) and not tab.isNewTab: + tab = self.newTab() else: # Remove the tab's previous path from the file system watcher. # Be careful not to remove the path if any other tabs have the same file open. @@ -2838,11 +2997,11 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos self.fileSystemWatcher.removePath(path) # Set to none until we know what we're reading in. - self.currTab.fileFormat = FILE_FORMAT_NONE + tab.fileFormat = FILE_FORMAT_NONE # Set path in tab's title and address bar. fileName = fileInfo.fileName() - idx = self.tabWidget.currentIndex() + idx = self.tabWidget.indexOf(tab) self.tabWidget.setTabText(idx, fileName) self.tabWidget.setTabIcon(idx, QtGui.QIcon()) self.tabWidget.setTabToolTip(idx, "{} - {}".format(fileName, nativeAbsPath)) @@ -2867,13 +3026,13 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos break else: # No matching file parser found. - if ext == "usdz": + if ext in USD_ZIP_EXTS: layer = utils.queryItemValue(link, "layer") dest = self.readUsdzFile(absFilePath, layer) self.restoreOverrideCursor() self.loadingProgressBar.setVisible(False) self.loadingProgressLabel.setVisible(False) - return self.setSource(utils.strToUrl(dest)) + return self.setSource(utils.strToUrl(dest), tab=tab) else: logger.debug("Using default parser") parser = self.fileParserDefault @@ -2881,84 +3040,82 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos # Stop Loading Tab stops the expensive parsing of the file # for links, checking if the links actually exist, etc. # Setting it to this bypasses link parsing if the tab is in edit mode. - parser.stop(self.currTab.inEditMode or not self.preferences['parseLinks']) + parser.stop(tab.inEditMode or not self.preferences['parseLinks']) self.actionStop.setEnabled(True) parser.parse(nativeAbsPath, fileInfo, link) - self.currTab.fileFormat = parser.fileFormat - self.setHighlighter(ext) + tab.fileFormat = parser.fileFormat + self.setHighlighter(ext, tab=tab) logger.debug("Setting HTML") - self.currTab.textBrowser.setHtml(parser.html) + tab.textBrowser.setHtml(parser.html) logger.debug("Setting plain text") - self.currTab.textEditor.setPlainText("".join(parser.text)) + tab.textEditor.setPlainText("".join(parser.text)) truncated = parser.truncated warning = parser.warning parser._cleanup() else: self.loadingProgressBar.setVisible(False) self.loadingProgressLabel.setVisible(False) - return self.setSourceFinish(False) + return self.setSourceFinish(False, tab=tab) except Exception: self.loadingProgressBar.setVisible(False) self.loadingProgressLabel.setVisible(False) return self.setSourceFinish(False, "The file could not be read: {}".format(nativeAbsPath), - traceback.format_exc()) + traceback.format_exc(), tab=tab) self.loadingProgressLabel.setText("Highlighting text") + self.labelFindPixmap.setVisible(False) + self.labelFindStatus.setVisible(False) + self.findRehighlightAll() else: # Load an empty tab pointing to the nonexistent file. - self.setHighlighter(ext) - self.currTab.textBrowser.setHtml("") - self.currTab.textEditor.setPlainText("") + self.setHighlighter(ext, tab=tab) + tab.textBrowser.setHtml("") + tab.textEditor.setPlainText("") truncated = False warning = None logger.debug("Updating history") - self.currTab.isNewTab = False + tab.isNewTab = False if isNewFile: - self.currTab.updateHistory(link, truncated=truncated) - self.currTab.updateFileStatus(truncated=truncated) + tab.updateHistory(link, truncated=truncated) + tab.updateFileStatus(truncated=truncated) if fileExists: logger.debug("Setting scroll position") # Set focus and scroll to given position. # For some reason this never seems to work the first time. - self.currTab.getCurrentTextWidget().setFocus() - self.currTab.getCurrentTextWidget().horizontalScrollBar().setValue(hScrollPos) - self.currTab.getCurrentTextWidget().verticalScrollBar().setValue(vScrollPos) + tab.getCurrentTextWidget().setFocus() + tab.getCurrentTextWidget().horizontalScrollBar().setValue(hScrollPos) + tab.getCurrentTextWidget().verticalScrollBar().setValue(vScrollPos) # Scroll to line number. if link.hasQuery(): line = utils.queryItemValue(link, "line") if line is not None: - try: - line = int(line) - except ValueError: - logger.warning("Invalid line number in query string: %s", line) - else: - self.goToLineNumber(line) + tab.goToLineNumber(line) if absFilePath not in self.fileSystemWatcher.files(): self.fileSystemWatcher.addPath(absFilePath) # Since we dirty the tab anytime the text is changed, undirty it, as we just loaded this file. - self.setDirtyTab(False) + tab.setDirty(False) self.loadingProgressBar.setVisible(False) self.loadingProgressLabel.setVisible(False) else: - if not self.currTab.inEditMode: - self.toggleEdit() - self.setDirtyTab(True) + if not tab.inEditMode: + self.toggleEdit(tab) + tab.setDirty(True) logger.debug("Cleanup") self.statusbar.showMessage("Done", 2000) except Exception: return self.setSourceFinish(False, "An error occurred while reading the file: {}".format(nativeAbsPath), - traceback.format_exc()) + traceback.format_exc(), tab=tab) else: - return self.setSourceFinish(warning=warning) + return self.setSourceFinish(warning=warning, tab=tab) - def setSourceFinish(self, success=True, warning=None, details=None): + def setSourceFinish(self, success=True, warning=None, details=None, tab=None): """ Finish updating UI after loading a new source. :Parameters: @@ -2968,30 +3125,36 @@ def setSourceFinish(self, success=True, warning=None, details=None): Optional warning message details : `str` | None Optional details for the warning message + tab : `BrowserTab | None + Tab that finished. Defaults to current tab. :Returns: Success :Rtype: `bool` """ # Clear link since we don't want any previous links to carry over. - self.linkHighlighted = QtCore.QUrl("") - self.actionStop.setEnabled(False) - self.updateButtons() + if tab is None or tab == self.currTab: + self.linkHighlighted = QtCore.QUrl("") + self.actionStop.setEnabled(False) + self.updateButtons() self.restoreOverrideCursor() if warning: self.showWarningMessage(warning, details) return success - def setSources(self, files): + def setSources(self, files, tab=None): """ Open multiple files in new tabs. :Parameters: files : `list` List of string-based paths to open + tab : `BrowserTab` | None + Tab this may be opening from. Useful for path expansion. """ - prevPath = self.currTab.getCurrentPath() + tab = tab or self.currTab + prevPath = tab.getCurrentPath() for path in files: - self.setSource(utils.expandUrl(path, prevPath), newTab=True) + self.setSource(utils.expandUrl(path, prevPath), newTab=True, tab=tab) def setLoadingProgress(self, value): self.loadingProgressBar.setValue(value) @@ -3027,7 +3190,7 @@ def addItemToMenu(self, url, menu, slot, maxLen=None, start=0, end=None): :Parameters: url : `QtCore.QUrl` The full URL to add to a history menu. - menu : `QtGui.QMenu` + menu : `QtWidgets.QMenu` Menu to add history item to. slot Method to connect action to @@ -3035,7 +3198,7 @@ def addItemToMenu(self, url, menu, slot, maxLen=None, start=0, end=None): Optional maximum number of history items in the menu. start : `int` Optional number of actions at the start of the menu to ignore. - end : `int` + end : `int` | None Optional number of actions at the end of the menu to ignore. """ # Get the current actions. @@ -3122,7 +3285,7 @@ def currentTabChanged(self, idx): # Highlighting can be very slow on bigger files. Don't worry about # updating it if we're closing tabs while quitting the app. - self.findHighlightAll() + self.findRehighlightAll() def setNavigationMenus(self): """ Set the navigation buttons to use the current tab's history menus. @@ -3148,7 +3311,7 @@ def updateButtons(self): url = self.currTab.getCurrentUrl() path = url.toLocalFile() urlStr = url.toString() - if "?" in urlStr: + if url.hasQuery(): urlStr, query = urlStr.split("?", 1) else: query = None @@ -3172,10 +3335,11 @@ def updateButtons(self): title += " - " + self.tabWidget.tabText(self.tabWidget.currentIndex()) self.actionBack.setEnabled(self.currTab.isBackwardAvailable()) self.actionForward.setEnabled(self.currTab.isForwardAvailable()) - self.actionRefresh.setEnabled(True) - self.actionFileInfo.setEnabled(True) - self.actionTextEditor.setEnabled(True) - self.actionOpenWith.setEnabled(True) + enable = bool(path) + self.actionRefresh.setEnabled(enable) + self.actionFileInfo.setEnabled(enable) + self.actionTextEditor.setEnabled(enable) + self.actionOpenWith.setEnabled(enable) self.setWindowTitle(title) path = self.currTab.getCurrentPath() @@ -3288,8 +3452,8 @@ def viewSource(self, checked=False): Just for signal """ html = self.currTab.textBrowser.toHtml() - self.newTab() - self.currTab.textBrowser.setPlainText(html) + tab = self.newTab() + tab.textBrowser.setPlainText(html) def showCriticalMessage(self, message, details=None, title=None): """ Show an error message with optional details text (useful for tracebacks). @@ -3347,37 +3511,37 @@ def showWarningMessage(self, message, details=None, title=None, icon=QtWidgets.Q return msgBox.exec_() return QtWidgets.QMessageBox.warning(self, title, message) - @Slot(bool) - def setDirtyTab(self, dirty=True): - """ Set the dirty state of the current tab. + @Slot(str, str, bool) + def _changeTabName(self, text, toolTip, dirty): + """ Change the displayed name of a tab. + + Called via signal from a tab when the tab's dirty state changes. :Parameters: + text : `str` + Name to display + toolTip : `str` + Tab tool tip dirty : `bool` - If the current tab is dirty. + Dirty state of tab """ - self.currTab.isNewTab = False - self.currTab.textEditor.document().setModified(dirty) - path = self.currTab.getCurrentPath() - if not path: - fileName = "(Untitled)" - tipSuffix = "" - else: - fileName = QtCore.QFileInfo(path).fileName() - tipSuffix = " - {}".format(path) - if self.currTab.fileFormat == FILE_FORMAT_USDC: - tipSuffix += " (binary)" - elif self.currTab.fileFormat == FILE_FORMAT_USDZ: - tipSuffix += " (zip)" - text = "*{}*".format(fileName) if dirty else fileName - idx = self.tabWidget.currentIndex() + tab = self.sender() + idx = self.tabWidget.indexOf(tab) + if idx == -1: + logger.debug("Tab not found for %s", text) + return self.tabWidget.setTabText(idx, text) - self.tabWidget.setTabToolTip(idx, text + tipSuffix) - self.setWindowTitle("{} - {}".format(self.app.appDisplayName, text)) - self.actionDiffFile.setEnabled(dirty) + self.tabWidget.setTabToolTip(idx, toolTip) + if tab == self.currTab: + self.setWindowTitle("{} - {}".format(self.app.appDisplayName, text)) + self.actionDiffFile.setEnabled(dirty) - def dirtySave(self): + def dirtySave(self, tab=None): """ Present a save dialog for dirty tabs to know if they're safe to close/reload or not. + :Parameters: + tab : `BrowserTab` | None + Tab to save. Defaults to current tab. :Returns: False if Cancel selected. True if Discard selected. @@ -3385,10 +3549,11 @@ def dirtySave(self): :Rtype: `bool` """ - if self.currTab.isDirty(): - doc = self.currTab.textEditor.document() + tab = tab or self.currTab + if tab.isDirty(): + doc = tab.textEditor.document() if (not doc.isUndoAvailable() and not doc.isRedoAvailable() - and not QtCore.QFile.exists(self.currTab.getCurrentPath())): + and not QtCore.QFile.exists(tab.getCurrentPath())): # We navigated to a non-existent file and haven't actually edited it yet, but other code set it to # dirty so it's easier to save as the new file. Don't prompt to close in this case. # TODO: Is there a better way to track this? @@ -3402,11 +3567,18 @@ def dirtySave(self): if btn == QtWidgets.QMessageBox.Cancel: return False elif btn == QtWidgets.QMessageBox.Save: - return self.saveTab() + return self.saveTab(tab) else: # Discard - self.setDirtyTab(False) + tab.setDirty(False) return True + def setDirtyTab(self, dirty=True): + warnings.warn( + "setDirtyTab has been deprecated. Please call the BrowserTab object's setDirty method instead.", + DeprecationWarning + ) + self.currTab.setDirty(dirty) + @Slot(QtCore.QUrl) def onOpenOldUrl(self, url): """ Open a path from history in the current tab. @@ -3431,8 +3603,9 @@ def onOpen(self, path): def onOpenLinkNewWindow(self): """ Open the currently highlighted link in a new window. """ + url = utils.urlFragmentToQuery(self.linkHighlighted) window = self.newWindow() - window.setSource(self.linkHighlighted) + window.setSource(url) @Slot() def onOpenLinkNewTab(self): @@ -3584,7 +3757,7 @@ def setModel(self, model): def updateModel(self): local_completion_prefix = self.local_completion_prefix - class InnerProxyModel(QtGui.QSortFilterProxyModel): + class InnerProxyModel(QtCore.QSortFilterProxyModel): def filterAcceptsRow(self, sourceRow, sourceParent): index0 = self.sourceModel().index(sourceRow, 0, sourceParent) return local_completion_prefix.lower() in self.sourceModel().data(index0).toString().lower() @@ -3624,7 +3797,7 @@ def __init__(self, url, parent=None, slot=None): :Parameters: url : `QtCore.QUrl` URL to open. - parent : `QtGui.QMenu` + parent : `QtWidgets.QMenu` Menu to add action to. slot Method to connect openFile signal to @@ -3800,7 +3973,7 @@ def setTabIcon(self, index, icon): class TextBrowser(QtWidgets.QTextBrowser): """ - Customized QTextBrowser to override mouse events. + Customized QTextBrowser to override mouse events and add line numbers. """ def __init__(self, parent=None): """ Create and initialize the text browser. @@ -3810,6 +3983,11 @@ def __init__(self, parent=None): Browser tab containing this text browser widget """ super(TextBrowser, self).__init__(parent) + self.lineNumbers = LineNumbers(self) + + def resizeEvent(self, event): + super(TextBrowser, self).resizeEvent(event) + self.lineNumbers.onEditorResize() def copySelectionToClipboard(self): """ Store current selection to the middle mouse selection. @@ -3834,7 +4012,7 @@ def mouseReleaseEvent(self, event): """ window = self.window() link = window.linkHighlighted - if link.toString(): + if link.isValid(): if event.button() & QtCore.Qt.LeftButton: # Only open the link if the user hasn't changed the selection of text while clicking. # BUG: Won't let user click any highlighted portion of a link. @@ -3845,9 +4023,9 @@ def mouseReleaseEvent(self, event): self.copySelectionToClipboard() -class TextEdit(QtWidgets.QTextEdit): +class TextEdit(QtWidgets.QPlainTextEdit): """ - Customized QTextEdit to allow entering spaces with the Tab key. + Customized text edit widget to allow entering spaces with the Tab key. """ def __init__(self, parent=None, tabSpaces=4, useSpaces=True, autoIndent=True): """ Create and initialize the tab. @@ -3865,6 +4043,12 @@ def __init__(self, parent=None, tabSpaces=4, useSpaces=True, autoIndent=True): self.tabSpaces = tabSpaces self.useSpaces = useSpaces self.autoIndent = autoIndent + + self.lineNumbers = PlainTextLineNumbers(self) + + def resizeEvent(self, event): + super(TextEdit, self).resizeEvent(event) + self.lineNumbers.onEditorResize() def keyPressEvent(self, e): """ Override the Tab key to insert spaces instead and the Return key to match indentation @@ -3880,7 +4064,7 @@ def keyPressEvent(self, e): return elif self.useSpaces: # Insert the spaces equivalent of a tab character. - # Otherwise, QTextEdit already handles inserting the tab character. + # Otherwise, QTextEdit/QPlainTextEdit already handle inserting the tab character. self.insertPlainText(" " * self.tabSpaces) return elif e.key() == QtCore.Qt.Key_Backtab and e.modifiers() == QtCore.Qt.ShiftModifier: @@ -3967,7 +4151,7 @@ def uncommentText(self, commentStart="#", commentEnd=""): while cursor.position() <= end and not cursor.atEnd(): block = cursor.block() # Select the number of characters used in the comment string. - for i in range(len(commentStart)): + for _ in range(len(commentStart)): cursor.movePosition(cursor.NextCharacter, cursor.KeepAnchor) # If the selection is all on the same line and matches the comment string, remove it. if block.contains(cursor.selectionEnd()) and cursor.selectedText() == commentStart: @@ -3984,7 +4168,7 @@ def uncommentText(self, commentStart="#", commentEnd=""): # This logic may not be completely right when some comment symbols are already in the selection. block = cursor.block() # Select the number of characters used in the comment string. - for i in range(len(commentStart)): + for _ in range(len(commentStart)): cursor.movePosition(cursor.NextCharacter, cursor.KeepAnchor) # If the selection is all on the same line and matches the comment string, remove it. if block.contains(cursor.selectionEnd()) and cursor.selectedText() == commentStart: @@ -3993,7 +4177,7 @@ def uncommentText(self, commentStart="#", commentEnd=""): cursor.setPosition(end - len(commentStart)) block = cursor.block() cursor.movePosition(cursor.EndOfBlock) - for i in range(len(commentEnd)): + for _ in range(len(commentEnd)): cursor.movePosition(cursor.PreviousCharacter, cursor.KeepAnchor) if block.contains(cursor.selectionStart()) and cursor.selectedText() == commentEnd: cursor.deleteChar() @@ -4037,7 +4221,7 @@ def unindentText(self): while cursor.position() < end and not cursor.atEnd(): currBlock = cursor.blockNumber() - for i in range(self.tabSpaces): + for _ in range(self.tabSpaces): cursor.movePosition(cursor.NextCharacter, cursor.KeepAnchor) if cursor.selectedText() == " ": cursor.deleteChar() @@ -4061,6 +4245,40 @@ def unindentText(self): cursor.movePosition(cursor.StartOfLine, cursor.MoveAnchor) cursor.endEditBlock() + def zoomIn(self, adjust=1): + """ Legacy Qt 4 support. + + :Parameters: + adjust : `int` + Font point size adjustment + """ + try: + super(TextEdit, self).zoomIn(adjust) + except AttributeError: + # Qt4 support. + font = self.document().defaultFont() + size = font.pointSize() + adjust + if size > 0: + font.setPointSize(size) + self.document().setDefaultFont(font) + + def zoomOut(self, adjust=1): + """ Legacy Qt 4 support. + + :Parameters: + adjust : `int` + Font point size adjustment + """ + try: + super(TextEdit, self).zoomOut(adjust) + except AttributeError: + # Qt4 support. + font = self.document().defaultFont() + size = font.pointSize() - adjust + if size > 0: + font.setPointSize(size) + self.document().setDefaultFont(font) + class BrowserTab(QtWidgets.QWidget): """ @@ -4071,6 +4289,7 @@ class BrowserTab(QtWidgets.QWidget): restoreTab = Signal(QtWidgets.QWidget) openFile = Signal(str) openOldUrl = Signal(QtCore.QUrl) + tabNameChanged = Signal(str, str, bool) def __init__(self, parent=None): """ Create and initialize the tab. @@ -4109,15 +4328,14 @@ def __init__(self, parent=None): # Text editor. self.textEditor = TextEdit(self) self.textEditor.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - self.textEditor.setAcceptRichText(False) self.textEditor.setFont(font) - self.textEditor.setWordWrapMode(QtGui.QTextOption.NoWrap) + self.textEditor.setLineWrapMode(self.textEditor.NoWrap) self.textEditor.setVisible(self.inEditMode) self.setIndentSettings(prefs['useSpaces'], prefs['tabSpaces'], prefs['autoIndent']) + self.textEditor.document().modificationChanged.connect(self.setDirty) - # Line numbers. - self.lineNumbers = LineNumbers(self, widget=self.getCurrentTextWidget()) - self.lineNumbers.setVisible(prefs['lineNumbers']) + self.textBrowser.lineNumbers.setVisible(prefs['lineNumbers']) + self.textEditor.lineNumbers.setVisible(prefs['lineNumbers']) # Menu item to be used in dropdown list of currently open tabs. self.action = QtWidgets.QAction("(Untitled)", None) @@ -4125,9 +4343,7 @@ def __init__(self, parent=None): # Add widget to layout and layout to tab self.browserLayout = QtWidgets.QHBoxLayout() - self.browserLayout.setContentsMargins(2,5,5,5) - self.browserLayout.setSpacing(2) - self.browserLayout.addWidget(self.lineNumbers) + self.browserLayout.setContentsMargins(0, 2, 0, 0) self.browserLayout.addWidget(self.textBrowser) self.browserLayout.addWidget(self.textEditor) self.setLayout(self.browserLayout) @@ -4222,7 +4438,7 @@ def getCurrentTextWidget(self): :Returns: The current text widget, based on edit mode :Rtype: - `TextBrowser` | `QtGui.QTextEdit` + `TextBrowser` | `TextEdit` """ return self.textEditor if self.inEditMode else self.textBrowser @@ -4288,10 +4504,10 @@ def gotoBreadcrumb(self, url, index=None): newIndex = index if index is not None else self.findUrl(url) self.backMenu.clear() self.forwardMenu.clear() - for i, historyItem in enumerate(self.history[:newIndex]): + for i in range(len(self.history[:newIndex])): self.historyIndex = i self.addHistoryAction(self.backMenu) - for i, historyItem in enumerate(self.history[newIndex+1:]): + for i in range(len(self.history[newIndex+1:])): self.historyIndex = i + newIndex + 1 self.addHistoryAction(self.forwardMenu, i) @@ -4299,6 +4515,30 @@ def gotoBreadcrumb(self, url, index=None): self.updateBreadcrumb() self.openOldUrl.emit(self.getCurrentUrl()) + def goToLineNumber(self, line=1): + """ Go to the given line number + + :Parameters: + line : `int` + Line number to scroll to. Defaults to 1 (top of document). + """ + try: + line = int(line) + except ValueError: + logger.warning("Invalid line number: %s", line) + return + + textWidget = self.getCurrentTextWidget() + block = textWidget.document().findBlockByNumber(line - 1) + cursor = textWidget.textCursor() + cursor.setPosition(block.position()) + # Highlight entire line. + pos = block.position() + block.length() - 1 + if pos != -1: + cursor.setPosition(pos, QtGui.QTextCursor.KeepAnchor) + textWidget.setTextCursor(cursor) + textWidget.ensureCursorVisible() + def isBackwardAvailable(self): """ Check if you can go back in history. @@ -4346,6 +4586,30 @@ def onHistoryActionTriggered(self, url): self.gotoBreadcrumb(url, self.sender().historyIndex) self.openOldUrl.emit(url) + @Slot(bool) + def setDirty(self, dirty=True): + """ Set the dirty state. + + :Parameters: + dirty : `bool` + If this tab is dirty. + """ + self.isNewTab = False + self.textEditor.document().setModified(dirty) + path = self.getCurrentPath() + if not path: + fileName = "(Untitled)" + tipSuffix = "" + else: + fileName = QtCore.QFileInfo(path).fileName() + tipSuffix = " - {}".format(path) + if self.fileFormat == FILE_FORMAT_USDC: + tipSuffix += " (binary)" + elif self.fileFormat == FILE_FORMAT_USDZ: + tipSuffix += " (zip)" + text = "*{}*".format(fileName) if dirty else fileName + self.tabNameChanged.emit(text, text + tipSuffix, dirty) + def setIndentSettings(self, useSpaces=True, tabSpaces=4, autoIndent=True): """ Set various indent settings, such as spaces for tabs and auto indentation @@ -4611,7 +4875,7 @@ def newWindow(self): :Returns: New main window widget :Rtype: - `QtGui.QWidget` + `QtWidgets.QWidget` """ window = self.createWindowFrame() self._windows.append(window) @@ -4648,9 +4912,22 @@ def onExit(self): class Settings(QtCore.QSettings): """ Add a method to get `bool` values from settings, since bool is stored as the `str` "true" or "false." """ + def value(self, key, default=None): + """ PySide2 bug fix of default value of 0 not getting used and None getting returned. + + :Parameters: + key : `str` + Key + default + Default value, if stored value is None. + """ + val = super(Settings, self).value(key, default) + return default if val is None else val + def boolValue(self, key, default=False): - """ Boolean values are saved to settings as the string "true" or "false". - Convert a setting back to a bool, since we don't have QVariant objects in Qt.py. + """ Boolean values are saved to settings as the string "true" or "false," except on a Mac, where the .plist + file saves them as actual booleans. Convert a setting back to a bool, since we don't have QVariant objects in + Qt.py. :Parameters: key : `str` @@ -4666,7 +4943,7 @@ def boolValue(self, key, default=False): val = self.value(key) if type(val) is bool: return val - return default if val is None else val == "true" + return bool(default) if val is None else val == "true" def run(): diff --git a/usdmanager/constants.py b/usdmanager/constants.py index 83c1aa5..7958dc5 100644 --- a/usdmanager/constants.py +++ b/usdmanager/constants.py @@ -17,14 +17,21 @@ Constant values """ # USD file extensions. -USD_EXTS = ("usd", "usda", "usdc", "usdz") +# Expandable with custom file formats. +# First in each tuple is preferred extension for that format (e.g. in Save dialog). +USD_AMBIGUOUS_EXTS = ("usd",) # Can be ASCII or crate. +USD_ASCII_EXTS = ("usda",) # Can ONLY be ASCII. +USD_CRATE_EXTS = ("usdc",) # Can ONLY be Crate. +USD_ZIP_EXTS = ("usdz",) +USD_EXTS = USD_AMBIGUOUS_EXTS + USD_ASCII_EXTS + USD_CRATE_EXTS + USD_ZIP_EXTS + # File filters for the File > Open... and File > Save As... dialogs. FILE_FILTER = ( "USD Files (*.{})".format(" *.".join(USD_EXTS)), - "USD - ASCII (*.usd *.usda)", - "USD - Crate (*.usd *.usdc)", - "USD - Zip (*.usdz)", + "USD - ASCII (*.{})".format(" *.".join(USD_AMBIGUOUS_EXTS + USD_ASCII_EXTS)), + "USD - Crate (*.{})".format(" *.".join(USD_AMBIGUOUS_EXTS + USD_CRATE_EXTS)), + "USD - Zip (*.{})".format(" *.".join(USD_ZIP_EXTS)), "All Files (*)" ) diff --git a/usdmanager/find_dialog.py b/usdmanager/find_dialog.py index 19dc328..ed20aa5 100644 --- a/usdmanager/find_dialog.py +++ b/usdmanager/find_dialog.py @@ -15,7 +15,7 @@ # from Qt.QtCore import Slot from Qt.QtWidgets import QStatusBar -from Qt.QtGui import QIcon +from Qt.QtGui import QIcon, QTextDocument from .utils import loadUiType @@ -61,6 +61,23 @@ def connectSignals(self): """ self.findLineEdit.textChanged.connect(self.updateButtons) + def searchFlags(self): + """ Get find flags based on checked options. + + :Returns: + Find flags + :Rtype: + `QTextDocument.FindFlags` + """ + flags = QTextDocument.FindFlags() + if self.caseSensitiveCheck.isChecked(): + flags |= QTextDocument.FindCaseSensitively + if self.wholeWordsCheck.isChecked(): + flags |= QTextDocument.FindWholeWords + if self.searchBackwardsCheck.isChecked(): + flags |= QTextDocument.FindBackward + return flags + @Slot(str) def updateButtons(self, text): """ diff --git a/usdmanager/find_dialog.ui b/usdmanager/find_dialog.ui index 82341a5..95052a9 100644 --- a/usdmanager/find_dialog.ui +++ b/usdmanager/find_dialog.ui @@ -7,7 +7,7 @@ 0 0 443 - 230 + 242 @@ -53,10 +53,18 @@ - + + + Replace + + - + + + Find + + diff --git a/usdmanager/highlighter.py b/usdmanager/highlighter.py index e036e0f..4e249fa 100644 --- a/usdmanager/highlighter.py +++ b/usdmanager/highlighter.py @@ -26,9 +26,6 @@ from .utils import findModules -# Used to clear out highlighting from the Find command. -DONT_MATCH_PHRASE = "USDMNGRDONTMATCH" - # Enabled when running in a theme with a dark background color. DARK_THEME = False @@ -106,7 +103,7 @@ def findHighlighters(): # Find all available "MasterHighlighter" subclasses within the highlighters module. classes = [] for module in findModules("highlighters"): - for name, cls in inspect.getmembers(module, lambda x: inspect.isclass(x) and issubclass(x, MasterHighlighter)): + for _, cls in inspect.getmembers(module, lambda x: inspect.isclass(x) and issubclass(x, MasterHighlighter)): classes.append(cls) return classes @@ -155,7 +152,7 @@ def __init__(self, parent, enableSyntaxHighlighting=False, programs=None): # Undo syntax highlighting on at least some of our links so the assigned colors show. self.ruleLink = createRule("*") self.highlightingRules.append(self.ruleLink) - self.setLinkPattern(programs or {}) + self.setLinkPattern(programs or {}, dirty=False) # Some general single-line rules that apply to many file formats. # Numeric literals @@ -194,14 +191,6 @@ def __init__(self, parent, enableSyntaxHighlighting=False, programs=None): if self.ruleLink not in self.highlightingRules: self.highlightingRules.append(self.ruleLink) - # Find phrase. - frmt = QtGui.QTextCharFormat() - frmt.setBackground(QtCore.Qt.yellow) - pattern = QtCore.QRegExp(DONT_MATCH_PHRASE, QtCore.Qt.CaseInsensitive, QtCore.QRegExp.FixedString) - self.findRule = (pattern, frmt) - self.blankRules.append(self.findRule) - self.highlightingRules.append(self.findRule) - self.setSyntaxHighlighting(enableSyntaxHighlighting) def getRules(self): @@ -240,48 +229,20 @@ def dirty(self): """ self.dirtied.emit() - def setFindPhrase(self, phrase): - """ Set the "find" phrase when searching for text. - - :Parameters: - phrase : `str` - Text in find bar to search for. - """ - if phrase == "": - phrase = DONT_MATCH_PHRASE - - if phrase != self.findPhrase: - if phrase == DONT_MATCH_PHRASE: - self.findRule = (self.findRule[0], QtGui.QTextCharFormat()) - else: - self.findRule[1].setBackground(QtCore.Qt.yellow) - self.findRule[0].setPattern(phrase) - self.findPhrase = phrase - self.dirty() - - def setFindCase(self, case): - """ Set the case sensitivity when searching for text. - - :Parameters: - case : `bool` - Find is case-sensitive if True. - """ - case = QtCore.Qt.CaseSensitive if case else QtCore.Qt.CaseInsensitive - if case != self.findRule[0].caseSensitivity(): - self.findRule[0].setCaseSensitivity(case) - self.dirty() - - def setLinkPattern(self, programs): + def setLinkPattern(self, programs, dirty=True): """ Set the rules to search for files based on file extensions, quotes, etc. :Parameters: programs : `dict` extension: program pairs of strings. + dirty : `bool` + If we should trigger a rehighlight or not. """ # This is slightly different than the main program's RegEx because Qt doesn't support all the same things. # TODO: Not allowing a backslash here might break Windows file paths if/when we try to support that. self.ruleLink[0].setPattern(r'(?:[^\'"@()\t\n\r\f\v\\]*\.)(?:' + '|'.join(programs.keys()) + r')(?=(?:[\'")@]|\\\"))') - self.dirty() + if dirty: + self.dirty() def setSyntaxHighlighting(self, enable, force=True): """ Enable/Disable syntax highlighting. @@ -334,6 +295,12 @@ def highlightBlock(self, text): # TODO: Do we need to reset the block state or anything else here? return + # Reduce name lookups for speed, since this is one of the slowest parts of the app. + setFormat = self.setFormat + currentBlockState = self.currentBlockState + setCurrentBlockState = self.setCurrentBlockState + previousBlockState = self.previousBlockState + for pattern, frmt in self.master.rules: i = pattern.indexIn(text) while i >= 0: @@ -344,12 +311,12 @@ def highlightBlock(self, text): i = pos1 else: length = pattern.matchedLength() - self.setFormat(i, length, frmt) + setFormat(i, length, frmt) i = pattern.indexIn(text, i + length) - self.setCurrentBlockState(0) + setCurrentBlockState(0) for state, (startExpr, endExpr, frmt) in enumerate(self.master.multilineRules, 1): - if self.previousBlockState() == state: + if previousBlockState() == state: # We're already inside a match for this rule. See if there's an ending match. startIndex = 0 add = 0 @@ -371,15 +338,15 @@ def highlightBlock(self, text): # We found the end of the multiline rule. length = endIndex - startIndex + add + endExpr.matchedLength() # Since we're at the end of this rule, reset the state so other multiline rules can try to match. - self.setCurrentBlockState(0) + setCurrentBlockState(0) else: # Still inside the multiline rule. length = len(text) - startIndex + add - self.setCurrentBlockState(state) + setCurrentBlockState(state) # Highlight the portion of this line that's inside the multiline rule. # TODO: This doesn't actually ensure we hit the closing expression before highlighting. - self.setFormat(startIndex, length, frmt) + setFormat(startIndex, length, frmt) # Look for the next match. startIndex = startExpr.indexIn(text, startIndex + length) @@ -390,7 +357,7 @@ def highlightBlock(self, text): else: add = startExpr.matchedLength() - if self.currentBlockState() == state: + if currentBlockState() == state: break self.dirty = False diff --git a/usdmanager/highlighters/python.py b/usdmanager/highlighters/python.py index d74d974..1792c67 100644 --- a/usdmanager/highlighters/python.py +++ b/usdmanager/highlighters/python.py @@ -36,7 +36,7 @@ def getRules(self): [ # Keywords r"\b(?:and|as|assert|break|class|continue|def|del|elif|else|except|exec|finally|for|from|global|if|" - r"import|in|is|lambda|not|or|pass|print|raise|return|try|while|with|yield)\b", + r"import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield)\b", QtGui.QColor("#4b7029"), QtGui.QColor("#4b7029"), QtGui.QFont.Bold diff --git a/usdmanager/linenumbers.py b/usdmanager/linenumbers.py index 321f720..c22c359 100644 --- a/usdmanager/linenumbers.py +++ b/usdmanager/linenumbers.py @@ -17,112 +17,245 @@ Line numbers widget for optimized display of line numbers on the left side of a text widget. """ -from Qt.QtCore import Slot -from Qt.QtWidgets import QFrame, QWidget -from Qt.QtGui import QFont, QPainter, QTextCharFormat +from Qt import QtCore +from Qt.QtCore import QRect, QSize, Slot +from Qt.QtGui import QColor, QFont, QPainter, QTextCharFormat, QTextFormat +from Qt.QtWidgets import QTextEdit, QWidget -class LineNumbers(QWidget): - """ Line number widget for `QTextBrowser` and `QTextEdit` widgets. - Currently does not support `QPlainTextEdit` widgets. +class PlainTextLineNumbers(QWidget): + """ Line number widget for `QPlainTextEdit` widgets. """ - def __init__(self, *args, **kwargs): - super(LineNumbers, self).__init__(*args) - - self.textWidget = self.doc = None - self.setTextWidget(kwargs.pop('widget', None)) + def __init__(self, parent): + """ Initialize the line numbers widget. + :Parameters: + parent : `QPlainTextEdit` + Text widget + """ + super(PlainTextLineNumbers, self).__init__(parent) + self.textWidget = parent + self._hiddenByUser = False + self._highlightCurrentLine = True + # Monospaced font to keep width from shifting. font = QFont() font.setStyleHint(QFont.Courier) font.setFamily("Monospace") self.setFont(font) - self.updateAndResize() + self.connectSignals() + self.updateLineWidth() + self.highlightCurrentLine() + def blockCount(self): + return self.textWidget.blockCount() + def connectSignals(self): - """ Connect relevant `QTextBrowser` or `QTextEdit` signals. + """ Connect signals from the text widget that affect line numbers. + """ + self.textWidget.blockCountChanged.connect(self.updateLineWidth) + self.textWidget.updateRequest.connect(self.updateLineNumbers) + self.textWidget.cursorPositionChanged.connect(self.highlightCurrentLine) + + @Slot() + def highlightCurrentLine(self): + """ Highlight the line the cursor is on. + + :Returns: + If highlighting was enabled or not. + :Rtype: + `bool` + """ + if not self._highlightCurrentLine: + return False + + extras = [x for x in self.textWidget.extraSelections() if x.format.property(QTextFormat.UserProperty) != "line"] + selection = QTextEdit.ExtraSelection() + lineColor = QColor(QtCore.Qt.darkGray).darker() if self.window().isDarkTheme() else \ + QColor(QtCore.Qt.yellow).lighter(180) + selection.format.setBackground(lineColor) + selection.format.setProperty(QTextFormat.FullWidthSelection, True) + selection.format.setProperty(QTextFormat.UserProperty, "line") + selection.cursor = self.textWidget.textCursor() + selection.cursor.clearSelection() + # Put at the beginning of the list so we preserve any highlighting from Find's highlight all functionality. + extras.insert(0, selection) + ''' + if self.window().buttonHighlightAll.isChecked() and self.window().findBar.text(): + selection = QTextEdit.ExtraSelection() + lineColor = QColor(QtCore.Qt.yellow) + selection.format.setBackground(lineColor) + selection.cursor = QtGui.QTextCursor(self.textWidget.document()) + selection.find(self.window().findBar.text()) + ''' + self.textWidget.setExtraSelections(extras) + return True + + def lineWidth(self, count=0): + """ Calculate the width of the widget based on the block count. + + :Parameters: + count : `int` + Block count. Defaults to current block count. + """ + if self._hiddenByUser: + return 0 + blocks = str(count or self.blockCount()) + try: + # horizontalAdvance added in Qt 5.11. + return 6 + self.fontMetrics().horizontalAdvance(blocks) + except AttributeError: + # Obsolete in Qt 5. + return 6 + self.fontMetrics().width(blocks) + + def onEditorResize(self): + """ Adjust line numbers size if the text widget is resized. + """ + cr = self.textWidget.contentsRect() + self.setGeometry(QRect(cr.left(), cr.top(), self.lineWidth(), cr.height())) + + def paintEvent(self, event): + """ Draw the visible line numbers. + """ + painter = QPainter(self) + painter.fillRect(event.rect(), QColor(QtCore.Qt.darkGray).darker(300) if self.window().isDarkTheme() \ + else QtCore.Qt.lightGray) + + textWidget = self.textWidget + currBlock = textWidget.document().findBlock(textWidget.textCursor().position()) + + block = textWidget.firstVisibleBlock() + blockNumber = block.blockNumber() + 1 + geo = textWidget.blockBoundingGeometry(block).translated(textWidget.contentOffset()) + top = round(geo.top()) + bottom = round(geo.bottom()) + width = self.width() - 3 # 3 is magic padding number + height = round(geo.height()) + flags = QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter + font = painter.font() + + # Shrink the line numbers if we zoom out so numbers don't overlap, but don't increase the size, since we don't + # (yet) account for that in this widget's width, leading to larger numbers cutting off the leading digits. + size = max(1, min(width, height - 3)) + if size < font.pointSize(): + font.setPointSize(size) + + while block.isValid() and top <= event.rect().bottom(): + if block.isVisible() and bottom >= event.rect().top(): + # Make the line number for the selected line bold. + font.setBold(block == currBlock) + painter.setFont(font) + painter.drawText(0, top, width, height, flags, str(blockNumber)) + + block = block.next() + top = bottom + bottom = top + round(textWidget.blockBoundingRect(block).height()) + blockNumber += 1 + + def setVisible(self, visible): + super(PlainTextLineNumbers, self).setVisible(visible) + self._hiddenByUser = not visible + self.updateLineWidth() + + def sizeHint(self): + return QSize(self.lineWidth(), 0) + + @Slot(QRect, int) + def updateLineNumbers(self, rect, dY): + """ Scroll the line numbers or repaint the visible numbers. """ - self.textWidget.verticalScrollBar().valueChanged[int].connect(self.update) - self.textWidget.currentCharFormatChanged[QTextCharFormat].connect(self.updateAndResize) - self.textWidget.cursorPositionChanged.connect(self.update) - self.textWidget.selectionChanged.connect(self.update) - self.doc.blockCountChanged[int].connect(self.updateAndResize) + if dY: + self.scroll(0, dY) + else: + self.update(0, rect.y(), self.width(), rect.height()) + if rect.contains(self.textWidget.viewport().rect()): + self.updateLineWidth() - def setTextWidget(self, widget): - """ Set the current text widget. + @Slot(int) + def updateLineWidth(self, count=0): + """ Adjust display of text widget to account for the widget of the line numbers. :Parameters: - widget : `QTextBrowser` | `QTextEdit` - The text widget that uses a QTextDocument + count : `int` + Block count of document. + """ + self.textWidget.setViewportMargins(self.lineWidth(count), 0, 0, 0) + + +class LineNumbers(PlainTextLineNumbers): + """ Line number widget for `QTextBrowser` and `QTextEdit` widgets. + Currently does not support `QPlainTextEdit` widgets. + """ + def blockCount(self): + return self.textWidget.document().blockCount() + + def connectSignals(self): + """ Connect relevant `QTextBrowser` or `QTextEdit` signals. """ - if widget is None: - return - self.textWidget = widget self.doc = self.textWidget.document() - self.connectSignals() + self.textWidget.verticalScrollBar().valueChanged.connect(self.update) + self.textWidget.currentCharFormatChanged.connect(self.resizeAndUpdate) + self.textWidget.cursorPositionChanged.connect(self.highlightCurrentLine) + self.doc.blockCountChanged.connect(self.updateLineWidth) @Slot() - @Slot(int) - def update(self, *args): - """ Just update. We know we don't need to resize with the signals that call this method. + def highlightCurrentLine(self): + """ Make sure the active line number is redrawn in bold by calling update. """ - super(LineNumbers, self).update() + if super(LineNumbers, self).highlightCurrentLine(): + self.update() - @Slot(int) @Slot(QTextCharFormat) - def updateAndResize(self, *args): + def resizeAndUpdate(self, *args): """ Resize bar if needed. """ - width = self.fontMetrics().width(str(self.doc.blockCount())) + 5 - if self.width() != width: - self.setFixedWidth(width) + self.updateLineWidth() super(LineNumbers, self).update() def paintEvent(self, event): - """ Override the default paintEvent to add in line numbers. + """ Draw line numbers. """ - vScrollPos = self.textWidget.verticalScrollBar().value() - pageBtm = vScrollPos + self.textWidget.viewport().height() - currBlock = self.doc.findBlock(self.textWidget.textCursor().position()) - - fontMetric = self.fontMetrics() painter = QPainter(self) - font = painter.font() + painter.fillRect(event.rect(), QColor(QtCore.Qt.darkGray).darker(300) if self.window().isDarkTheme() \ + else QtCore.Qt.lightGray) + + textWidget = self.textWidget + doc = textWidget.document() + vScrollPos = textWidget.verticalScrollBar().value() + pageBtm = vScrollPos + textWidget.viewport().height() + currBlock = doc.findBlock(textWidget.textCursor().position()) + width = self.width() - 3 # 3 is magic padding number + height = textWidget.fontMetrics().height() + flags = QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter + font = painter.font() + + # Shrink the line numbers if we zoom out so numbers don't overlap, but don't increase the size, since we don't + # (yet) account for that in this widget's width, leading to larger numbers cutting off the leading digits. + size = max(1, min(width, height - 3)) + if size < font.pointSize(): + font.setPointSize(size) + # Find roughly the current top-most visible block. - block = self.doc.begin() - lineHeight = self.doc.documentLayout().blockBoundingRect(block).height() + block = doc.begin() + lineHeight = doc.documentLayout().blockBoundingRect(block).height() - block = self.doc.findBlockByNumber(int(vScrollPos/lineHeight)) + block = doc.findBlockByNumber(int(vScrollPos / lineHeight)) currLine = block.blockNumber() while block.isValid(): currLine += 1 # Check if the position of the block is outside the visible area. - yPos = self.doc.documentLayout().blockBoundingRect(block).topLeft().y() + yPos = doc.documentLayout().blockBoundingRect(block).topLeft().y() if yPos > pageBtm: break - if block == currBlock: - # Make the line number for the selected line bold. - font.setBold(True) - painter.setFont(font) - # Draw the line number right justified at the y position of the line. 3 is a magic padding number. - painter.drawText(self.width() - fontMetric.width(str(currLine)) - 3, - round(yPos) - vScrollPos + fontMetric.ascent() + 3, - str(currLine)) - font.setBold(False) - painter.setFont(font) - else: - painter.drawText(self.width() - fontMetric.width(str(currLine)) - 3, - round(yPos) - vScrollPos + fontMetric.ascent() + 3, - str(currLine)) - + # Make the line number for the selected line bold. + font.setBold(block == currBlock) + painter.setFont(font) + painter.drawText(0, yPos - vScrollPos, width, height, flags, str(currLine)) + # Go to the next block. block = block.next() - - painter.end() - - super(LineNumbers, self).paintEvent(event) diff --git a/usdmanager/main_window.ui b/usdmanager/main_window.ui index 13f7faa..8784c4b 100644 --- a/usdmanager/main_window.ui +++ b/usdmanager/main_window.ui @@ -55,7 +55,9 @@ New tab - + + + @@ -221,12 +223,15 @@ 16 + + true + - + 0 0 @@ -247,10 +252,13 @@ 0 + + Find + - + false @@ -261,10 +269,7 @@ - Find previous occurrence - - - Previous + Find previous occurrence (Ctrl+Shift+G) @@ -277,10 +282,16 @@ 16 + + Qt::ToolButtonTextBesideIcon + + + true + - + false @@ -291,10 +302,7 @@ - Find next occurrence - - - Next + Find next occurrence (Ctrl+G) @@ -307,6 +315,12 @@ 16 + + Qt::ToolButtonTextBesideIcon + + + true + @@ -351,10 +365,24 @@ - + + + + 0 + 0 + + + - + + + + 0 + 0 + + + @@ -399,7 +427,7 @@ 0 0 910 - 26 + 28 @@ -512,7 +540,9 @@ &Recently Closed Tabs - + + + @@ -580,7 +610,9 @@ - + + + &About @@ -591,7 +623,9 @@ - + + + &Quit @@ -605,7 +639,9 @@ - + + + &Documentation @@ -619,7 +655,9 @@ false - + + + &Back @@ -633,7 +671,9 @@ false - + + + &Forward @@ -644,7 +684,9 @@ - + + + &Open... @@ -655,7 +697,9 @@ - + + + &Edit File @@ -677,7 +721,9 @@ - + + + &Find... @@ -726,7 +772,9 @@ - + + + New &Tab @@ -737,7 +785,9 @@ - + + + &Save @@ -748,7 +798,9 @@ - + + + Save &As... @@ -773,7 +825,9 @@ false - + + + &Undo @@ -787,7 +841,9 @@ false - + + + &Redo @@ -801,7 +857,9 @@ false - + + + Cu&t @@ -815,7 +873,9 @@ false - + + + &Copy @@ -829,7 +889,9 @@ false - + + + &Paste @@ -843,7 +905,9 @@ false - + + + Select &All @@ -871,7 +935,9 @@ false - + + + &Reload @@ -885,7 +951,9 @@ - + + + &Zoom In @@ -896,7 +964,9 @@ - + + + Zoom &Out @@ -907,7 +977,9 @@ - + + + &Normal Size @@ -941,7 +1013,9 @@ false - + + + Open with &text editor... @@ -957,7 +1031,9 @@ - + + + &Browse Mode @@ -1007,7 +1083,9 @@ false - + + + &Open with... @@ -1018,7 +1096,9 @@ - + + + Prefere&nces... @@ -1032,7 +1112,9 @@ false - + + + &File Info... @@ -1049,7 +1131,9 @@ - + + + &New Window @@ -1063,7 +1147,9 @@ true - + + + &Full Screen @@ -1085,7 +1171,9 @@ false - + + + U&nindent @@ -1105,7 +1193,9 @@ false - + + + &Indent @@ -1159,7 +1249,9 @@ false - + + + &Stop @@ -1173,7 +1265,9 @@ - + + + &Print... @@ -1184,7 +1278,9 @@ - + + + Print Pre&view... diff --git a/usdmanager/parser.py b/usdmanager/parser.py index 06d359c..9f50531 100644 --- a/usdmanager/parser.py +++ b/usdmanager/parser.py @@ -49,6 +49,7 @@ class FileParser(QObject): # Override as needed. fileFormat = FILE_FORMAT_NONE + lineCharLimit = LINE_CHAR_LIMIT # Group within the RegEx corresponding to the file path only. # Useful if you modify compile() but not linkParse(). RE_FILE_GROUP = 1 @@ -86,7 +87,7 @@ def acceptsFile(self, fileInfo, link): :Rtype: `bool` """ - raise NotImplemented + raise NotImplementedError def _cleanup(self): """ Reset variables for a new file. @@ -155,6 +156,13 @@ def parse(self, nativeAbsPath, fileInfo, link): self.status.emit("Parsing text for links") logger.debug("Parsing text for links.") + # Reduce name lookups for speed, since this is one of the slowest parts of the app. + emit = self.progress.emit + lineCharLimit = self.lineCharLimit + finditer = self.regex.finditer + re_file_group = self.RE_FILE_GROUP + parseMatch = self.parseMatch + html = "" # Escape HTML characters for proper display. # Do this before we add any actual HTML characters. @@ -166,19 +174,19 @@ def parse(self, nativeAbsPath, fileInfo, link): html += "".join(lines[i:]) break - self.progress.emit(i) - if len(line) > LINE_CHAR_LIMIT: + emit(i) + if len(line) > lineCharLimit: html += self.parseLongLine(line) continue # Search for multiple, non-overlapping links on each line. offset = 0 - for m in self.regex.finditer(line): - linkPath = m.group(self.RE_FILE_GROUP) - start = m.start(self.RE_FILE_GROUP) - end = m.end(self.RE_FILE_GROUP) + for m in finditer(line): + linkPath = m.group(re_file_group) + start = m.start(re_file_group) + end = m.end(re_file_group) try: - href = self.parseMatch(m, linkPath, nativeAbsPath, fileInfo) + href = parseMatch(m, linkPath, nativeAbsPath, fileInfo) except ValueError: # File doesn't exist or path cannot be resolved. # Color it red. diff --git a/usdmanager/parsers/usd.py b/usdmanager/parsers/usd.py index 864b05f..7d6e2e6 100644 --- a/usdmanager/parsers/usd.py +++ b/usdmanager/parsers/usd.py @@ -23,7 +23,8 @@ from Qt.QtCore import QFileInfo, Slot from .. import utils -from ..constants import FILE_FORMAT_USD, FILE_FORMAT_USDA, FILE_FORMAT_USDC +from ..constants import FILE_FORMAT_USD, FILE_FORMAT_USDA, FILE_FORMAT_USDC,\ + USD_AMBIGUOUS_EXTS, USD_ASCII_EXTS, USD_CRATE_EXTS from ..parser import AbstractExtParser @@ -37,7 +38,7 @@ class UsdAsciiParser(AbstractExtParser): Treat as plain text. This is the simplest of the Usd parsers, which other USD parsers should inherit from. """ - exts = ("usda",) + exts = USD_ASCII_EXTS fileFormat = FILE_FORMAT_USDA def __init__(self, *args, **kwargs): @@ -130,7 +131,7 @@ def parseMatch(self, match, linkPath, nativeAbsPath, fileInfo): # Make the HTML link. if self.exists[fullPath]: _, fullPathExt = splitext(fullPath) - if fullPathExt == ".usdc" or (fullPathExt == ".usd" and utils.isUsdCrate(fullPath)): + if fullPathExt[1:] in USD_CRATE_EXTS or (fullPathExt[1:] in USD_AMBIGUOUS_EXTS and utils.isUsdCrate(fullPath)): queryParams.insert(0, "binary=1") return '{}'.format(fullPath, "&".join(queryParams), linkPath) @@ -187,7 +188,7 @@ class UsdCrateParser(UsdAsciiParser): Don't bother checking the fist line for PXR-USDC. If this is a valid ASCII USD file and not binary, but we use this parser accidentally, the file will load slower (since we do a usdcat conversion) but won't break anything. """ - exts = ("usdc",) + exts = USD_CRATE_EXTS fileFormat = FILE_FORMAT_USDC def acceptsFile(self, fileInfo, link): @@ -201,7 +202,7 @@ def acceptsFile(self, fileInfo, link): Full URL, potentially with query string """ ext = fileInfo.suffix() - return ext in self.exts or (ext == "usd" and utils.queryItemBoolValue(link, "binary")) + return ext in self.exts or (ext in USD_AMBIGUOUS_EXTS and utils.queryItemBoolValue(link, "binary")) def read(self, path): return self.parent().readUsdCrateFile(path) @@ -210,7 +211,7 @@ def read(self, path): class UsdParser(UsdAsciiParser): """ Parse ambiguous USD files that may be ASCII or crate. """ - exts = ("usd",) + exts = USD_AMBIGUOUS_EXTS fileFormat = FILE_FORMAT_USD def acceptsFile(self, fileInfo, link): diff --git a/usdmanager/preferences_dialog.py b/usdmanager/preferences_dialog.py index 666553b..1c92352 100644 --- a/usdmanager/preferences_dialog.py +++ b/usdmanager/preferences_dialog.py @@ -53,7 +53,7 @@ def setupUi(self, widget): Creates and lays out the widgets defined in the ui file. :Parameters: - widget : `QtGui.QWidget` + widget : `QtWidgets.QWidget` Base widget """ #super(PreferencesDialog, self).setupUi(widget) # TODO: Switch back to this if we get loadUiType working. diff --git a/usdmanager/utils.py b/usdmanager/utils.py index b5f3578..8cc6afb 100644 --- a/usdmanager/utils.py +++ b/usdmanager/utils.py @@ -35,7 +35,7 @@ else: uic = Qt._uic -from .constants import USD_EXTS +from .constants import USD_EXTS, USD_AMBIGUOUS_EXTS, USD_ASCII_EXTS, USD_CRATE_EXTS # Set up logging. @@ -121,10 +121,7 @@ def expandUrl(path, parentPath=None): query = None url = QtCore.QUrl.fromLocalFile(os.path.abspath(expandPath(path, parentPath, sdf_format_args))) if query: - if Qt.IsPySide2 or Qt.IsPyQt5: - url.setQuery(query) - else: - url.setQueryItems([x.split("=", 1) for x in query.split("&")]) + url.setQuery(query) return url @@ -214,7 +211,7 @@ def generateTemporaryUsdFile(usdFileName, tmpDir=None): :Raises OSError: If usdcat fails """ - fd, tmpFileName = tempfile.mkstemp(suffix=".usd", dir=tmpDir) + fd, tmpFileName = tempfile.mkstemp(suffix="." + USD_AMBIGUOUS_EXTS[0], dir=tmpDir) os.close(fd) usdcat(QtCore.QDir.toNativeSeparators(usdFileName), tmpFileName, format="usda") return tmpFileName @@ -309,7 +306,7 @@ def unzip(path, tmpDir=None): return destDir -def getUsdzLayer(usdzDir, layer=None): +def getUsdzLayer(usdzDir, layer=None, usdz=None): """ Get a layer from an unzipped usdz archive. :Parameters: @@ -318,6 +315,8 @@ def getUsdzLayer(usdzDir, layer=None): layer : `str` Default layer within file (e.g. the portion within the square brackets here: @foo.usdz[path/to/file/within/package.usd]@) + usdz : `str` + Original usdz file path :Returns: Layer file path :Rtype: @@ -332,18 +331,30 @@ def getUsdzLayer(usdzDir, layer=None): else: raise ValueError("Layer {} not found in usdz archive {}".format(layer, usdzDir)) - # TODO: Figure out if this is really the proper way to get the default layer. + if usdz is not None: + try: + from pxr import Usd + except ImportError: + logger.debug("Unable to import pxr.Usd to find usdz default layer.") + else: + zipFile = Usd.ZipFile.Open(usdz) + if zipFile: + for fileName in zipFile.GetFileNames(): + return os.path.join(usdzDir, fileName) + raise ValueError("Default layer not found in usdz archive!") + + # Fallback to checking the files on disk instead of using USD. destFile = os.path.join(usdzDir, "defaultLayer.usd") if os.path.exists(destFile): return destFile - files = glob(os.path.join(usdzDir, "*.usd")) + glob(os.path.join(usdzDir, "*.usd[ac]")) + files = [] + for ext in USD_AMBIGUOUS_EXTS + USD_ASCII_EXTS + USD_CRATE_EXTS: + files += glob(os.path.join(usdzDir, "*." + ext)) if files: if len(files) == 1: return files[0] - else: - raise ValueError("Ambiguous default layer in usdz archive!") - else: - raise ValueError("No default layer found in usdz archive!") + raise ValueError("Ambiguous default layer in usdz archive!") + raise ValueError("No default layer found in usdz archive!") def humanReadableSize(size): @@ -357,7 +368,7 @@ def humanReadableSize(size): :Rtype: `str` """ - for unit in ["bytes", "kB", "MB", "GB"]: + for unit in ("bytes", "kB", "MB", "GB"): if abs(size) < 1024: return "{:.1f} {}".format(size, unit) size /= 1024.0 @@ -526,13 +537,12 @@ def queryItemValue(url, key, default=None): :Raises ValueError: If an invalid query string is given """ - url = url.toString() - if "?" in url: - query = url.split("?", 1)[1] - for item in query.split("&"): + if url.hasQuery(): + query = url.toString().split("?", 1)[1] + for item in query.split(url.queryPairDelimiter()): if item: try: - k, v = item.split("=") + k, v = item.split(url.queryValueDelimiter()) except ValueError: logger.error("Invalid query string: %s", query) else: @@ -585,6 +595,29 @@ def sdfQuery(link): return sdf_format_args +def urlFragmentToQuery(url): + """ Convert a URL with a fragment (e.g. url#?foo=bar) to a URL with a query string. + + Normally, this app treats that as a file to NOT reload, using the query string as a mechanism to modify the + currently loaded file, such as jumping to a line number. We instead convert this to a "normal" URL with a query + string if the URL needs to load in a new tab or new window, for example. + + :Parameters: + url : `QtCore.QUrl` + URL + :Returns: + Converted URL + :Rtype: + `QtCore.QUrl` + """ + if url.hasFragment(): + fragment = url.fragment() + url.setFragment(None) + if fragment.startswith("?"): + url.setQuery(fragment[1:]) + return url + + def usdRegEx(exts): """ RegEx to find other file paths in USD-based text files. diff --git a/usdmanager/version.py b/usdmanager/version.py index 282f930..c610696 100644 --- a/usdmanager/version.py +++ b/usdmanager/version.py @@ -13,4 +13,4 @@ # See the License for the specific language governing permissions and # limitations under the License. # -__version__ = '0.11.0' +__version__ = '0.12.0'