From 6c276c4e4cb08994047e2432e86e332d2726c3b2 Mon Sep 17 00:00:00 2001 From: mds-dwa Date: Tue, 2 Aug 2022 13:53:35 -0700 Subject: [PATCH] [BUG] ensure app temp dir exists before loading sources [BUG] only set selection clipboard on OS that supports it [ENH] display absolute path to program in about dialog [ENH] theme/icon support, add missing icons, move off django icons [EHN] Rewrite file parsers to move read/write logic out of init [MNT] rm deprecated -dark flag [MNT] better defaults for diff and text editor on Windows [DOC] clarify install instructions Signed-off-by: mds-dwa --- README.md | 14 +- docs/development.md | 19 +- docs/installation.md | 19 +- setup.py | 1 + usdmanager/__init__.py | 727 ++++++++++++++++--------------- usdmanager/config.json | 5 + usdmanager/constants.py | 9 +- usdmanager/find_dialog.py | 12 +- usdmanager/highlighters/lua.py | 5 +- usdmanager/include_panel.py | 29 +- usdmanager/parser.py | 83 +++- usdmanager/parsers/usd.py | 221 ++++++++-- usdmanager/preferences_dialog.py | 10 +- usdmanager/usdviewstyle.qss | 109 +++-- usdmanager/utils.py | 91 +++- 15 files changed, 859 insertions(+), 495 deletions(-) diff --git a/README.md b/README.md index 004a5e4..499b8b5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ USD Manager is an open-source, python-based Qt tool for browsing, managing, and combining the best features from your favorite web browser and text editor into one application, with hooks to deeply integrate with other pipeline tools. It is developed and maintained by [DreamWorks Animation](http://www.dreamworksanimation.com) for use with USD and other hierarchical, text-based workflows, primarily geared towards feature film production. While -primarily designed around PyQt4, USD Manager uses the Qt.py compatibility library to allow working with PyQt4, PyQt5, +originally designed around PyQt4, USD Manager uses the Qt.py compatibility library to allow working with PyQt4, PyQt5, PySide, or PySide2 for Qt bindings. ![USD Manager screenshot](docs/_static/screenshot_island.png?raw=true "USD Manager") @@ -42,8 +42,14 @@ Installing USD Manager Requirements ------------ -usdmanager requires Python 2, [Qt.py](https://github.com/mottosso/Qt.py) and [setuptools](https://github.com/pypa/setuptools) -(can be handled by setup.py), and one of Qt.py's 4 supported Qt bindings, which will need to be installed separately. +usdmanager requires [Python](https://www.python.org/) 2 (for Python 3, see the +[python3 branch](https://github.com/dreamworksanimation/usdmanager/tree/python3)), +[Qt.py](https://github.com/mottosso/Qt.py) and [setuptools](https://github.com/pypa/setuptools) +(can be handled by setup.py), and one of Qt.py's four supported Qt bindings, which will need to be installed separately. + +Additionally, an installation of [USD](https://graphics.pixar.com/usd) itself is recommended but not required for all use cases. +Installing USD provides access to file path resolvers, non-ASCII USD formats, and plug-ins like usdview. +All USD versions should be supported. Install with setup.py --------------------- @@ -58,7 +64,7 @@ For a personal install, try: python setup.py install --user ``` -Studios with significant python codebases or non-trivial installs may need to customize setup.py +Studios with significant python codebases or non-trivial installs may need to customize [setup.py](setup.py). Your PATH and PYTHONPATH will need to be set appropriately to launch usdmanager, and this will depend on your setup.py install settings. diff --git a/docs/development.md b/docs/development.md index b5020e6..a43071a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -24,7 +24,9 @@ Supported keys include: - **appURL _(str)_** - Documentation URL. Defaults to the public GitHub repository. - **defaultPrograms _({str: str})_** - File extension keys with the command to open the file type as the values. - **diffTool _(str)_** - Diff command. Defaults to xdiff. -- **iconTheme _(str)_** - QtGui.QIcon theme name. Defaults to crystal_project. +- **iconTheme _(str)_** - QtGui.QIcon theme name. Defaults to crystal_project. Can be overridden by iconThemes for a +specific app theme. +- **iconThemes _(dict)_** - QtGui.QIcon theme name to use per theme the app supports ("light" and "dark") - **textEditor _(str)_** - Text editor to use when opening files externally if $EDITOR environment variable is not set. Defaults to nedit. - **themeSearchPaths _([str])_** - Paths to prepend to QtGui.QIcon's theme search paths. @@ -43,7 +45,10 @@ Example app config JSON file: "tx": "rez-run openimageio_arras -- iv" }, "diffTool": "python /usr/bin/meld", - "iconTheme": "gnome", + "iconThemes": { + "light": "crystal_project", + "dark": "gnome" + }, "textEditor": "gedit", "themeSearchPaths": [] } @@ -117,13 +122,13 @@ class CustomExample(Plugin): ## Icons -Most icons in the app come from themes pre-installed on your system, ideally following the +Some icons in the app come from themes pre-installed on your system, ideally following the [freedesktop.org standards](https://standards.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html). The preferred icon set that usdmanager was originally developed with is Crystal Project Icons. These icons are licensed -under LGPL and available via pypi and GitHub here: https://github.com/ambv/django-crystal-small. While not required for -the application to work, if you would like these icons to get the most out of the application, please install them to a -directory named crystal_project under one of the directories listed by `Qt.QtGui.QIcon.themeSearchPaths()` (e.g. -/usr/share/icons/crystal_project). +under LGPL and available via PyPI and GitHub here: https://github.com/mds-dwa/crystal-small. While not required for +the application to work, if you would like these icons to get the most out of the application, please ensure crystal-small +is installed via pip (already part of the default setup) or install them to a directory named crystal_project under one +of the directories listed by `Qt.QtGui.QIcon.themeSearchPaths()` (e.g. /usr/share/icons/crystal_project). Additional icons for custom plug-ins can be placed in the plugins directory and then added to the [usdmanager/plugins/images.qrc](https://github.com/dreamworksanimation/usdmanager/blob/master/usdmanager/plugins/images.qrc) file. After adding a file to images.rc, run the diff --git a/docs/installation.md b/docs/installation.md index f9647ea..d9dfe7b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -16,10 +16,14 @@ have not been as heavily tested. Notes to help with installation on specific ope - [Common Problems](#common-problems) ## Prerequisites -- Install Python 2 ([https://www.python.org/downloads/](https://www.python.org/downloads/)) +- Install Python 2 ([https://www.python.org/downloads/](https://www.python.org/downloads/)), or 3 if on the python3 branch. * **Windows:** Ensure the install location is part of your PATH variable (newer installs should have an option for this) - Install one of the recommended Python Qt bindings * **Python 2:** PyQt4 or PySide + * **Python 3:** PyQt5 or PySide2, example: + ``` + pip install PySide2 + ``` ## Install with setup.py @@ -65,7 +69,7 @@ and this will depend on your setup.py install settings. 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"``` +If setup.py complains about missing setuptools, you can install it via pip. If you installed a new enough python 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 or C:\Users\username\AppData\Local\Microsoft\WindowsApps\pip.exe), but if needed, you can permanently add it to your environment with this (adjusting the path as needed): ```setx PATH "%PATH%;C:\Python27\Scripts"``` 1. Upgrade pip if needed 1. Launch Command Prompt in Administrator mode @@ -73,15 +77,12 @@ If setup.py complains about missing setuptools, you can install it via pip. If y 2. Install setuptools if needed 1. Run ```pip install setuptools``` 3. Re-run the setup.py step above for usdmanager -4. If you don't modify your path, you should now be able to run something like this to launch the program: ```python C:\Python27\Scripts\usdmanager``` +4. If you don't modify your path, you should now be able to run something like this to launch the program: ```python C:\Python27\Scripts\usdmanager``` or from the install directory itself, e.g. ``` python .\build\scripts-3.8\usdmanager``` #### Known Issues -- Drive letter may show doubled-up in address bar (e.g. C:C:/my_file.txt) +- Since this is not installed as an entirely self-contained package, the application name (and icon) will by Python, not USD Manager. ## Common Problems -- Missing icons (may still be missing some even after this!) - * ```pip install django-crystal-small``` (this also installs django by default, which you may not want) - * Add installed path to your downloaded usdmanager/config.json file, then re-run the setup.py install. You'll need a line similar to this in your config.json: ```"themeSearchPaths": ["C:\\Python27\\Lib\\site-packages\\django_crystal_small\\static\\crystal"]``` - Can't open files in external text editor - * In Preferences, try setting your default text editor - * **Windows:** Try ```notepad.exe``` or ```"C:\Windows\notepad.exe"``` (including the quotation marks) + * In Preferences, update your default text editor + * **Windows:** Try ```notepad```, ```notepad.exe```, or ```"C:\Windows\notepad.exe"``` (including the quotation marks on that last one) diff --git a/setup.py b/setup.py index 1d3eb75..090949c 100644 --- a/setup.py +++ b/setup.py @@ -68,6 +68,7 @@ data_files=[("usdmanager", ["usdmanager/usdviewstyle.qss"])], scripts=glob("scripts/*"), install_requires=[ + "crystal_small", # Default icons "Qt.py>=1.1", "setuptools", # For pkg_resources ], diff --git a/usdmanager/__init__.py b/usdmanager/__init__.py index 374cbfc..be73bd1 100644 --- a/usdmanager/__init__.py +++ b/usdmanager/__init__.py @@ -45,6 +45,7 @@ import json import logging import os +import re import shlex import signal import shutil @@ -76,14 +77,15 @@ 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_AMBIGUOUS_EXTS, USD_ASCII_EXTS, USD_CRATE_EXTS, USD_ZIP_EXTS, USD_EXTS) + LINE_LIMIT, FILE_FILTER, FILE_FORMAT_NONE, FILE_FORMAT_TXT, 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, PlainTextLineNumbers from .include_panel import IncludePanel -from .parser import FileParser, AbstractExtParser +from .parser import AbstractExtParser, FileParser, SaveFileError from .plugins import images_rc as plugins_rc from .plugins import Plugin from .preferences_dialog import PreferencesDialog @@ -200,7 +202,7 @@ class UsdMngrWindow(QtWidgets.QMainWindow): - Remember scroll position per file so going back in history jumps you to approximately where you were before. - Add Browse... buttons to select default applications. - - Set consistent cross-platform read/write/execute permissions when saving new files + - Set consistent cross-platform read/write/execute permissions when saving new files. Known issues: @@ -209,6 +211,7 @@ class UsdMngrWindow(QtWidgets.QMainWindow): blocks. - Line numbers width not always immediately updated after switching to new class. - If a file loses edit permissions, it can stay in edit mode and let you make changes that can't be saved. + - Qt bug: QPushButton dark theme hover/press color not respected. """ @@ -236,13 +239,16 @@ def __init__(self, parent=None, **kwargs): self.defaultPrograms.update(self.app.DEFAULTS['defaultPrograms']) self.programs = self.defaultPrograms self.masterHighlighters = {} + self.preferences = {} self._darkTheme = False self.contextMenuPos = None self.findDlg = None self.lastOpenFileDir = self.app.opts['dir'] self.linkHighlighted = QtCore.QUrl("") - self.quitting = False + self.quitting = False # If the app is in the process of shutting down + self._prevParser = None # Previous file parser, used for menu updates + self.currTab = None # Currently selected tab # Track changes to files on disk. self.fileSystemWatcher = QtCore.QFileSystemWatcher(self) @@ -251,19 +257,6 @@ def __init__(self, parent=None, **kwargs): self.setupUi() self.connectSignals() - # Find and initialize file parsers. - self.fileParsers = [] - self._initFileParser(FileParser, FileParser.__name__) - # Don't include the default parser in the list we iterate through to find compatible custom parsers, - # since we only use it as a fallback. - self.fileParserDefault = self.fileParsers.pop() - self._prevParser = None - for module in utils.findModules("parsers"): - for name, cls in inspect.getmembers(module, lambda x: inspect.isclass(x) and - issubclass(x, FileParser) and - x not in (FileParser, AbstractExtParser)): - self._initFileParser(cls, name) - # Find and initialize plugins. self.plugins = [] for module in utils.findModules("plugins"): @@ -284,6 +277,10 @@ def _initFileParser(self, cls, name): Parser class to instantiate name : `str` Class name + :Returns: + File parser instance + :Rtype: + `FileParser` """ try: parser = cls(self) @@ -292,7 +289,7 @@ def _initFileParser(self, cls, name): logger.exception("Failed to initialize parser %s", name) else: logger.debug("Initialized parser %s", name) - self.fileParsers.append(parser) + return parser def setupUi(self): """ Create and lay out the widgets defined in the ui file, then add additional modifications to the UI. @@ -301,6 +298,7 @@ def setupUi(self): # You now have access to the widgets defined in the ui file. # Update some app defaults that required the GUI to be created first. + self.setWindowIcon(QtGui.QIcon(":images/images/logo.png")) defaultDocFont = QtGui.QFont() defaultDocFont.setStyleHint(QtGui.QFont.Courier) defaultDocFont.setFamily("Monospace") @@ -318,6 +316,12 @@ def setupUi(self): self._darkTheme = True # Set usdview-based stylesheet. logger.debug("Setting dark theme") + + iconThemeName = self.app.DEFAULTS['iconThemes'][userThemeName] + logger.debug("Icon theme name: %s", iconThemeName) + QtGui.QIcon.setThemeName(iconThemeName) + del iconThemeName + stylesheet = resource_filename(__name__, "usdviewstyle.qss") with open(stylesheet) as f: # Qt style sheet accepts only forward slashes as path separators. @@ -344,86 +348,73 @@ def setupUi(self): .badLink {{color:#F33}} {}""" - searchPaths = QtGui.QIcon.themeSearchPaths() - extraSearchPaths = [x for x in self.app.DEFAULTS['themeSearchPaths'] if x not in searchPaths] - if extraSearchPaths: - searchPaths = extraSearchPaths + searchPaths - QtGui.QIcon.setThemeSearchPaths(searchPaths) - - # Set the preferred theme name for some non-standard icons. - QtGui.QIcon.setThemeName(self.app.DEFAULTS['iconTheme']) - # Try to adhere to the freedesktop icon standards: # https://standards.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html # Some icons are preferred from the crystal_project set, which sadly follows different naming standards. # While we can define theme icons in the .ui file, it doesn't give us the fallback option. # Additionally, it doesn't work in Qt 4.8.6 but does work in Qt 5.10.0. # If you don't have the proper icons installed, the actions simply won't have an icon. It's non-critical. - ft = QtGui.QIcon.fromTheme - self.menuOpenRecent.setIcon(ft("document-open-recent")) - self.actionPrintPreview.setIcon(ft("document-print-preview")) - self.menuRecentlyClosedTabs.setIcon(ft("document-open-recent")) - self.actionEdit.setIcon(ft("accessories-text-editor")) - self.actionIndent.setIcon(ft("format-indent-more")) - self.actionUnindent.setIcon(ft("format-indent-less")) - self.exitAction.setIcon(ft("application-exit")) - self.documentationAction.setIcon(ft("help-browser")) - self.aboutAction.setIcon(ft("help-about")) - icon = ft("window-close") - if icon.isNull(): - self.buttonCloseFind.setText("x") - else: - self.buttonCloseFind.setIcon(ft("window-close")) - - # Try for standard name, then fall back to crystal_project name. - self.actionBrowse.setIcon(ft("applications-internet", ft("Globe"))) - self.actionFileInfo.setIcon(ft("dialog-information", ft("info"))) - self.actionPreferences.setIcon(ft("preferences-system", ft("configure"))) - self.actionZoomIn.setIcon(ft("zoom-in", ft("viewmag+"))) - self.actionZoomOut.setIcon(ft("zoom-out", ft("viewmag-"))) - self.actionNormalSize.setIcon(ft("zoom-original", ft("viewmag1"))) - textEdit = ft("accessories-text-editor", ft("edit")) + icon = utils.icon + self.menuOpenRecent.setIcon(icon("document-open-recent")) + self.actionPrintPreview.setIcon(icon("document-print-preview")) + self.menuRecentlyClosedTabs.setIcon(icon("document-open-recent")) + self.aboutAction.setIcon(icon("help-about")) + self.exitAction.setIcon(icon("application-exit")) + self.actionIndent.setIcon(icon("format-indent-more")) + self.actionUnindent.setIcon(icon("format-indent-less")) + self.documentationAction.setIcon(icon("help-browser")) + self.actionBrowse.setIcon(icon("applications-internet")) + self.actionFileInfo.setIcon(icon("dialog-information")) + self.actionFind.setIcon(icon("edit-find")) + self.actionFindPrev.setIcon(icon("edit-find-previous")) + self.actionFindNext.setIcon(icon("edit-find-next")) + self.actionPreferences.setIcon(icon("preferences-system")) + self.actionZoomIn.setIcon(icon("zoom-in")) + self.actionZoomOut.setIcon(icon("zoom-out")) + self.actionNormalSize.setIcon(icon("zoom-original")) + textEdit = icon("accessories-text-editor") self.actionEdit.setIcon(textEdit) self.actionTextEditor.setIcon(textEdit) - self.buttonGo.setIcon(ft("media-playback-start", ft("1rightarrow"))) - self.actionFullScreen.setIcon(ft("view-fullscreen", ft("window_fullscreen"))) - self.browserReloadIcon = ft("view-refresh", ft("reload")) + self.buttonGo.setIcon(icon("media-playback-start")) + self.actionFullScreen.setIcon(icon("view-fullscreen")) + self.browserReloadIcon = icon("view-refresh") self.actionRefresh.setIcon(self.browserReloadIcon) - self.browserStopIcon = ft("process-stop", ft("stop")) + self.browserStopIcon = icon("process-stop") self.actionStop.setIcon(self.browserStopIcon) - - # Try for crystal_project name, then fall back to standard name. - self.actionFind.setIcon(ft("find", ft("edit-find"))) - self.actionOpen.setIcon(ft("fileopen", ft("document-open"))) - self.buttonFindPrev.setIcon(ft("previous", ft("go-previous"))) - self.buttonFindNext.setIcon(ft("next", ft("go-next"))) - self.actionNewWindow.setIcon(ft("new_window", ft("window-new"))) - self.actionOpenWith.setIcon(ft("terminal", ft("utilities-terminal"))) - self.actionPrint.setIcon(ft("printer", ft("document-print"))) - self.actionUndo.setIcon(ft("undo", ft("edit-undo"))) - self.actionRedo.setIcon(ft("redo", ft("edit-redo"))) - self.actionCut.setIcon(ft("editcut", ft("edit-cut"))) - self.actionCopy.setIcon(ft("editcopy", ft("edit-copy"))) - self.actionPaste.setIcon(ft("editpaste", ft("edit-paste"))) - self.actionSelectAll.setIcon(ft("ark_selectall", ft("edit-select-all"))) - self.actionSave.setIcon(ft("filesave", ft("document-save"))) - self.actionSaveAs.setIcon(ft("filesaveas", ft("document-save-as"))) - self.actionBack.setIcon(ft("back", ft("go-previous"))) - self.actionForward.setIcon(ft("forward", ft("go-next"))) - self.actionGoToLineNumber.setIcon(ft("goto", ft("go-jump"))) - newTab = ft("tab_new", ft("tab-new")) + self.actionOpen.setIcon(icon("document-open")) + self.buttonFindPrev.setIcon(icon("go-previous")) + self.buttonFindNext.setIcon(icon("go-next")) + self.actionNewWindow.setIcon(icon("window-new")) + self.actionOpenWith.setIcon(icon("utilities-terminal")) + self.actionPrint.setIcon(icon("document-print")) + self.actionUndo.setIcon(icon("edit-undo")) + self.actionRedo.setIcon(icon("edit-redo")) + self.actionCut.setIcon(icon("edit-cut")) + self.actionCopy.setIcon(icon("edit-copy")) + self.actionPaste.setIcon(icon("edit-paste")) + self.actionSelectAll.setIcon(icon("edit-select-all")) + self.actionSave.setIcon(icon("document-save")) + self.actionSaveAs.setIcon(icon("document-save-as")) + self.actionBack.setIcon(icon("go-previous")) + self.actionForward.setIcon(icon("go-next")) + self.actionGoToLineNumber.setIcon(icon("go-jump")) + newTab = icon("tab-new") self.actionNewTab.setIcon(newTab) self.buttonNewTab.setIcon(newTab) - removeTab = ft("tab_remove", ft("window-close")) + removeTab = icon("tab-remove", icon("window-close")) self.actionCloseTab.setIcon(removeTab) self.buttonClose.setIcon(removeTab) + close = icon("window-close") + if close.isNull(): + self.buttonCloseFind.setText("x") + else: + self.buttonCloseFind.setIcon(close) # These icons have non-standard names and may only be available in crystal_project icons or a similar set. - self.binaryIcon = ft("binary") - self.zipIcon = ft("zip") - self.actionCommentOut.setIcon(ft("comment")) - self.actionUncomment.setIcon(ft("removecomment")) - self.buttonHighlightAll.setIcon(ft("highlight")) + self.actionCommentOut.setIcon(icon("comment-add")) + self.actionUncomment.setIcon(icon("comment-remove")) + self.actionDiffFile.setIcon(icon("file-diff")) + self.buttonHighlightAll.setIcon(icon("highlight")) self.aboutQtAction.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_TitleBarMenuButton)) @@ -500,8 +491,8 @@ def setupUi(self): self.actionCloseOther = QtWidgets.QAction(self.actionCloseTab.icon(), "Close Other Tabs", self) self.actionCloseRight = QtWidgets.QAction(self.actionCloseTab.icon(), "Close Tabs to the Right", self) self.actionRefreshTab = QtWidgets.QAction(self.actionRefresh.icon(), "&Refresh", self) - self.actionDuplicateTab = QtWidgets.QAction(ft("tab_duplicate"), "&Duplicate", self) - self.actionViewSource = QtWidgets.QAction(ft("html"), "View Page So&urce", self) + self.actionDuplicateTab = QtWidgets.QAction(icon("tab_duplicate"), "&Duplicate", self) + self.actionViewSource = QtWidgets.QAction(icon("html"), "View Page So&urce", self) # Extra keyboard shortcuts QtWidgets.QShortcut(QtGui.QKeySequence("Ctrl+="), self, self.increaseFontSize) @@ -529,6 +520,21 @@ def setupUi(self): self.statusbar.addWidget(self.loadingProgressBar) self.statusbar.addWidget(self.loadingProgressLabel) + # Find and initialize file parsers. + # Signals require some of the UI to be created already, but we need to do this before a tab is created. + self.fileParsers = [] + # Don't include the default parser in the list we iterate through to find compatible custom parsers, + # since we only use it as a fallback. + self.fileParserDefault = self._initFileParser(FileParser, FileParser.__name__) + self._prevParser = None + for module in utils.findModules("parsers"): + for name, cls in inspect.getmembers(module, lambda x: inspect.isclass(x) and + issubclass(x, FileParser) and + x not in (FileParser, AbstractExtParser)): + parser = self._initFileParser(cls, name) + if parser is not None: + self.fileParsers.append(parser) + # Add one of our special tabs. self.currTab = self.newTab() self.setNavigationMenus() @@ -548,8 +554,8 @@ def setupUi(self): # OS-specific hacks. # QSysInfo doesn't have productType until Qt5. if (Qt.IsPySide2 or Qt.IsPyQt5) and QtCore.QSysInfo.productType() in ("osx", "macos"): - self.buttonTabList.setIcon(ft("1downarrow1")) - + self.buttonTabList.setIcon(icon("1downarrow1")) + # 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) @@ -603,17 +609,20 @@ def customTextBrowserContextMenu(self, pos): pos : `QtCore.QPoint` Position of the right-click """ - menu = self.currTab.textBrowser.createStandardContextMenu(pos) + # Position workaround for https://bugreports.qt.io/browse/QTBUG-89439. + customPos = pos + QtCore.QPoint(self.currTab.textBrowser.horizontalScrollBar().value(), + self.currTab.textBrowser.verticalScrollBar().value()) + menu = self.currTab.textBrowser.createStandardContextMenu(customPos) actions = menu.actions() - # 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. + self.linkHighlighted = QtCore.QUrl(self.currTab.textBrowser.anchorAt(pos)) if self.linkHighlighted.isValid(): menu.insertAction(actions[0], self.actionOpenLinkNewWindow) menu.insertAction(actions[0], self.actionOpenLinkNewTab) - menu.insertAction(actions[0], self.actionOpenLinkWith) - menu.insertSeparator(actions[0]) - menu.addAction(self.actionSaveLinkAs) + # If this is a self-referential link, don't add certain actions. + if not self.linkHighlighted.hasFragment(): + menu.insertAction(actions[0], self.actionOpenLinkWith) + menu.insertSeparator(actions[0]) + menu.addAction(self.actionSaveLinkAs) else: menu.insertAction(actions[0], self.actionBack) menu.insertAction(actions[0], self.actionForward) @@ -633,7 +642,8 @@ def customTextBrowserContextMenu(self, pos): menu.addAction(self.actionViewSource) actions[0].setIcon(self.actionCopy.icon()) actions[3].setIcon(self.actionSelectAll.icon()) - menu.exec_(self.currTab.textBrowser.mapToGlobal(pos)) + menu.exec_(self.currTab.textBrowser.mapToGlobal( + pos + QtCore.QPoint(self.currTab.textBrowser.lineNumbers.width(), 0))) del actions, menu @Slot(QtCore.QPoint) @@ -652,7 +662,7 @@ def customTextEditorContextMenu(self, pos): actions[3].setIcon(self.actionCut.icon()) actions[4].setIcon(self.actionCopy.icon()) actions[5].setIcon(self.actionPaste.icon()) - actions[6].setIcon(QtGui.QIcon.fromTheme("edit-delete")) + actions[6].setIcon(utils.icon("edit-delete")) actions[8].setIcon(self.actionSelectAll.icon()) path = self.currTab.getCurrentPath() if path: @@ -996,9 +1006,14 @@ def newWindow(self): return self.app.newWindow() @Slot(bool) - def newTab(self, *args): + def newTab(self, checked=False, focus=True): """ Create a new tab. + :Parameters: + checked : `bool` + For signal only + focus : `bool` + If True, change focus to this tab :Returns: New tab :Rtype: @@ -1007,9 +1022,12 @@ def newTab(self, *args): newTab = BrowserTab(self.tabWidget) newTab.highlighter = highlighter.Highlighter(newTab.getCurrentTextWidget().document(), self.masterHighlighters[None]) + newTab.parser = self.fileParserDefault newTab.textBrowser.zoomIn(self.preferences['fontSizeAdjust']) newTab.textEditor.zoomIn(self.preferences['fontSizeAdjust']) - self.tabWidget.setCurrentIndex(self.tabWidget.addTab(newTab, "(Untitled)")) + idx = self.tabWidget.addTab(newTab, "(Untitled)") + if focus: + self.tabWidget.setCurrentIndex(idx) self.addressBar.setFocus() # Add to menu of tabs. @@ -1101,7 +1119,7 @@ def openRecent(self, url): """ self.setSource(url, newTab=True) - def saveFile(self, filePath, fileFormat=FILE_FORMAT_NONE, tab=None, _checkUsd=True): + def saveFile(self, filePath, fileFormat=FILE_FORMAT_NONE, tab=None): """ Save the current file as the given filePath. :Parameters: @@ -1111,93 +1129,89 @@ def saveFile(self, filePath, fileFormat=FILE_FORMAT_NONE, tab=None, _checkUsd=Tr 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: If saved or not. :Rtype: `bool` """ - logger.debug("Checking file status") - path = QtCore.QFile(filePath) - if path.exists() and not QtCore.QFileInfo(path).isWritable(): - self.showCriticalMessage("The file is not writable.\n{}".format(filePath), title="Save File") - return False - logger.debug("Writing file") - self.setOverrideCursor() - tab = tab or self.currTab + try: + logger.debug("Checking file status: %s", filePath) + qFile = QtCore.QFile(filePath) + fileInfo = QtCore.QFileInfo(qFile) + if qFile.exists() and not fileInfo.isWritable(): + self.showCriticalMessage("The file is not writable.\n{}".format(filePath), title="Save File") + 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[1:] in USD_CRATE_EXTS: - crate = True - elif ext[1:] in USD_AMBIGUOUS_EXTS and (fileFormat == FILE_FORMAT_USDC or ( + _, ext = os.path.splitext(filePath) + if 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 = utils.mkstemp(suffix="." + USD_AMBIGUOUS_EXTS[0], dir=self.app.tmpDir) - os.close(fd) - status = 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") - except Exception: - logger.exception("Save failed on USD crate conversion") - self.restoreOverrideCursor() - self.showCriticalMessage("The file could not be saved due to a usdcat error!", - traceback.format_exc(), - "Save File") - else: - status = True - tab.fileFormat = FILE_FORMAT_USDC - self.restoreOverrideCursor() - QtCore.QTimer.singleShot(10, partial(self.fileSystemWatcher.addPath, filePath)) + # Saving as crate file with generic (i.e. .usd, not .usdc) extension. + # Set the URL to ensure we pick up the crate parser instead of the generic USD parser. + url = utils.strToUrl(filePath + "?binary=1") else: - self.restoreOverrideCursor() - os.remove(tmpPath) - return status - elif fileFormat == FILE_FORMAT_USDZ: - # TODO: usdz support - self.restoreOverrideCursor() - self.showCriticalMessage("Writing usdz files is not yet supported!", title="Save File") - return False - elif path.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Text): + url = utils.strToUrl(filePath) + + for parser in self.fileParsers: + if parser.acceptsFile(fileInfo, url): + break + else: + parser = self.fileParserDefault + # Don't monitor changes to this file while we save it. self.fileSystemWatcher.removePath(filePath) - try: - out = QtCore.QTextStream(path) - 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[1:] in USD_AMBIGUOUS_EXTS + USD_ASCII_EXTS: - tab.fileFormat = FILE_FORMAT_USDA - else: - tab.fileFormat = FILE_FORMAT_NONE - self.restoreOverrideCursor() - finally: - path.close() + parser.write(qFile, filePath, tab, self.app.tmpDir) # This sometimes triggers too early if we're saving the file, prompting you to reload your own changes. # Delay re-watching the file by a millisecond. # Don't watch the file if this is a temp .usd file for crate conversion. - if _checkUsd: - QtCore.QTimer.singleShot(10, partial(self.fileSystemWatcher.addPath, filePath)) - + QtCore.QTimer.singleShot(10, partial(self.fileSystemWatcher.addPath, filePath)) tab.setDirty(False) - return True - else: + except SaveFileError as e: self.restoreOverrideCursor() - self.showCriticalMessage("The file could not be opened for saving!", title="Save File") + self.showCriticalMessage(str(e), e.details, title="Save File") + return False + except Exception: + self.restoreOverrideCursor() + self.showCriticalMessage("Failed to save file!", traceback.format_exc(), title="Save File") return False - def getSaveAsPath(self, path=None, tab=None): + # TODO: Saving .txt as .rdlb doesn't update binary icon until refreshing, but .txt to .usdc does. + self.restoreOverrideCursor() + return True + + def updateTabParser(self, tab, fileInfo, link, fileFormat=None): + """ Update the file parser a tab is using based on the given file. + + :Parameters: + tab : `BrowserTab` + Tab + fileInfo : `QtCore.QFileInfo` + File info object + link : `QtCore.QUrl` + Link to file, potentially with query parameters + fileFormat : `int` | None + The parser must match this file format, if not None. + """ + if tab == self.currTab: + self._prevParser = tab.parser + + for parser in self.fileParsers: + # TODO: Improve UsdParser so the FILE_FORMAT_USDC check isn't needed here. + if parser.acceptsFile(fileInfo, link) and ( + fileFormat is None or fileFormat == parser.fileFormat or + (fileFormat == FILE_FORMAT_USDC and parser.fileFormat in (FILE_FORMAT_USD, FILE_FORMAT_USDA))): + logger.debug("Using parser %s", parser.__class__.__name__) + tab.parser = parser + break + else: + # No matching file parser found. + logger.debug("Using default parser") + tab.parser = self.fileParserDefault + + def getSaveAsPath(self, path=None, tab=None, fileFilter=None): """ Get a path from the user to save an arbitrary file as. :Parameters: @@ -1205,38 +1219,49 @@ def getSaveAsPath(self, path=None, tab=None): Path to use for selecting default file extension filter. tab : `BrowserTab` | None Tab that path is for. + fileFilter : `str` | None + File name filter to pre-select :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) :Rtype: (`str`|None, `int`) """ - fileFormat = FILE_FORMAT_NONE - if path: - startFilter = FILE_FILTER[FILE_FORMAT_USD if utils.isUsdFile(path) else FILE_FORMAT_NONE] + if path and fileFilter is None: + # Find the first file filter that matches the current file extension. + _, ext = os.path.splitext(path) + extRe = re.compile(r'\*\.{}\b'.format(ext)) + for nameFilter in FILE_FILTER: + if extRe.search(nameFilter): + fileFilter = nameFilter + break + else: + fileFilter = FILE_FILTER[FILE_FORMAT_NONE] else: tab = tab or self.currTab path = tab.getCurrentPath() - startFilter = FILE_FILTER[tab.fileFormat] + if fileFilter is None: + fileFilter = FILE_FILTER[tab.fileFormat] - dlg = FileDialog(self, "Save File As", path or self.lastOpenFileDir, FILE_FILTER, startFilter, + dlg = FileDialog(self, "Save File As", path or self.lastOpenFileDir, FILE_FILTER, fileFilter, self.preferences['showHiddenFiles']) dlg.setAcceptMode(dlg.AcceptSave) dlg.setFileMode(dlg.AnyFile) if dlg.exec_() != dlg.Accepted: - return None, fileFormat + return None, FILE_FORMAT_NONE filePaths = dlg.selectedFiles() if not filePaths or not filePaths[0]: - return None, fileFormat + return None, FILE_FORMAT_NONE filePath = filePaths[0] selectedFilter = dlg.selectedNameFilter() 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]: + # Find the file format based on the selected file filter. + fileFormat = FILE_FILTER.index(selectedFilter) + if fileFormat == FILE_FORMAT_USD: 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. @@ -1246,33 +1271,22 @@ def getSaveAsPath(self, path=None, tab=None): 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, tab) - elif selectedFilter == FILE_FILTER[FILE_FORMAT_USDC]: - fileFormat = FILE_FORMAT_USDC + self.showCriticalMessage("Please enter a valid extension for {}".format(selectedFilter)) + return self.getSaveAsPath(filePath, tab, selectedFilter) + elif fileFormat != FILE_FORMAT_NONE: + validExts = [x.lstrip("*") for x in selectedFilter.rsplit("(", 1)[1].rsplit(")", 1)[0].split()] if ext not in validExts: - self.showCriticalMessage("Please enter a valid extension for a usdc file") - return self.getSaveAsPath(filePath, tab) - elif selectedFilter == FILE_FILTER[FILE_FORMAT_USDZ]: - fileFormat = FILE_FORMAT_USDZ - if ext not in validExts: - if len(validExts) == 1: + if len(validExts) == 1 and validExts[0]: # Just add the extension since it can't be anything else. - filePath += "." + validExts[0] + filePath += validExts[0] modifiedExt = True + elif fileFormat == FILE_FORMAT_TXT: + # Allow any (or no) extension for plain text files. + # Set the file format back to none since we don't treat these any differently yet. + fileFormat = FILE_FORMAT_NONE else: - # Fallback in case we ever allow more extensions. - self.showCriticalMessage("Please enter a valid extension for a usdz file") - return self.getSaveAsPath(filePath, tab) - elif len(validExts) == 1 and ext not in validExts and validExts[0]: - # Just add the extension since it can't be anything else. - filePath += "." + validExts[0] - modifiedExt = True + self.showCriticalMessage("Please enter a valid extension for {}".format(selectedFilter)) + return self.getSaveAsPath(filePath, tab, selectedFilter) info = QtCore.QFileInfo(filePath) self.lastOpenFileDir = info.absoluteDir().path() @@ -1286,7 +1300,7 @@ def getSaveAsPath(self, path=None, tab=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, tab) + return self.getSaveAsPath(path, tab, selectedFilter) # Now we have a valid path to save as. return filePath, fileFormat @@ -1315,20 +1329,23 @@ def saveFileAs(self, checked=False, tab=None): fileName = fileInfo.fileName() ext = fileInfo.suffix() self.tabWidget.setTabText(idx, fileName) - if tab.fileFormat == FILE_FORMAT_USDC: - self.tabWidget.setTabIcon(idx, self.binaryIcon) - self.tabWidget.setTabToolTip(idx, "{} - {} (binary)".format(fileName, filePath)) - 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)) url = QtCore.QUrl.fromLocalFile(filePath) tab.updateHistory(url) tab.updateFileStatus() self.updateRecentMenus(url, url.toString()) self.setHighlighter(ext, tab=tab) + + # Get the new parser if we changed from usdc to usda, usd(a) to usdc, etc. + self.updateTabParser(tab, fileInfo, url, fileFormat) + + # Update UI items that depend on the parser or file format. + if tab.parser.binary: + self.tabWidget.setTabToolTip(idx, "{} - {} (binary)".format(fileName, filePath)) + elif tab.fileFormat == FILE_FORMAT_USDZ: + self.tabWidget.setTabToolTip(idx, "{} - {} (zip)".format(fileName, filePath)) + else: + self.tabWidget.setTabToolTip(idx, "{} - {}".format(fileName, filePath)) + self.tabWidget.setTabIcon(idx, tab.parser.icon) if tab == self.currTab: self.updateButtons() return True @@ -2320,6 +2337,7 @@ def duplicateTab(self): # Open the same document as the original tab. if not url.isEmpty(): + # TODO: Should we copy some of the data instead of reloading from disk? self.setSource(url) @Slot() @@ -2476,8 +2494,8 @@ def diffFile(self): Allows you to make comparisons using a temporary file, without saving your changes. """ path = self.currTab.getCurrentPath() - if self.currTab.fileFormat == FILE_FORMAT_USDC: - path = self.getUsdCrateCachePath(path) + if self.currTab.parser.binary: + path = self.getCachePath(path, self.currTab.parser) fd, tmpPath = utils.mkstemp(suffix=QtCore.QFileInfo(path).fileName(), dir=self.app.tmpDir) with os.fdopen(fd, 'w') as f: @@ -2720,7 +2738,6 @@ def hoverUrl(self, link): self.statusbar.showMessage("{} (binary)".format(path)) else: self.statusbar.showMessage(path) - self.linkHighlighted = link @Slot(str) def onFileChange(self, path): @@ -2824,92 +2841,48 @@ def overrideCursor(self, cursor=QtCore.Qt.WaitCursor): finally: self.restoreOverrideCursor() - def getUsdCrateCachePath(self, fileStr): - """ Cache a converted Crate file so we can use it again later without reconversion if it's still newer. + def getCachePath(self, fileStr, fileParser): + """ Cache a converted binary file so we can use it again later without reconversion if it's still newer. :Parameters: fileStr : `str` - USD file path + Binary file path + fileParser : `AbstractExtParser` + File parser :Returns: Cache file path :Rtype: `str` """ - if (fileStr in self.app.usdCache and - QtCore.QFileInfo(self.app.usdCache[fileStr]).lastModified() > QtCore.QFileInfo(fileStr).lastModified()): - usdPath = self.app.usdCache[fileStr] - logger.debug("Reusing cached file %s for binary file %s", usdPath, fileStr) + if (fileStr in self.app.fileCache and + QtCore.QFileInfo(self.app.fileCache[fileStr]).lastModified() > QtCore.QFileInfo(fileStr).lastModified()): + path = self.app.fileCache[fileStr] + logger.debug("Reusing cached file %s for binary file %s", path, fileStr) else: - logger.debug("Converting binary USD file to ASCII representation...") - usdPath = utils.generateTemporaryUsdFile(fileStr, self.app.tmpDir) - self.app.usdCache[fileStr] = usdPath - return usdPath - - def readUsdCrateFile(self, fileStr): - """ Read in a USD crate file via usdcat converting a temp file to ASCII. + logger.debug("Converting binary file to ASCII representation...") + self.app.fileCache[fileStr] = path = fileParser.generateTempFile(fileStr, self.app.tmpDir) + return path - :Parameters: - fileStr : `str` - USD file path - :Returns: - ASCII file text - :Rtype: - `bool` - """ - self.currTab.fileFormat = FILE_FORMAT_USDC - self.tabWidget.setTabIcon(self.tabWidget.currentIndex(), self.binaryIcon) + def readBinaryFile(self, fileStr, fileParser): + """ Read in a binary file, converting to a temp ASCII file. - usdPath = self.getUsdCrateCachePath(fileStr) - with open(usdPath) as f: - fileText = f.readlines() - - # TODO: Remove files from the cache once we reach a certain file size threshold? - #os.remove(usdPath) - - return fileText - - def readUsdzFile(self, fileStr, layer=None): - """ Read in a USD zip (.usdz) file via usdzip, uncompressing to a temp directory. + Used by file parsers. :Parameters: fileStr : `str` - USD file path - layer : `str` | None - Default layer within file (e.g. the portion within the square brackets here: - @foo.usdz[path/to/file/within/package.usd]@) + Binary file path + fileParser : `AbstractExtParser` + File parser :Returns: - Destination file + ASCII file text :Rtype: `str` - :Raises zipfile.BadZipfile: - For bad ZIP files - :Raises zipfile.LargeZipFile: - When a ZIP file would require ZIP64 functionality but that has not been enabled - :Raises ValueError: - If default layer not found - """ - # Cache the unzipped directory so we can use it again later without reconversion if it's still newer. - if (fileStr in self.app.usdCache and - QtCore.QFileInfo(self.app.usdCache[fileStr]).lastModified() > QtCore.QFileInfo(fileStr).lastModified()): - usdPath = self.app.usdCache[fileStr] - logger.debug("Reusing cached directory %s for zip file %s", usdPath, fileStr) - else: - logger.debug("Uncompressing usdz file...") - usdPath = utils.unzip(fileStr, self.app.tmpDir) - self.app.usdCache[fileStr] = usdPath - - # Check for a nested usdz reference (e.g. @set.usdz[areas/shire.usdz[architecture/BilboHouse/Table.usd]]@) - 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, fileStr) - return self.readUsdzFile(dest, layer2) - - args = "?extractedDir={}".format(usdPath) - return utils.getUsdzLayer(usdPath, layer, fileStr) + args + """ + with open(self.getCachePath(fileStr, fileParser)) as f: + return f.readlines() @Slot(QtCore.QUrl) - def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos=0, tab=None): + def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos=0, tab=None, focus=True): """ Create a new tab or update the current one. Process a file to add links. Send the formatted text to the appropriate tab. @@ -2930,6 +2903,8 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos Vertical scroll bar position. tab : `BrowserTab` | None Existing tab to load in. Defaults to current tab. Ignored if newTab=True. + focus : `bool` + If True, change focus to this tab. Currently only applies when creating new tabs. :Returns: True if the file was loaded successfully (or was dirty but the user cancelled the save prompt). :Rtype: @@ -2940,39 +2915,51 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos 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. - # Should this instead be relative based on the currently active tab's directory? - fileInfo = QtCore.QFileInfo(link.toLocalFile()) - absFilePath = fileInfo.absoluteFilePath() - if not absFilePath: - 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. + # parameters instead of reloading the file. QFileInfo gets confused by fragments, so process this first. if link.hasFragment(): + print("has fragment") queryLink = utils.urlFragmentToQuery(link) - tab = tab or self.currTab if newTab: - return self.setSource(queryLink, isNewFile, newTab, hScrollPos, vScrollPos, tab) + return self.setSource(queryLink, isNewFile, newTab, hScrollPos, vScrollPos, tab, focus) if queryLink.hasQuery(): # Scroll to line number. line = utils.queryItemValue(queryLink, "line") if line is not None: + tab = tab or self.currTab 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) + # HACK to interpret a fragment URL where the # was encoded (Qt5) or not recognized as a fragment (Qt4). + # This happens when using Qt's "Copy Link Location" context menu action with a fragment-based URL and pasting + # it in the address bar. + fullUrlStr = link.toString() + if "%23?" in fullUrlStr: # Qt5 + logger.debug("Converting link with encoded '#' and query string: %s", fullUrlStr) + link = utils.strToUrl(fullUrlStr.replace("%23?", "?", 1)) + return self.setSource(link, isNewFile, newTab, hScrollPos, vScrollPos, tab, focus) + elif (Qt.IsPyQt4 or Qt.IsPySide) and "#?" in fullUrlStr: + logger.debug("Converting link with '#?': %s", fullUrlStr) + link = utils.strToUrl(fullUrlStr.replace("#?", "?", 1)) + return self.setSource(link, isNewFile, newTab, hScrollPos, vScrollPos, tab, focus) + + # TODO: When given a relative path here, this expands based on the directory the tool was launched from. + # Should this instead be relative based on the currently active tab's directory? + localFile = link.toLocalFile() + fileInfo = QtCore.QFileInfo(localFile) + absFilePath = fileInfo.absoluteFilePath() + if not absFilePath: + logger.warning("Unable to determine file path from %s", link) + return self.setSourceFinish(tab=tab) + + nativeAbsPath = QtCore.QDir.toNativeSeparators(absFilePath) + fileExists = True # Assume the file exists for now. + logger.debug("Setting source to %s (local file path: %s) %s %s", fullUrlStr, localFile, nativeAbsPath, link) + self.setOverrideCursor() try: # If the filename contains an asterisk, make sure there is at least one valid file. @@ -3020,13 +3007,13 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos return self.setSourceFinish(tab=tab) if multFiles is not None: - self.setSources(multFiles, tab=tab) + self.setSources(multFiles, tab=tab, focus=focus) return self.setSourceFinish(tab=tab) # Open this in a new tab or not? tab = tab or self.currTab if (newTab or (isNewFile and self.preferences['newTab'])) and not tab.isNewTab: - tab = self.newTab() + tab = self.newTab(focus=focus) 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. @@ -3055,24 +3042,15 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos try: if self.validateFileSize(fileInfo): - self._prevParser = tab.parser - for parser in self.fileParsers: - if parser.acceptsFile(fileInfo, link): - logger.debug("Using parser %s", parser.__class__.__name__) - tab.parser = parser - break - else: - # No matching file parser found. - 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), tab=tab) - else: - logger.debug("Using default parser") - tab.parser = self.fileParserDefault + self.updateTabParser(tab, fileInfo, link) + parser = tab.parser + if ext in USD_ZIP_EXTS: + layer = utils.queryItemValue(link, "layer") + dest = parser.read(absFilePath, layer, self.app.fileCache, self.app.tmpDir) + self.restoreOverrideCursor() + self.loadingProgressBar.setVisible(False) + self.loadingProgressLabel.setVisible(False) + return self.setSource(utils.strToUrl(dest), tab=tab, focus=focus) # Stop Loading Tab stops the expensive parsing of the file # for links, checking if the links actually exist, etc. @@ -3082,6 +3060,7 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos parser.parse(nativeAbsPath, fileInfo, link) tab.fileFormat = parser.fileFormat + self.tabWidget.setTabIcon(idx, parser.icon) self.setHighlighter(ext, tab=tab) logger.debug("Setting HTML") tab.textBrowser.setHtml(parser.html) @@ -3089,7 +3068,7 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos tab.textEditor.setPlainText("".join(parser.text)) truncated = parser.truncated warning = parser.warning - parser._cleanup() + parser.cleanup() else: self.loadingProgressBar.setVisible(False) self.loadingProgressLabel.setVisible(False) @@ -3179,7 +3158,7 @@ def setSourceFinish(self, success=True, warning=None, details=None, tab=None): self.showWarningMessage(warning, details) return success - def setSources(self, files, tab=None): + def setSources(self, files, tab=None, focus=True): """ Open multiple files in new tabs. :Parameters: @@ -3187,11 +3166,13 @@ def setSources(self, files, tab=None): List of string-based paths to open tab : `BrowserTab` | None Tab this may be opening from. Useful for path expansion. + focus : `bool` + Change focus to the new tabs as they are created. """ tab = tab or self.currTab prevPath = tab.getCurrentPath() for path in files: - self.setSource(utils.expandUrl(path, prevPath), newTab=True, tab=tab) + self.setSource(utils.expandUrl(path, prevPath), newTab=True, tab=tab, focus=focus) @Slot(int) def setLoadingProgress(self, value): @@ -3423,7 +3404,7 @@ def updateEditButtons(self): self.actionUndo.setEnabled(self.currTab.textEditor.document().isUndoAvailable()) self.actionRedo.setEnabled(self.currTab.textEditor.document().isRedoAvailable()) self.actionFind.setText("&Find/Replace...") - self.actionFind.setIcon(QtGui.QIcon.fromTheme("edit-find-replace")) + self.actionFind.setIcon(utils.icon("edit-find-replace")) self.actionCommentOut.setEnabled(True) self.actionUncomment.setEnabled(True) self.actionIndent.setEnabled(True) @@ -3437,7 +3418,7 @@ def updateEditButtons(self): self.actionCut.setEnabled(False) self.actionPaste.setEnabled(False) self.actionFind.setText("&Find...") - self.actionFind.setIcon(QtGui.QIcon.fromTheme("edit-find")) + self.actionFind.setIcon(utils.icon("edit-find")) self.actionCommentOut.setEnabled(False) self.actionUncomment.setEnabled(False) self.actionIndent.setEnabled(False) @@ -4003,6 +3984,7 @@ def __init__(self, parent=None): """ super(TextBrowser, self).__init__(parent) self.lineNumbers = LineNumbers(self) + self._mouseStartPos = QtCore.QPoint(0, 0) def resizeEvent(self, event): """ Ensure line numbers resize properly when this resizes. @@ -4024,9 +4006,22 @@ def copySelectionToClipboard(self): """ cursor = self.textCursor() if cursor.hasSelection(): - selection = cursor.selectedText().replace(u'\u2029', '\n') clipboard = QtWidgets.QApplication.clipboard() - clipboard.setText(selection, clipboard.Selection) + if clipboard.supportsSelection(): + selection = cursor.selectedText().replace(u'\u2029', '\n') + clipboard.setText(selection, clipboard.Selection) + + def mousePressEvent(self, event): + """ Store the starting mouse position so that on mouse release we can determine if it was an intentional mouse + move to highlight text or a click that may have drifted a pixel or two. + + :Parameters: + event : `QtGui.QMouseEvent` + Mouse press event + """ + super(TextBrowser, self).mousePressEvent(event) + if event.button() == QtCore.Qt.LeftButton: + self._mouseStartPos = event.pos() def mouseReleaseEvent(self, event): """ Add support for middle mouse button clicking of links. @@ -4035,16 +4030,28 @@ def mouseReleaseEvent(self, event): event : `QtGui.QMouseEvent` Mouse release event """ - window = self.window() - link = window.linkHighlighted - if link.isValid(): - if event.button() & QtCore.Qt.LeftButton: + link = self.anchorAt(event.pos()) + if link: + url = QtCore.QUrl(link) + modifiers = event.modifiers() + 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. - if not self.textCursor().hasSelection(): - window.setSource(link, newTab=event.modifiers() & QtCore.Qt.ControlModifier) + # Allow moving an arbitrary leeway of 3 pixels during the click. + if (event.pos() - self._mouseStartPos).manhattanLength() <= 3: + if modifiers == QtCore.Qt.NoModifier: + self.window().setSource(url) + elif modifiers == QtCore.Qt.ControlModifier: + self.window().setSource(url, newTab=True, focus=False) + elif modifiers == QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier: + self.window().setSource(url, newTab=True, focus=True) + elif modifiers == QtCore.Qt.ShiftModifier: + window = self.window().newWindow() + window.setSource(url) + return elif event.button() & QtCore.Qt.MidButton: - window.setSource(link, newTab=True) + # Don't focus on new tab unless Shift is used. + self.window().setSource(url, newTab=True, focus=modifiers & QtCore.Qt.ShiftModifier) + return self.copySelectionToClipboard() @@ -4344,6 +4351,7 @@ def __init__(self, parent=None): self.history = [] # List of FileStatus objects self.historyIndex = -1 # First file opened will be 0. self.fileFormat = FILE_FORMAT_NONE # Used to differentiate between things like usda and usdc. + self.highlighter = None # Syntax highlighter self.parser = None # File parser for the currently active file type, used to add extra Commands menu actions. font = parent.font() prefs = parent.window().preferences @@ -4645,7 +4653,7 @@ def setDirty(self, dirty=True): else: fileName = QtCore.QFileInfo(path).fileName() tipSuffix = " - {}".format(path) - if self.fileFormat == FILE_FORMAT_USDC: + if self.parser.binary: tipSuffix += " (binary)" elif self.fileFormat == FILE_FORMAT_USDZ: tipSuffix += " (zip)" @@ -4759,8 +4767,8 @@ class App(QtCore.QObject): # Temporary directory for operations like converting crate to ASCII. tmpDir = None - # Mapping of converted USD file paths to avoid reconversion if the cached file is still newer. - usdCache = {} + # Mapping of converted file paths to avoid reconversion if the cached file is still newer. + fileCache = {} # The widget class to build the application's main window. uiSource = UsdMngrWindow @@ -4773,7 +4781,7 @@ class App(QtCore.QObject): def __init__(self): super(App, self).__init__() - self.appPath = sys.argv[0] + self.appPath = os.path.abspath(sys.argv[0]) self.appName = os.path.basename(self.appPath) self.opts = { 'dir': os.getcwd(), @@ -4790,14 +4798,9 @@ def run(self): group = parser.add_mutually_exclusive_group() group.add_argument("-theme", choices=["light", "dark"], help="Override the user theme preference. Use the Preferences dialog to save this setting)") - # Legacy flag, now equivalent to "-theme dark" - group.add_argument("-dark", action="store_true", help=argparse.SUPPRESS) parser.add_argument("-info", action="store_true", help="Log info messages") parser.add_argument("-debug", action="store_true", help="Log debugging messages") results = parser.parse_args() - if results.dark: - results.theme = "dark" - logger.warning('The -dark flag has been deprecated. Please use "-theme dark" instead.') self.opts['info'] = results.info self.opts['debug'] = results.debug self.opts['theme'] = results.theme @@ -4830,16 +4833,25 @@ def run(self): logger.exception("Failed to load app config from %s", appConfigPath) appConfig = {} + # Find the default icons if this was pip installed with the defaults. + searchPaths = appConfig.get("themeSearchPaths", []) + try: + import crystal_small + except ImportError: + logger.debug("Unable to import crystal_small. If icons are missing, check your config and installation.") + else: + searchPaths.append(crystal_small.PATH) + # Define app defaults that we use when the user preference doesn't exist and when resetting preferences in the # Preferences dialog. self.DEFAULTS = { 'autoCompleteAddressBar': True, 'autoIndent': True, 'defaultPrograms': appConfig.get("defaultPrograms", {}), - 'diffTool': appConfig.get("diffTool", "xdiff"), + 'diffTool': appConfig.get("diffTool", "FC" if os.name == "nt" else "xdiff"), 'findMatchCase': False, 'fontSizeAdjust': 0, - 'iconTheme': appConfig.get("iconTheme", "crystal_project"), + 'iconThemes': appConfig.get("iconThemes", {}), 'includeVisible': True, 'lastOpenWithStr': "", 'lineLimit': LINE_LIMIT, @@ -4851,12 +4863,27 @@ def run(self): 'syntaxHighlighting': True, 'tabSpaces': 4, 'teletype': True, - 'textEditor': os.getenv("EDITOR", appConfig.get("textEditor", "nedit")), + 'textEditor': os.getenv("EDITOR", appConfig.get("textEditor", "idle" if os.name == "nt" else "nedit")), 'theme': None, - 'themeSearchPaths': appConfig.get("themeSearchPaths", []), + 'themeSearchPaths': searchPaths, 'usdview': appConfig.get("usdview", "usdview"), 'useSpaces': True, } + + # Set up icon defaults before loading any windows. + if self.DEFAULTS['themeSearchPaths']: + # Ensure themeSearchPaths trumps anything in the default search paths. + searchPaths = self.DEFAULTS['themeSearchPaths'] + [x for x in QtGui.QIcon.themeSearchPaths() + if x not in self.DEFAULTS['themeSearchPaths']] + logger.debug("Theme search paths: %s", searchPaths) + QtGui.QIcon.setThemeSearchPaths(searchPaths) + + # Set the preferred theme name for some non-standard icons. + for theme in ("light", "dark"): + if theme not in self.DEFAULTS['iconThemes']: + self.DEFAULTS['iconThemes'][theme] = appConfig.get("iconTheme", "crystal_project") + QtGui.QIcon.setThemeName(self.DEFAULTS['iconThemes'][results.theme or "light"]) + utils.ICON_ALIASES.update(appConfig.get("iconAliases", {})) # Documentation URL. self.appURL = appConfig.get("appURL", "https://github.com/dreamworksanimation/usdmanager") @@ -4864,6 +4891,10 @@ def run(self): # Create a main window. window = self.newWindow() + # Create a temp directory for cache-like files before opening any files. + self.tmpDir = tempfile.mkdtemp(prefix=self.appName) + logger.debug("Temp directory: %s", self.tmpDir) + # Open any files passed in by the user. if results.fileName: window.setSources(results.fileName) @@ -4920,10 +4951,6 @@ def mainLoop(self): """ Start the application loop. """ if not App._eventLoopStarted: - # Create a temp directory for cache-like files. - if self.tmpDir is None: - self.tmpDir = tempfile.mkdtemp(prefix=self.appName) - logger.debug("Temp directory: %s", self.tmpDir) App._eventLoopStarted = True # Let the python interpreter continue running every 500 ms so we can cleanly kill the app on a diff --git a/usdmanager/config.json b/usdmanager/config.json index 441934c..9b87c61 100644 --- a/usdmanager/config.json +++ b/usdmanager/config.json @@ -1,4 +1,9 @@ { "defaultPrograms": { + }, + "themeSearchPaths": [], + "iconThemes": { + "light": "crystal_project", + "dark": "crystal_project" } } diff --git a/usdmanager/constants.py b/usdmanager/constants.py index 7958dc5..3590176 100644 --- a/usdmanager/constants.py +++ b/usdmanager/constants.py @@ -32,16 +32,18 @@ "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)), + "Text Files (*.html *.json *.log *.py *.txt *.xml *.yaml *.yml)", "All Files (*)" ) # Format of the currently active file. Also, the index in the file filter list for that type. # Used for things such as differentiating between file types when using the generic .usd extension. -FILE_FORMAT_USD = 0 # Generic USD file (usda or usdc) +FILE_FORMAT_USD = 0 # Generic USD file (usda or usdc) FILE_FORMAT_USDA = 1 # ASCII USD file FILE_FORMAT_USDC = 2 # Binary USD crate file FILE_FORMAT_USDZ = 3 # Zip-compressed USD package -FILE_FORMAT_NONE = 4 # Generic text file +FILE_FORMAT_TXT = 4 # Plain text file +FILE_FORMAT_NONE = 5 # Generic file, presumably plain text # Default template for display files with links. # When dark theme is enabled, this is overridden in __init__.py. @@ -54,7 +56,8 @@ # Set a length limit on parsing for links and syntax highlighting on long lines. 999 chosen semi-arbitrarily to speed # up things like crate files with really long timeSamples lines that otherwise lock up the UI. -# TODO: Potentially truncate the display of long lines, too, since it can slow down interactivity of the Qt UI. Maybe make it a [...] link to display the full line again? +# TODO: Potentially truncate the display of long lines, too, since it can slow down interactivity of the Qt UI. +# Maybe make it a [...] link to display the full line again? LINE_CHAR_LIMIT = 999 # Truncate loading files with more lines than this. diff --git a/usdmanager/find_dialog.py b/usdmanager/find_dialog.py index 660eb0a..5980372 100644 --- a/usdmanager/find_dialog.py +++ b/usdmanager/find_dialog.py @@ -17,9 +17,9 @@ """ from Qt.QtCore import Slot from Qt.QtWidgets import QDialog, QStatusBar -from Qt.QtGui import QIcon, QTextDocument +from Qt.QtGui import QTextDocument -from .utils import loadUiWidget +from .utils import icon, loadUiWidget class FindDialog(QDialog): @@ -43,8 +43,8 @@ def setupUi(self): self.baseInstance = loadUiWidget('find_dialog.ui', self) self.statusBar = QStatusBar(self) self.verticalLayout.addWidget(self.statusBar) - self.findBtn.setIcon(QIcon.fromTheme("edit-find")) - self.replaceBtn.setIcon(QIcon.fromTheme("edit-find-replace")) + self.findBtn.setIcon(icon("edit-find")) + self.replaceBtn.setIcon(icon("edit-find-replace")) def connectSignals(self): """ Connect signals to slots. @@ -107,7 +107,7 @@ def updateForEditMode(self, edit): self.buttonBox2.setVisible(not edit) if edit: self.setWindowTitle("Find/Replace") - self.setWindowIcon(QIcon.fromTheme("edit-find-replace")) + self.setWindowIcon(icon("edit-find-replace")) else: self.setWindowTitle("Find") - self.setWindowIcon(QIcon.fromTheme("edit-find")) + self.setWindowIcon(icon("edit-find")) diff --git a/usdmanager/highlighters/lua.py b/usdmanager/highlighters/lua.py index d181f6e..9167d33 100644 --- a/usdmanager/highlighters/lua.py +++ b/usdmanager/highlighters/lua.py @@ -13,6 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. # +""" +Lua syntax highlighter +""" from Qt import QtCore, QtGui from ..highlighter import MasterHighlighter @@ -24,7 +27,7 @@ class MasterLuaHighlighter(MasterHighlighter): extensions = ["lua"] comment = "--" multilineComment = ("--[[", "]]") - + def getRules(self): return [ [ # Symbols diff --git a/usdmanager/include_panel.py b/usdmanager/include_panel.py index eb86cce..56aea09 100644 --- a/usdmanager/include_panel.py +++ b/usdmanager/include_panel.py @@ -13,13 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. # +""" Left-hand side file browser. +""" import os from Qt import QtCore, QtWidgets from Qt.QtCore import Signal, Slot -from Qt.QtGui import QIcon -from .utils import expandPath, overrideCursor +from .utils import expandPath, icon, overrideCursor class IncludePanel(QtWidgets.QWidget): @@ -63,16 +64,16 @@ def __init__(self, path="", filter="", selectedFilter="", parent=None): self.listView = QtWidgets.QListView(self) self.fileTypeLabelFiller = QtWidgets.QLabel(self) self.fileTypeComboFiller = QtWidgets.QLabel(self) - self.buttonOpen = QtWidgets.QPushButton(QIcon.fromTheme("document-open"), "Open", self) + self.buttonOpen = QtWidgets.QPushButton(icon("document-open"), "Open", self) self.buttonOpen.setEnabled(False) # Item settings. - self.buttonHome.setIcon(QIcon.fromTheme("folder_home", self.style().standardIcon(QtWidgets.QStyle.SP_DirHomeIcon))) + self.buttonHome.setIcon(icon("folder-home", self.style().standardIcon(QtWidgets.QStyle.SP_DirHomeIcon))) self.buttonHome.setToolTip("User's home directory") self.buttonHome.setAutoRaise(True) self.buttonOriginal.setToolTip("Original directory") - self.lookInCombo.setMinimumSize(50,0) - self.toParentButton.setIcon(QIcon.fromTheme("up", self.style().standardIcon(QtWidgets.QStyle.SP_FileDialogToParent))) + self.lookInCombo.setMinimumSize(50, 0) + self.toParentButton.setIcon(icon("folder-up", self.style().standardIcon(QtWidgets.QStyle.SP_FileDialogToParent))) self.toParentButton.setAutoRaise(True) self.toParentButton.setToolTip("Parent directory") self.listView.setDragEnabled(True) @@ -89,14 +90,14 @@ def __init__(self, path="", filter="", selectedFilter="", parent=None): self.buttonOpen.setFocusPolicy(QtCore.Qt.NoFocus) # Item size policies. - self.lookInCombo.setSizePolicy (QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Fixed) - self.toParentButton.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) - self.buttonHome.setSizePolicy (QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) - self.buttonOriginal.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) - self.fileNameLabel.setSizePolicy (QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) - self.fileTypeCombo.setSizePolicy (QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Fixed) - self.fileTypeLabel.setSizePolicy (QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) - self.buttonOpen.setSizePolicy (QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + self.lookInCombo.setSizePolicy(QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Fixed) + self.toParentButton.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + self.buttonHome.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + self.buttonOriginal.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + self.fileNameLabel.setSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + self.fileTypeCombo.setSizePolicy(QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Fixed) + self.fileTypeLabel.setSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + self.buttonOpen.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) # Layouts. self.include1Layout = QtWidgets.QHBoxLayout() diff --git a/usdmanager/parser.py b/usdmanager/parser.py index 5567f58..086704f 100644 --- a/usdmanager/parser.py +++ b/usdmanager/parser.py @@ -19,10 +19,12 @@ import logging import re +import traceback from collections import defaultdict from xml.sax.saxutils import escape, unescape -from Qt.QtCore import QFile, QFileInfo, QObject, Signal, Slot +from Qt.QtCore import QFile, QFileInfo, QIODevice, QObject, QTextStream, Signal, Slot +from Qt.QtGui import QIcon from .constants import LINE_CHAR_LIMIT, CHAR_LIMIT, FILE_FORMAT_NONE, HTML_BODY from .utils import expandPath @@ -41,6 +43,23 @@ def __missing__(self, key): return self[key] +class SaveFileError(Exception): + """ Exception when saving files, where details can be used to provide the earlier traceback for the user in the + error dialog's details section. + """ + def __init__(self, message, details=None): + """ Initialize the exception. + + :Parameters: + message : `str` + Message + details : `str` | None + Optional traceback to accompany this message. + """ + super(SaveFileError, self).__init__(message) + self.details = details + + class FileParser(QObject): """ Base class for RegEx-based file parsing. """ @@ -50,6 +69,13 @@ class FileParser(QObject): # Override as needed. fileFormat = FILE_FORMAT_NONE lineCharLimit = LINE_CHAR_LIMIT + + # If the file format is binary or not (e.g. USD's crate format). + binary = False + + # Optional icon to display in the tab bar when this file parser is used. + icon = QIcon() + # Group within the RegEx corresponding to the file path only. # Useful if you modify compile() but not linkParse(). RE_FILE_GROUP = 1 @@ -69,7 +95,7 @@ def __init__(self, parent=None): self.regex = None self._stop = False - self._cleanup() + self.cleanup() self.progress.connect(parent.setLoadingProgress) self.status.connect(parent.loadingProgressLabel.setText) @@ -94,7 +120,7 @@ def acceptsFile(self, fileInfo, link): """ raise NotImplementedError - def _cleanup(self): + def cleanup(self): """ Reset variables for a new file. Don't override. @@ -123,7 +149,23 @@ def compile(self): r')' # end group 1 r'(?:[\'"@]|\\\")' # 1 of: single quote, double quote, backslash followed by double quote, or at symbol. ) - + + @staticmethod + def generateTempFile(fileName, tmpDir=None): + """ For file formats supporting ASCII and binary representations, generate a temporary ASCII file that the user can edit. + + :Parameters: + fileName : `str` + Binary file path + tmpDir : `str` | None + Temp directory to create the new file within + :Returns: + Temporary file name + :Rtype: + `str` + """ + raise NotImplementedError + def parse(self, nativeAbsPath, fileInfo, link): """ Parse a file for links, generating a plain text version and HTML version of the file text. @@ -138,7 +180,7 @@ def parse(self, nativeAbsPath, fileInfo, link): link : `QUrl` Full file path URL """ - self._cleanup() + self.cleanup() self.status.emit("Reading file") self.text = self.read(nativeAbsPath) @@ -323,6 +365,35 @@ def stopTriggered(self, checked=False): """ self.stop() + def write(self, qFile, filePath, tab, tmpDir): + """ Write out a plain text file. + + :Parameters: + qFile : `QtCore.QFile` + Object representing the file to write to + filePath : `str` + File path to write to + tab : `str` + Tab being written + tmpDir : `str` + Temporary directory, if needed for any write operations. + :Raises SaveFileError: + If the file write fails. + """ + if not qFile.open(QIODevice.WriteOnly | QIODevice.Text): + raise SaveFileError("The file could not be opened for saving!") + + try: + out = QTextStream(qFile) + _ = out << tab.textEditor.toPlainText() + except Exception: + raise SaveFileError("The file could not be saved.", traceback.format_exc()) + finally: + qFile.close() + + tab.parser = self + tab.fileFormat = self.fileFormat + class AbstractExtParser(FileParser): """ Determines which files are supported based on extension. @@ -330,7 +401,7 @@ class AbstractExtParser(FileParser): """ # Tuple of `str` file extensions (without the leading .) that this parser can support. Example: ("usda",) exts = () - + def acceptsFile(self, fileInfo, link): """ Accept files with the proper extension. diff --git a/usdmanager/parsers/usd.py b/usdmanager/parsers/usd.py index 1c2aed0..dca425d 100644 --- a/usdmanager/parsers/usd.py +++ b/usdmanager/parsers/usd.py @@ -18,17 +18,18 @@ """ import logging import os -import re from os.path import sep, splitext +import re +import traceback from xml.sax.saxutils import escape, unescape -from Qt.QtCore import QFileInfo, Slot +from Qt.QtCore import QDir, QFile, QFileInfo, Slot from Qt.QtGui import QIcon from .. import utils -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 +from ..constants import FILE_FORMAT_USD, FILE_FORMAT_USDA, FILE_FORMAT_USDC, FILE_FORMAT_USDZ,\ + USD_AMBIGUOUS_EXTS, USD_ASCII_EXTS, USD_CRATE_EXTS, USD_ZIP_EXTS +from ..parser import AbstractExtParser, SaveFileError # Set up logging. @@ -42,7 +43,7 @@ class UsdAsciiParser(AbstractExtParser): """ USD ASCII files. - + Treat as plain text. This is the simplest of the Usd parsers, which other USD parsers should inherit from. """ exts = USD_ASCII_EXTS @@ -57,16 +58,16 @@ def __init__(self, *args, **kwargs): r"\s*(.*)\s*" # Everything inside the square brackets. r"(\].*)$" # Closing bracket to the end of the line. ) - + @Slot() def compile(self): """ Compile regular expression to find links in USD files. """ self.regex = utils.usdRegEx(self.parent().programs.keys()) - + def parse(self, nativeAbsPath, fileInfo, link): """ Parse a file for links, generating a plain text version and HTML version of the file text. - + :Parameters: nativeAbsPath : `str` OS-native absolute file path @@ -79,12 +80,12 @@ def parse(self, nativeAbsPath, fileInfo, link): self.sdf_format_args = utils.sdfQuery(link) self.extractedDir = utils.queryItemValue(link, "extractedDir") return super(UsdAsciiParser, self).parse(nativeAbsPath, fileInfo, link) - + def parseMatch(self, match, linkPath, nativeAbsPath, fileInfo): """ Parse a RegEx match of a path to another file. - + Override for specific language parsing. - + :Parameters: match RegEx match object @@ -106,7 +107,7 @@ def parseMatch(self, match, linkPath, nativeAbsPath, fileInfo): # We then have to re-escape the path before inserting it into HTML. linkPath = unescape(linkPath) expanded_path = utils.expandPath( - linkPath, nativeAbsPath, + linkPath, nativeAbsPath, self.sdf_format_args, extractedDir=self.extractedDir) if QFileInfo(linkPath).isAbsolute(): @@ -116,7 +117,7 @@ def parseMatch(self, match, linkPath, nativeAbsPath, fileInfo): # Relative path from the current file to the link. fullPath = fileInfo.dir().absoluteFilePath(expanded_path) logger.debug("Parsed link is relative (%s). Expanded to %s", linkPath, fullPath) - + # Override any previously set sdf format args. local_sdf_args = self.sdf_format_args.copy() if match.group(3): @@ -131,7 +132,7 @@ def parseMatch(self, match, linkPath, nativeAbsPath, fileInfo): sorted(local_sdf_args.items(), key=lambda x: x[0]))] else: queryParams = [] - + # .usdz file references (e.g. @set.usdz[foo/bar.usd]@) if match.group(2): queryParams.append("layer=" + match.group(2)) @@ -151,13 +152,13 @@ def pathForLink(path): if fullPathExt[1:] in USD_CRATE_EXTS or (fullPathExt[1:] in USD_AMBIGUOUS_EXTS and utils.isUsdCrate(fullPath)): queryParams.insert(0, "binary=1") - link = '{}'.format(pathForLink(fullPath), "&".join(queryParams), - escape(linkPath)) + link = '{}'.format(pathForLink(fullPath), + "&".join(queryParams), escape(linkPath)) logger.debug('parseMatch: created binary link <%s> for path <%s>', link, linkPath) - - queryStr = "?" + "&".join(queryParams) if queryParams else "" - link = '{}'.format(pathForLink(fullPath), queryStr, escape(linkPath)) - logger.debug('parseMatch: created link <%s> for path <%s>', link, linkPath) + else: + queryStr = "?" + "&".join(queryParams) if queryParams else "" + link = '{}'.format(pathForLink(fullPath), queryStr, escape(linkPath)) + logger.debug('parseMatch: created link <%s> for path <%s>', link, linkPath) return link elif '*' in linkPath or '' in linkPath or '.#.' in linkPath: # Create an orange link for files with wildcards in the path, @@ -206,17 +207,19 @@ def parseLongLine(self, line): class UsdCrateParser(UsdAsciiParser): """ Parse USD file assuming it is a crate file. - + 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. """ + binary = True exts = USD_CRATE_EXTS fileFormat = FILE_FORMAT_USDC - + icon = utils.icon("binary") + def acceptsFile(self, fileInfo, link): """ Accept .usdc files, or .usd files that do have a true binary query string value (i.e. .usd files we've already confirmed are crate). - + :Parameters: fileInfo : `QFileInfo` File info object @@ -225,9 +228,55 @@ def acceptsFile(self, fileInfo, link): """ ext = fileInfo.suffix() return ext in self.exts or (ext in USD_AMBIGUOUS_EXTS and utils.queryItemBoolValue(link, "binary")) - + + @staticmethod + def generateTempFile(fileName, tmpDir=None): + """ Generate a temporary ASCII USD file that the user can edit. + + :Parameters: + fileName : `str` + Binary USD file path + tmpDir : `str` | None + Temp directory to create the new file within + :Returns: + Temporary file name + :Rtype: + `str` + :Raises OSError: + If USD conversion fails + """ + return utils.generateTemporaryUsdFile(fileName, tmpDir) + def read(self, path): - return self.parent().readUsdCrateFile(path) + return self.parent().readBinaryFile(path, self) + + def write(self, qFile, filePath, tab, tmpDir): + """ Write out the text to an ASCII file, then convert it to crate. + + :Parameters: + qFile : `QtCore.QFile` + Object representing the file to write to + filePath : `str` + File path to write to + tab : `str` + Tab being written + tmpDir : `str` + Temporary directory, if needed for any write operations. + :Raises SaveFileError: + If the file write fails. + """ + fd, tmpPath = utils.mkstemp(suffix="." + USD_AMBIGUOUS_EXTS[0], dir=tmpDir) + os.close(fd) + super(UsdCrateParser, self).write(QFile(tmpPath), tmpPath, tab, tmpDir) + try: + logger.debug("Converting back to USD crate file") + utils.usdcat(tmpPath, QDir.toNativeSeparators(filePath), format="usdc") + except Exception: + logger.exception("Save failed on USD crate conversion") + raise SaveFileError("The file could not be saved due to a usdcat error!", traceback.format_exc()) + tab.parser = self + tab.fileFormat = self.fileFormat + os.remove(tmpPath) class UsdParser(UsdAsciiParser): @@ -235,11 +284,11 @@ class UsdParser(UsdAsciiParser): """ exts = USD_AMBIGUOUS_EXTS fileFormat = FILE_FORMAT_USD - + def acceptsFile(self, fileInfo, link): """ Accept .usd files that do not have a true binary query string in the URL (i.e. we haven't yet opened this file to determine if it is crate, or we have checked and it wasn't crate). - + :Parameters: fileInfo : `QFileInfo` File info object @@ -247,16 +296,122 @@ def acceptsFile(self, fileInfo, link): Full URL, potentially with query string """ return fileInfo.suffix() in self.exts and not utils.queryItemBoolValue(link, "binary") - + + def setBinary(self, binary): + """ Set if the parser is currently parsing a binary or ASCII file. + + :Parameters: + binary : `bool` + If the current file is binary or ASCII. + """ + self.binary = binary + if binary: + self.fileFormat = FILE_FORMAT_USDC + self.icon = UsdCrateParser.icon + else: + self.fileFormat = FILE_FORMAT_USDA + self.icon = UsdAsciiParser.icon + def read(self, path): with open(path) as f: # Read in the first line. If it's a binary USD file, # convert it to a temp ASCII file for viewing/editing. if f.readline().startswith("PXR-USDC"): - self.fileFormat = FILE_FORMAT_USDC - return self.parent().readUsdCrateFile(path) - - self.fileFormat = FILE_FORMAT_USDA + self.setBinary(True) + return self.parent().readBinaryFile(path, UsdCrateParser) + + self.setBinary(False) # Read in the full file. f.seek(0) return f.readlines() + + def write(self, *args, **kwargs): + """ Write out the text to an ASCII or crate file. + + :Parameters: + qFile : `QtCore.QFile` + Object representing the file to write to + filePath : `str` + File path to write to + tab : `str` + Tab being written + tmpDir : `str` + Temporary directory, if needed for any write operations. + :Raises SaveFileError: + If the file write fails. + """ + if self.binary: + UsdCrateParser.write(self, *args, **kwargs) + else: + super(UsdParser, self).write(*args, **kwargs) + + +class UsdzParser(UsdParser): + """ Parse zipped USD archives. + """ + exts = USD_ZIP_EXTS + fileFormat = FILE_FORMAT_USDZ + icon = utils.icon("zip", utils.icon("package-x-generic")) + + def read(self, path, layer=None, cache=None, tmpDir=None): + """ Read in a USD zip (.usdz) file via usdzip, uncompressing to a temp directory. + + :Parameters: + path : `str` + USDZ file path + layer : `str` | None + Default layer within file (e.g. the portion within the square brackets here: + @foo.usdz[path/to/file/within/package.usd]@) + cache : `dict` | None + Dictionary of cached (e.g. unzipped) files + tmpDir : `str` | None + Temporary directory to use for unzipping + :Returns: + Destination file + :Rtype: + `str` + :Raises zipfile.BadZipfile: + For bad ZIP files + :Raises zipfile.LargeZipFile: + When a ZIP file would require ZIP64 functionality but that has not been enabled + :Raises ValueError: + If default layer not found + """ + cache = cache or {} + + # Cache the unzipped directory so we can use it again later without reconversion if it's still newer. + if (path in cache and + QFileInfo(cache[path]).lastModified() > QFileInfo(path).lastModified()): + usdPath = cache[path] + logger.debug("Reusing cached directory %s for zip file %s", usdPath, path) + else: + logger.debug("Uncompressing usdz file...") + usdPath = utils.unzip(path, tmpDir) + cache[path] = usdPath + + # Check for a nested usdz reference (e.g. @set.usdz[areas/shire.usdz[architecture/BilboHouse/Table.usd]]@) + 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, path) + return self.readUsdzFile(dest, layer2) + + args = "?extractedDir={}".format(usdPath) + return utils.getUsdzLayer(usdPath, layer, path) + args + + def write(self, *args, **kwargs): + """ Write out a USD zip file. + + :Parameters: + qFile : `QtCore.QFile` + Object representing the file to write to + filePath : `str` + File path to write to + tab : `str` + Tab being written + tmpDir : `str` + Temporary directory, if needed for any write operations. + :Raises SaveFileError: + If the file write fails. + """ + raise SaveFileError("Writing usdz files is not yet supported!") diff --git a/usdmanager/preferences_dialog.py b/usdmanager/preferences_dialog.py index 4d1bb36..eb7cb7e 100644 --- a/usdmanager/preferences_dialog.py +++ b/usdmanager/preferences_dialog.py @@ -18,11 +18,11 @@ """ from Qt.QtCore import Slot, QRegExp -from Qt.QtGui import QIcon, QRegExpValidator +from Qt.QtGui import QRegExpValidator from Qt.QtWidgets import QAbstractButton, QDialog, QDialogButtonBox, QFontDialog, QLineEdit, QMessageBox, QVBoxLayout from .constants import LINE_LIMIT -from .utils import loadUiWidget +from .utils import icon, loadUiWidget class PreferencesDialog(QDialog): @@ -50,9 +50,9 @@ def setupUi(self): """ Creates and lays out the widgets defined in the ui file. """ self.baseInstance = loadUiWidget("preferences_dialog.ui", self) - self.setWindowIcon(QIcon.fromTheme("preferences-system")) - self.buttonFont.setIcon(QIcon.fromTheme("preferences-desktop-font")) - self.buttonNewProg.setIcon(QIcon.fromTheme("list-add")) + self.setWindowIcon(icon("preferences-system")) + self.buttonFont.setIcon(icon("preferences-desktop-font")) + self.buttonNewProg.setIcon(icon("list-add")) # ----- General tab ----- # Set initial preferences. diff --git a/usdmanager/usdviewstyle.qss b/usdmanager/usdviewstyle.qss index 62eb651..5d5903e 100644 --- a/usdmanager/usdviewstyle.qss +++ b/usdmanager/usdviewstyle.qss @@ -1,6 +1,7 @@ /** * GENERAL CSS STYLE RULES - * Copied from usdview + * Copied with slight modifications from usdview + * https://github.com/PixarAnimationStudios/USD/blob/release/pxr/usdImaging/usdviewq/usdviewstyle.qss */ /* *** QWidget *** @@ -43,12 +44,13 @@ QGroupBox::title { padding: 0px 3px; /* cover the border around the title */ } -/* *** QDoubleSpinBox *** - * Base style for QDoubleSpinBox +/* *** QAbstractSpinBox *** + * Base style for QAbstractSpinBox * This is the widget that allows users to select a value - * and provides up/down arrows to adjust it. + * and provides up/down arrows to adjust it. We configure QAbstractSpinBox + * because we use both QDoubleSpinBox and QSpinBox */ -QDoubleSpinBox { +QAbstractSpinBox { background: rgb(34, 34, 34); padding: 2px; /* make it a little bigger */ border-radius: 7px; /* make it very round like in presto */ @@ -57,31 +59,31 @@ QDoubleSpinBox { } /* Common style for the up and down buttons */ -QDoubleSpinBox::up-button, QDoubleSpinBox::down-button { +QAbstractSpinBox::up-button, QAbstractSpinBox::down-button { background: rgb(42, 42, 42); margin-right: -1px; /* Move to the right a little */ } /* Darken the background when button pressed down */ -QDoubleSpinBox::up-button:pressed, QDoubleSpinBox::down-button:pressed { +QAbstractSpinBox::up-button:pressed, QAbstractSpinBox::down-button:pressed { background: rgb(34, 34, 34); } /* Round the outside of the button like in presto */ -QDoubleSpinBox::up-button { +QAbstractSpinBox::up-button { margin-top: -3px; /* move higher to align */ border-top-right-radius: 7px; } /* Round the outside of the button like in presto */ -QDoubleSpinBox::down-button { +QAbstractSpinBox::down-button { margin-bottom: -3px; /* move lower to align */ border-bottom-right-radius: 7px; } /* Adjust size and color of both arrows (inside buttons) */ -QDoubleSpinBox::up-arrow, -QDoubleSpinBox::down-arrow, +QAbstractSpinBox::up-arrow, +QAbstractSpinBox::down-arrow, QComboBox::down-arrow { width: 6px; height: 3px; @@ -89,14 +91,14 @@ QComboBox::down-arrow { } /* Set the disabled color for the arrows */ -QDoubleSpinBox::up-arrow:disabled, -QDoubleSpinBox::down-arrow:disabled, +QAbstractSpinBox::up-arrow:disabled, +QAbstractSpinBox::down-arrow:disabled, QComboBox::down-arrow:disabled { background: rgb(88, 88, 88); } /* Shape the up arrow */ -QDoubleSpinBox::up-arrow { +QAbstractSpinBox::up-arrow { border-top-right-radius: 3px; /* round upper left and upper right */ border-top-left-radius: 3px; /* to form a triangle-ish shape */ border-bottom: 1px solid rgb(122, 122, 122); /* decorative */ @@ -104,7 +106,7 @@ QDoubleSpinBox::up-arrow { /* Shape the down arrow */ -QDoubleSpinBox::down-arrow, +QAbstractSpinBox::down-arrow, QComboBox::down-arrow{ border-bottom-right-radius: 3px; /* round lower right and lower left */ border-bottom-left-radius: 3px; /* to form a triangle-ish shape */ @@ -115,8 +117,8 @@ QComboBox::down-arrow{ * base style for QTextEdit */ -/* font color for QTextEdit, QLineEdit and QDoubleSpinBox */ -QTextEdit, QPlainTextEdit, QDoubleSpinBox, QlineEdit{ +/* font color for QTextEdit, QLineEdit and QAbstractSpinBox */ +QTextEdit, QPlainTextEdit, QAbstractSpinBox, QlineEdit{ color: rgb(227, 227, 227); } @@ -170,6 +172,16 @@ QSplitter::handle { background-color: rgb(32, 32, 32); } +/* Balance between making the splitters easier to find/grab, and + * clean use of space. */ +QSplitter::handle:horizontal { + width: 4px; +} + +QSplitter::handle:vertical { + height: 4px; +} + /* Override the backround for labels, make them transparent */ QLabel { background: none; @@ -189,7 +201,7 @@ QPushButton{ border-radius: 3; /* give the text enough space */ - padding: 4px; + padding: 3px; padding-right: 10px; padding-left: 10px; } @@ -228,16 +240,32 @@ QTreeView::item, QTableView::item { /* this border serves to separate the columns * since the grid is often invised */ border-right: 1px solid rgb(41, 41, 41); - height: 20px; + + padding-top: 1px; + padding-bottom: 1px; } /* Selected items highlighted in orange */ -QTreeView::item:selected, +.QTreeWidget::item:selected, QTreeView::branch:selected, QTableView::item:selected { background: rgb(189, 155, 84); } +/* hover items a bit lighter */ +.QTreeWidget::item:hover:!pressed:!selected, +QTreeView::branch:hover:!pressed:!selected, +QTableView::item:hover:!pressed:!selected { + background: rgb(70, 70, 70); +} + +.QTreeWidget::item:hover:!pressed:selected, +QTreeView::branch:hover:!pressed:selected, +QTableView::item:hover:!pressed:selected { +/* background: rgb(132, 109, 59); */ + background: rgb(227, 186, 101); +} + /* give the tables and trees an alternating dark/clear blue background */ QTableView, QTableWidget, QTreeWidget { background: rgb(55, 55, 55); @@ -341,30 +369,7 @@ QTabBar::tab:left:!selected { margin-right: 2px; } -/* *** QSlider *** - * Style the time slider - * Style in inner groove - */ -QSlider::groove:horizontal { - border: 2px solid rgb(47, 47, 47); - background: rgb(58, 58, 58); - height: 6px; - margin: 2px 0; -} - -/* Style the handle with orange background, border and proper size */ -QSlider::handle:horizontal { - background: QLinearGradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 rgb(207, 151, 53), stop: 1 rgb(229, 162, 44)); - - border: 1px solid rgb(42, 42, 42); - border-radius: 5px; - - width: 10px; - margin: -4px 0; -} - /* Set the disabled background color for slider handle and checkbox */ -QSlider::handle:horizontal:disabled, QCheckBox::indicator:checked:disabled { background: QLinearGradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 rgb(177, 161, 134), stop: 1 rgb(188, 165, 125)); } @@ -433,9 +438,11 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { } /* *** QMenuBar *** - * Style the menu bar + * Style the menu bars */ -QMenuBar { + +/* A bit bigger and brighter for main menu */ +QMenuBar#menubar { background: rgb(80, 80, 80); border: 2px solid rgb(41, 41, 41); } @@ -490,18 +497,18 @@ QMenu::separator { * Note: The down arrow is style in the QSpinBox style */ QComboBox { - color: rgb(227, 227, 227); /* Weird, if we dont specify, its back */ + color: rgb(227, 227, 227); /* Weird, if we dont specify, it's black */ height: 22px; background: rgb(41, 41, 41); border:none; - border-radius: 7px; + border-radius: 5px; padding: 1px 0px 1px 3px; /*This makes text colour work*/ } QComboBox::drop-down { background: rgb(41, 41, 41); border:none; - border-radius: 7px; + border-radius: 5px; } QToolTip { @@ -509,6 +516,8 @@ QToolTip { padding-right: 7px; } +/* End usdview styles. USD Manager specific changes below this. */ + QToolBar { background-color: rgb(56, 56, 56); border-bottom: 1px solid rgb(35, 35, 35); /* Defining any border fixes an issue with background-color not working */ @@ -522,3 +531,7 @@ QLineEdit#findBar { AddressBar { background-color: rgb(41, 41, 41); } + +QStatusBar::item { + border: 0px solid black +} \ No newline at end of file diff --git a/usdmanager/utils.py b/usdmanager/utils.py index 79f4d0f..fa3d1fb 100644 --- a/usdmanager/utils.py +++ b/usdmanager/utils.py @@ -28,8 +28,9 @@ from glob import glob from pkg_resources import resource_filename -import Qt -from Qt import QtCore, QtWidgets +from Qt import QtCore +from Qt.QtGui import QIcon +from Qt.QtWidgets import QApplication from .constants import USD_EXTS, USD_AMBIGUOUS_EXTS, USD_ASCII_EXTS, USD_CRATE_EXTS @@ -45,6 +46,55 @@ logger.warn("Unable to create AssetResolver - Asset links may not work correctly") resolver = None +# This can be updated based on the config.json. +ICON_ALIASES = { + "crystal_project": { + "accessories-text-editor": "edit", + "application-exit": "exit", + "applications-internet": "Globe", + "comment-add": "comment", + "comment-remove": "removecomment", + "dialog-information": "info", + "document-open": "fileopen", + "document-open-recent": "history", + "document-print": "printer", + "document-save": "filesave", + "document-save-as": "filesaveas", + "edit-copy": "editcopy", + "edit-cut": "editcut", + "edit-find": "find", + "edit-find-next": ":/images/images/findNext.png", + "edit-find-previous": ":/images/images/findPrev.png", + "edit-paste": "editpaste", + "edit-redo": "redo", + "edit-select-all": "ark_selectall", + "edit-undo": "undo", + "file-diff": "kompare", + "folder-home": "folder_home", + "folder-up": "up", + "format-indent-less": "format_decreaseindent", + "format-indent-more": "format_increaseindent", + "go-jump": "goto", + "go-next": "next", + "go-previous": "previous", + "help-about": "14_star", + "help-browser": "help", + "media-playback-start": "1rightarrow", + "preferences-system": "configure", + "process-stop": "stop", + "tab-new": "tab_new", + "tab-remove": "tab_remove", + "utilities-terminal": "terminal", + "view-fullscreen": "window_fullscreen", + "view-refresh": "reload", + "window-close": "fileclose", + "window-new": "new_window", + "zoom-in": "viewmag+", + "zoom-original": "viewmag1", + "zoom-out": "viewmag-", + } +} + def expandPath(path, parentPath=None, sdf_format_args=None, extractedDir=None): """ Expand and normalize a path that may have variables in it. @@ -134,7 +184,7 @@ def expandUrl(path, parentPath=None): def strToUrl(path): """ Properly set the query parameter of a URL, which doesn't seem to set QUrl.hasQuery properly unless using - .setQuery (or .setQueryItems in Qt5). + .setQuery. Use this when a path might have a query string after it or start with file://. In all other cases. QUrl.fromLocalFile should work fine. @@ -158,10 +208,7 @@ def strToUrl(path): url = QtCore.QUrl.fromLocalFile(path) 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 @@ -270,6 +317,32 @@ def mkstemp(dir, **kwargs): return fd, tmpFileName +def icon(name, fallback=None): + """ Get an icon, using theme-based configs to look up icon name aliases. + + :Parameters: + name : `str` + Icon name or resource path + fallback : `QIcon` | None + Fallback icon if an icon for name (or it's alias) is not found. + :Returns: + Icon + :Rtype: + `QIcon` + """ + try: + alias = ICON_ALIASES[QIcon.themeName()][name] + except KeyError: + return QIcon.fromTheme(name) if fallback is None else QIcon.fromTheme(name, fallback) + else: + # Assume we passed in a resource path instead of a theme icon. + if alias.startswith(":"): + return QIcon(alias) + if fallback is None: + return QIcon.fromTheme(alias, QIcon.fromTheme(name)) + return QIcon.fromTheme(alias, QIcon.fromTheme(name, fallback)) + + def usdcat(inputFile, outputFile, format=None): """ Generate a temporary ASCII USD file that the user can edit. @@ -531,11 +604,11 @@ def overrideCursor(cursor=QtCore.Qt.WaitCursor): with overrideCursor(): # do something that may raise an error """ - QtWidgets.QApplication.setOverrideCursor(cursor) + QApplication.setOverrideCursor(cursor) try: yield finally: - QtWidgets.QApplication.restoreOverrideCursor() + QApplication.restoreOverrideCursor() def queryItemValue(url, key, default=None):