diff --git a/docs/development.md b/docs/development.md
index 8b9a85f..b5020e6 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -49,6 +49,17 @@ Example app config JSON file:
}
```
+## Language-specific parsing
+
+When default path parsing logic is not good enough, you can add unique parsing for file types by subclassing the
+[parser.AbstractExtParser](https://github.com/dreamworksanimation/usdmanager/blob/master/usdmanager/parser.py).
+Save your new class in a file in the parsers directory. Set the class's extensions tuple (e.g. (".html", ".xml")) for a
+simple list of file extensions to match, or override the acceptsFile method for more advanced control.
+
+Within each parser, you can define custom menu items that will be added to the bottom of the Commands menu whenever a
+parser is active. For example, the [USD parser](https://github.com/dreamworksanimation/usdmanager/blob/master/usdmanager/parsers/usd.py)
+adds an "Open in usdview..." action.
+
## Syntax highlighting
To add syntax highlighting for additional languages, subclass the
@@ -122,4 +133,4 @@ following to update [usdmanager/plugins/images_rc.py](https://github.com/dreamwo
pyrcc4 usdmanager/plugins/images.rc > usdmanager/plugins/images_rc.py
```
-If using pyrcc4, be sure to replace PyQt4 with Qt in the images_rc.py's import line.
\ No newline at end of file
+If using pyrcc4, be sure to replace PyQt4 with Qt in the images_rc.py's import line.
diff --git a/usdmanager/__init__.py b/usdmanager/__init__.py
index 7c9d6db..374cbfc 100644
--- a/usdmanager/__init__.py
+++ b/usdmanager/__init__.py
@@ -95,21 +95,22 @@
# Qt.py compatibility.
-if Qt.IsPySide2:
- # Add QUrl.path missing in PySide2 build.
- if not hasattr(QtCore.QUrl, "path"):
- def qUrlPath(self):
- """ Get the decoded URL without any query string.
-
- :Returns:
- Decoded URL without query string
- :Rtype:
- `str`
- """
- return self.toString(QtCore.QUrl.PrettyDecoded | QtCore.QUrl.RemoveQuery)
-
- QtCore.QUrl.path = qUrlPath
-elif Qt.IsPyQt5:
+if Qt.IsPySide2 or Qt.IsPyQt5:
+ if Qt.IsPySide2:
+ # Add QUrl.path missing in PySide2 build.
+ if not hasattr(QtCore.QUrl, "path"):
+ def qUrlPath(self):
+ """ Get the decoded URL without any query string.
+
+ :Returns:
+ Decoded URL without query string
+ :Rtype:
+ `str`
+ """
+ return self.toString(QtCore.QUrl.PrettyDecoded | QtCore.QUrl.RemoveQuery)
+
+ QtCore.QUrl.path = qUrlPath
+
# Add query pair/value delimiters until we move fully onto Qt5 and switch to the QUrlQuery class.
def queryPairDelimiter(self):
""" Get the query pair delimiter character.
@@ -184,7 +185,6 @@ class UsdMngrWindow(QtWidgets.QMainWindow):
- Ability to write and repackage as usdz
- - Plug-ins based on active file type (ABC-specific commands, USD commands, etc.)
- Different extensions to search for based on file type.
- Add customized print options like name of file and date headers, similar to printing a web page.
- Move setSource link parsing to a thread?
@@ -205,23 +205,10 @@ class UsdMngrWindow(QtWidgets.QMainWindow):
Known issues:
- AddressBar file completer has problems occasionally.
- - Save As... doesn't add file to recent files or history menus.
- Find with text containing a new line character does not work due to QTextDocument storing these as separate
blocks.
- Line numbers width not always immediately updated after switching to new class.
- - Qt.py problems:
-
- - PyQt5
-
- - Non-critical messages
-
- - QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-mdsandell'
- - QXcbConnection: XCB error: 3 (BadWindow), sequence: 878, resource id: 26166399, major code: 40
- (TranslateCoords), minor code: 0
-
- - PySide2
-
- - Preferences dialog doesn't center on main window, can't load via loadUiType
+ - If a file loses edit permissions, it can stay in edit mode and let you make changes that can't be saved.
"""
@@ -270,6 +257,7 @@ def __init__(self, parent=None, **kwargs):
# 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
@@ -615,7 +603,7 @@ def customTextBrowserContextMenu(self, pos):
pos : `QtCore.QPoint`
Position of the right-click
"""
- menu = self.currTab.textBrowser.createStandardContextMenu()
+ menu = self.currTab.textBrowser.createStandardContextMenu(pos)
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
@@ -636,8 +624,8 @@ def customTextBrowserContextMenu(self, pos):
path = self.currTab.getCurrentPath()
if path:
menu.addSeparator()
- if utils.isUsdFile(path):
- menu.addAction(self.actionUsdView)
+ for args in self.currTab.parser.plugins:
+ menu.addAction(*args)
menu.addAction(self.actionTextEditor)
menu.addAction(self.actionOpenWith)
menu.addSeparator()
@@ -669,8 +657,8 @@ def customTextEditorContextMenu(self, pos):
path = self.currTab.getCurrentPath()
if path:
menu.addSeparator()
- if utils.isUsdFile(path):
- menu.addAction(self.actionUsdView)
+ for args in self.currTab.parser.plugins:
+ menu.addAction(*args)
menu.addAction(self.actionTextEditor)
menu.addAction(self.actionOpenWith)
menu.addSeparator()
@@ -938,7 +926,6 @@ def connectSignals(self):
self.actionUncomment.triggered.connect(self.uncommentTextRequest)
self.actionIndent.triggered.connect(self.indentText)
self.actionUnindent.triggered.connect(self.unindentText)
- self.actionUsdView.triggered.connect(self.launchUsdView)
self.actionTextEditor.triggered.connect(self.launchTextEditor)
self.actionOpenWith.triggered.connect(self.launchProgramOfChoice)
self.actionOpenLinkWith.triggered.connect(self.onOpenLinkWith)
@@ -1304,11 +1291,13 @@ def getSaveAsPath(self, path=None, tab=None):
# Now we have a valid path to save as.
return filePath, fileFormat
- @Slot()
- def saveFileAs(self, tab=None):
+ @Slot(bool)
+ def saveFileAs(self, checked=False, tab=None):
""" Save the current file with a new filename.
:Parameters:
+ checked : `bool`
+ For signal only
tab : `BrowserTab` | None
Tab to save. Defaults to current tab.
:Returns:
@@ -1335,8 +1324,10 @@ def saveFileAs(self, tab=None):
else:
self.tabWidget.setTabIcon(idx, QtGui.QIcon())
self.tabWidget.setTabToolTip(idx, "{} - {}".format(fileName, filePath))
- tab.updateHistory(QtCore.QUrl.fromLocalFile(filePath))
+ url = QtCore.QUrl.fromLocalFile(filePath)
+ tab.updateHistory(url)
tab.updateFileStatus()
+ self.updateRecentMenus(url, url.toString())
self.setHighlighter(ext, tab=tab)
if tab == self.currTab:
self.updateButtons()
@@ -1375,11 +1366,13 @@ def saveLinkAs(self):
else:
self.showWarningMessage("Selected file does not exist.")
- @Slot()
- def saveTab(self, tab=None):
+ @Slot(bool)
+ def saveTab(self, checked=False, tab=None):
""" If the file already has a name, save it; otherwise, get a filename and save it.
:Parameters:
+ checked : `bool`
+ For signal only
tab : `BrowserTab` | None
Tab to save. Defaults to current tab.
:Returns:
@@ -1641,19 +1634,21 @@ def removeTab(self, index):
# Edit Menu Methods
###
- @Slot()
- # TODO: Test this doesn't get "False" as the first param from using triggered signal on an action. Probably need to
- # test on Mac.
- def toggleEdit(self, tab=None):
+ @Slot(bool)
+ def toggleEdit(self, checked=False, tab=None):
""" Switch between Browse mode and Edit mode.
+ :Parameters:
+ checked : `bool`
+ Unused. For signal/slot only
+ tab : `BrowserTab`
+ Tab to toggle edit mode on
:Returns:
True if we switched modes; otherwise, False.
This only returns False if we were in Edit mode and the user cancelled due to unsaved changes.
:Rtype:
`bool`
"""
- logger.debug("toggleEdit: %s", tab) # TEMP for TODO
tab = tab or self.currTab
# Don't change between browse and edit mode if dirty. Saves if needed.
@@ -1772,10 +1767,13 @@ def toggleFindClose(self):
self.findWidget.setVisible(False)
@Slot()
- def find(self, flags=None, startPos=3, loop=True):
+ @Slot(bool)
+ def find(self, checked=False, flags=None, startPos=3, loop=True):
""" Find next hit for the search text.
:Parameters:
+ checked : `bool`
+ For signal only
flags
Options for document().find().
startPos : `int`
@@ -2072,7 +2070,7 @@ def replaceAllInOpenFiles(self):
if not status.writable:
continue
if not tab.inEditMode:
- self.toggleEdit(tab)
+ self.toggleEdit(tab=tab)
thisCount = self.replaceAll(findText, replaceText, tab, report=False)
if thisCount:
files += 1
@@ -2330,13 +2328,16 @@ def refreshSelectedTab(self):
"""
selectedIndex = self.tabWidget.tabBar.tabAt(self.contextMenuPos)
selectedTab = self.tabWidget.widget(selectedIndex)
- self.refreshTab(selectedTab)
+ self.refreshTab(tab=selectedTab)
@Slot()
- def refreshTab(self, tab=None):
+ @Slot(bool)
+ def refreshTab(self, checked=False, tab=None):
""" Reload the file for a tab.
:Parameters:
+ checked : `bool`
+ For signal only
tab : `BrowserTab` | None
Tab to refresh. Defaults to current tab if None.
:Returns:
@@ -2481,8 +2482,7 @@ def diffFile(self):
fd, tmpPath = utils.mkstemp(suffix=QtCore.QFileInfo(path).fileName(), dir=self.app.tmpDir)
with os.fdopen(fd, 'w') as f:
f.write(self.currTab.textEditor.toPlainText())
- args = shlex.split(self.preferences['diffTool']) + [QtCore.QDir.toNativeSeparators(path), tmpPath]
- self.launchProcess(args)
+ self.launchPathCommand(self.preferences['diffTool'], [QtCore.QDir.toNativeSeparators(path), tmpPath])
@staticmethod
def getPermissionString(path):
@@ -2607,29 +2607,23 @@ def unindentText(self):
def launchTextEditor(self):
""" Launch the current file in a separate text editing application.
"""
- path = QtCore.QDir.toNativeSeparators(self.currTab.getCurrentPath())
- args = shlex.split(self.preferences['textEditor']) + [path]
- self.launchProcess(args)
+ self.launchPathCommand(self.preferences['textEditor'],
+ QtCore.QDir.toNativeSeparators(self.currTab.getCurrentPath()))
@Slot()
def launchUsdView(self):
""" Launch the current file in usdview.
"""
- app = self.app.DEFAULTS['usdview']
- path = QtCore.QDir.toNativeSeparators(self.currTab.getCurrentPath())
- # Files with spaces have to be double-quoted on Windows for usdview.
- if os.name == "nt":
- cmd = '{} "{}"'.format(app, path)
- self.launchProcess(cmd, shell=True)
- else:
- args = [app, path]
- self.launchProcess(args)
+ self.launchPathCommand(self.app.DEFAULTS['usdview'],
+ QtCore.QDir.toNativeSeparators(self.currTab.getCurrentPath()))
- @Slot()
- def launchProgramOfChoice(self, path=None):
+ @Slot(bool)
+ def launchProgramOfChoice(self, checked=False, path=None):
""" Open a file with a program given by the user.
:Parameters:
+ checked : `bool`
+ For signal only
path : `str`
File to open. If None, use currently open file.
"""
@@ -2639,9 +2633,9 @@ def launchProgramOfChoice(self, path=None):
# Get program of choice from user.
prog, ok = QtWidgets.QInputDialog.getText(
self, "Open with...",
- "Please enter the program you would like to open this file with.\n\nYou may include command line "
- "options as well, and the file path will be appended to the end of the command.\n\n"
- "Example:\n usdview --unloaded\n rm\n",
+ "Please enter the program you would like to open this file with.\n\nYou may include command line options "
+ "as well, and the file path will be appended to the end of the command.\n\nUse {} if the path needs to go "
+ "in a specific place within the command.\n\nExample:\n usdview --unloaded\n ls {} -l\n",
QtWidgets.QLineEdit.Normal, self.preferences['lastOpenWithStr'])
# Return if cancel was pressed or nothing entered.
if not ok or not prog:
@@ -2651,8 +2645,7 @@ def launchProgramOfChoice(self, path=None):
self.updatePreference('lastOpenWithStr', prog)
# Launch program.
- args = shlex.split(prog) + [path]
- self.launchProcess(args)
+ self.launchPathCommand(prog, path)
###
# Help Menu Methods
@@ -2796,7 +2789,7 @@ def promptOnFileChange(self, path):
# Make sure the tab appears as dirty so the user is prompted on exit to do so if they still haven't up to
# that point.
if not self.currTab.inEditMode:
- self.toggleEdit(self.currTab)
+ self.toggleEdit(tab=self.currTab)
self.currTab.setDirty()
return False
@@ -3021,11 +3014,9 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos
if ext in self.programs and self.programs[ext]:
if multFiles is not None:
# Assumes program takes a space-separated list of files.
- args = shlex.split(self.programs[ext]) + multFiles
- self.launchProcess(args)
+ self.launchPathCommand(self.programs[ext], multFiles)
else:
- args = shlex.split(self.programs[ext]) + [nativeAbsPath]
- self.launchProcess(args)
+ self.launchPathCommand(self.programs[ext], nativeAbsPath)
return self.setSourceFinish(tab=tab)
if multFiles is not None:
@@ -3054,10 +3045,7 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos
self.tabWidget.setTabToolTip(idx, "{} - {}".format(fileName, nativeAbsPath))
# Take care of various history menus.
- self.addItemToMenu(link, self.menuHistory, slot=self.setSource, maxLen=25, start=3, end=2)
- self.addItemToMenu(link, self.menuOpenRecent, slot=self.openRecent, maxLen=RECENT_FILES)
- self.menuOpenRecent.setEnabled(True)
- self.addRecentFileToSettings(fullUrlStr)
+ self.updateRecentMenus(link, fullUrlStr)
if fileExists:
# TODO: If files can load in parallel, this single progress bar would need to change.
@@ -3067,9 +3055,11 @@ 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.
@@ -3082,7 +3072,7 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos
return self.setSource(utils.strToUrl(dest), tab=tab)
else:
logger.debug("Using default parser")
- parser = self.fileParserDefault
+ tab.parser = self.fileParserDefault
# Stop Loading Tab stops the expensive parsing of the file
# for links, checking if the links actually exist, etc.
@@ -3151,7 +3141,7 @@ def setSource(self, link, isNewFile=True, newTab=False, hScrollPos=0, vScrollPos
self.loadingProgressLabel.setVisible(False)
else:
if not tab.inEditMode:
- self.toggleEdit(tab)
+ self.toggleEdit(tab=tab)
tab.setDirty(True)
logger.debug("Cleanup")
@@ -3324,6 +3314,7 @@ def currentTabChanged(self, idx):
Index of the newly selected tab
"""
prevMode = self.currTab.inEditMode
+ self._prevParser = self.currTab.parser
self.currTab = self.tabWidget.widget(idx)
if prevMode != self.currTab.inEditMode:
self.editModeChanged.emit(self.currTab.inEditMode)
@@ -3377,6 +3368,7 @@ def updateButtons(self):
self.breadcrumb.setText(self.currTab.breadcrumb)
self.updateEditButtons()
+ self.updateParserPlugins()
title = self.app.appDisplayName
if self.currTab.isNewTab:
@@ -3397,10 +3389,6 @@ def updateButtons(self):
self.actionOpenWith.setEnabled(enable)
self.setWindowTitle(title)
- path = self.currTab.getCurrentPath()
- usd = utils.isUsdFile(path)
- self.actionUsdView.setEnabled(usd)
-
status = self.currTab.getFileStatus()
self.fileStatusButton.setText(status.text)
self.fileStatusButton.setIcon(status.icon)
@@ -3410,6 +3398,20 @@ def updateButtons(self):
# Useful for plug-ins that may need to update their actions' enabled state.
self.updatingButtons.emit()
+ def updateParserPlugins(self):
+ """ Update parser-specific UI actions when the file parser has changed
+ due to the display of a different file type.
+ """
+ if self.currTab.parser != self._prevParser:
+ # Clear old actions up to the last separator.
+ for action in reversed(self.menuCommands.actions()):
+ if action.isSeparator():
+ break
+ self.menuCommands.removeAction(action)
+ if self.currTab.parser is not None:
+ for args in self.currTab.parser.plugins:
+ self.menuCommands.addAction(*args)
+
def updateEditButtons(self):
""" Toggle edit action and button text.
"""
@@ -3452,17 +3454,74 @@ def validateAddressBar(self, address):
"""
self.buttonGo.setEnabled(bool(address.strip()))
- def launchProcess(self, args, **kwargs):
- """ Launch a program with the path `str` as an argument.
+ def launchPathCommand(self, command, path, **kwargs):
+ """ Launch a command with a file path either being appended to the end
+ or substituting curly brackets if present.
Any additional keyword arguments are passed to the Popen object.
+ Example:
+ launchPathCommand("rez-env usd_view -c 'usdview {}'", "scene.usd")
+ runs: rez-env usd_view -c 'usdview scene.usd'
+
+ launchPathCommand("nedit", "scene.usd") runs: nedit scene.usd
+
+ launchPathCommand("ls", ["foo", "bar"]) runs: ls foo bar
+
+ :Parameters:
+ command : `str` | [`str`]
+ Command to run. If the path to open with the command cannot
+ simply be appended with a space at the end of the command, use
+ {} like standard python string formatting to denote where the
+ path should go.
+ path : `str` | [`str`]
+ File path `str`, space-separated list of files paths as a
+ single `str`, or `list` of `str` file paths
+ :Returns:
+ Returns process ID, or None if the subprocess fails
+ :Rtype:
+ `subprocess.Popen` | None
+ """
+ if not isinstance(command, list):
+ if '{}' in command:
+ try:
+ quote = shlex.quote # Python 3.3+ (shlex is already imported)
+ except AttributeError:
+ from pipes import quote # Deprecated since python 2.7
+ if isinstance(path, list):
+ path = subprocess.list2cmdline(quote(x) for x in path)
+ try:
+ command = command.format(quote(path))
+ except IndexError as e:
+ self.showCriticalMessage("Invalid command: {}. If using curly brackets, please ensure there is only "
+ "one set.".format(e), details="Command: {}\nPath: {}".format(command, path))
+ return
+ if not kwargs.get("shell"):
+ command = shlex.split(command)
+ else:
+ command = shlex.split(command)
+ if isinstance(path, list):
+ command += path
+ else:
+ command.append(path)
+ else:
+ if isinstance(path, list):
+ command += path
+ elif '{}' in command:
+ while '{}' in command:
+ command[command.index('{}')] = path
+ else:
+ command.append(path)
+ return self.launchProcess(command, **kwargs)
+
+ def launchProcess(self, args, **kwargs):
+ """ Launch a subprocess. Any additional keyword arguments are passed to the Popen object.
+
:Parameters:
args : `list` | `str`
A sequence of program arguments with the program as the first arg.
- If also passing in shell=True, this should be a single string.
:Returns:
- Returns process ID or None
+ Returns process ID, or None if the subprocess fails
:Rtype:
`subprocess.Popen` | None
"""
@@ -3470,6 +3529,7 @@ def launchProcess(self, args, **kwargs):
try:
if kwargs.get("shell"):
# With shell=True, convert args to a string to call Popen with.
+ # Properly quote any args as necessary before using this.
logger.debug("Running Popen with shell=True")
if isinstance(args, list):
args = subprocess.list2cmdline(args)
@@ -3477,11 +3537,11 @@ def launchProcess(self, args, **kwargs):
else:
# Leave args as a list for Popen, but still log the string command.
logger.info(subprocess.list2cmdline(args))
- p = subprocess.Popen(args, **kwargs)
- return p
+ return subprocess.Popen(args, **kwargs)
except Exception:
self.restoreOverrideCursor()
- self.showCriticalMessage("Operation failed. {} may not be installed.".format(args[0]),
+ cmd = args[0] if isinstance(args, list) else args.split()[0]
+ self.showCriticalMessage("Operation failed. {} may not be installed.".format(cmd),
traceback.format_exc())
@Slot(bool)
@@ -3552,8 +3612,8 @@ def showWarningMessage(self, message, details=None, title=None, icon=QtWidgets.Q
return msgBox.exec_()
return QtWidgets.QMessageBox.warning(self, title, message)
- @Slot(str, str, bool)
- def _changeTabName(self, text, toolTip, dirty):
+ @Slot(QtWidgets.QWidget, str, str, bool)
+ def _changeTabName(self, tab, text, toolTip, dirty):
""" Change the displayed name of a tab.
Called via signal from a tab when the tab's dirty state changes.
@@ -3566,7 +3626,6 @@ def _changeTabName(self, text, toolTip, dirty):
dirty : `bool`
Dirty state of tab
"""
- tab = self.sender()
idx = self.tabWidget.indexOf(tab)
if idx == -1:
logger.debug("Tab not found for %s", text)
@@ -3608,7 +3667,7 @@ def dirtySave(self, tab=None):
if btn == QtWidgets.QMessageBox.Cancel:
return False
elif btn == QtWidgets.QMessageBox.Save:
- return self.saveTab(tab)
+ return self.saveTab(tab=tab)
else: # Discard
tab.setDirty(False)
return True
@@ -3651,7 +3710,7 @@ def onOpenLinkNewTab(self):
def onOpenLinkWith(self):
""" Show the "Open With..." dialog for the currently highlighted link.
"""
- self.launchProgramOfChoice(QtCore.QDir.toNativeSeparators(self.linkHighlighted.toLocalFile()))
+ self.launchProgramOfChoice(path=QtCore.QDir.toNativeSeparators(self.linkHighlighted.toLocalFile()))
@Slot(str)
def onBreadcrumbActivated(self, path):
@@ -3672,6 +3731,20 @@ def onBreadcrumbHovered(self, path):
"""
self.statusbar.showMessage(path, 2000)
+ def updateRecentMenus(self, link, fullUrlStr):
+ """ Update the history and recently open files menus.
+
+ :Parameters:
+ link : `QtCore.QUrl`
+ URL to file
+ fullUrlStr : `str`
+ URL string representation
+ """
+ self.addItemToMenu(link, self.menuHistory, slot=self.setSource, maxLen=25, start=3, end=2)
+ self.addItemToMenu(link, self.menuOpenRecent, slot=self.openRecent, maxLen=RECENT_FILES)
+ self.menuOpenRecent.setEnabled(True)
+ self.addRecentFileToSettings(fullUrlStr)
+
class AddressBar(QtWidgets.QLineEdit):
"""
@@ -4247,7 +4320,7 @@ class BrowserTab(QtWidgets.QWidget):
restoreTab = Signal(QtWidgets.QWidget)
openFile = Signal(str)
openOldUrl = Signal(QtCore.QUrl)
- tabNameChanged = Signal(str, str, bool)
+ tabNameChanged = Signal(QtWidgets.QWidget, str, str, bool)
def __init__(self, parent=None):
""" Create and initialize the tab.
@@ -4271,6 +4344,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.parser = None # File parser for the currently active file type, used to add extra Commands menu actions.
font = parent.font()
prefs = parent.window().preferences
@@ -4576,7 +4650,7 @@ def setDirty(self, dirty=True):
elif self.fileFormat == FILE_FORMAT_USDZ:
tipSuffix += " (zip)"
text = "*{}*".format(fileName) if dirty else fileName
- self.tabNameChanged.emit(text, text + tipSuffix, dirty)
+ self.tabNameChanged.emit(self, text, text + tipSuffix, dirty)
def setIndentSettings(self, useSpaces=True, tabSpaces=4, autoIndent=True):
""" Set various indent settings, such as spaces for tabs and auto indentation
@@ -4731,6 +4805,10 @@ def run(self):
# Initialize the application and settings.
self._set_log_level()
logger.debug("Qt version: %s %s", Qt.__binding__, Qt.__binding_version__)
+ # Avoid the following with PySide2: "Qt WebEngine seems to be initialized from a plugin. Please set
+ # Qt::AA_ShareOpenGLContexts using QCoreApplication::setAttribute before constructing QGuiApplication."
+ if Qt.IsPySide2:
+ QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts)
self.app = QtWidgets.QApplication(sys.argv)
self.app.setApplicationName(self.appName)
self.app.setWindowIcon(QtGui.QIcon(":images/images/logo.png"))
diff --git a/usdmanager/file_dialog.py b/usdmanager/file_dialog.py
index 9841d09..c624244 100644
--- a/usdmanager/file_dialog.py
+++ b/usdmanager/file_dialog.py
@@ -41,6 +41,11 @@ def __init__(self, parent=None, caption="", directory="", filters=None, selected
Show hidden files
"""
super(FileDialog, self).__init__(parent, caption, directory, ';;'.join(filters or FILE_FILTER))
+
+ # The following line avoids this warning with Qt5:
+ # "GtkDialog mapped without a transient parent. This is discouraged."
+ self.setOption(QFileDialog.DontUseNativeDialog)
+
if selectedFilter:
self.selectNameFilter(selectedFilter)
if showHidden:
diff --git a/usdmanager/find_dialog.py b/usdmanager/find_dialog.py
index ed20aa5..660eb0a 100644
--- a/usdmanager/find_dialog.py
+++ b/usdmanager/find_dialog.py
@@ -13,54 +13,44 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
+""" Create the Find or Find/Replace dialog.
+"""
from Qt.QtCore import Slot
-from Qt.QtWidgets import QStatusBar
+from Qt.QtWidgets import QDialog, QStatusBar
from Qt.QtGui import QIcon, QTextDocument
-from .utils import loadUiType
+from .utils import loadUiWidget
-try:
- UI_TYPE = loadUiType("find_dialog.ui")
-except KeyError:
- # Building docs, have a safe fallback
- from Qt.QtWidgets import QDialog
- UI_TYPE = QDialog
-class FindDialog(UI_TYPE):
+class FindDialog(QDialog):
"""
Find/Replace dialog
"""
def __init__(self, parent=None, **kwargs):
""" Initialize the dialog.
-
+
:Parameters:
parent : `QtWidgets.QWidget` | None
Parent widget
"""
super(FindDialog, self).__init__(parent, **kwargs)
- self.setupUi(self)
+ self.setupUi()
self.connectSignals()
-
- def setupUi(self, widget):
- """
- Creates and lays out the widgets defined in the ui file.
-
- :Parameters:
- widget : `QtWidgets.QWidget`
- Base widget
+
+ def setupUi(self):
+ """ Creates and lays out the widgets defined in the ui file.
"""
- super(FindDialog, self).setupUi(widget)
+ 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"))
-
+
def connectSignals(self):
- """
- Connect signals to slots.
+ """ Connect signals to slots.
"""
self.findLineEdit.textChanged.connect(self.updateButtons)
-
+
def searchFlags(self):
""" Get find flags based on checked options.
@@ -82,7 +72,7 @@ def searchFlags(self):
def updateButtons(self, text):
"""
Update enabled state of buttons as entered text changes.
-
+
:Parameters:
text : `str`
Currently entered find text
@@ -96,13 +86,13 @@ def updateButtons(self, text):
if not enabled:
self.statusBar.clearMessage()
self.setStyleSheet("QLineEdit#findLineEdit{background:none}")
-
+
@Slot(bool)
def updateForEditMode(self, edit):
"""
Show/Hide text replacement options based on if we are editing or not.
If editing, allow replacement of the found text.
-
+
:Parameters:
edit : `bool`
If in edit mode or not
diff --git a/usdmanager/linenumbers.py b/usdmanager/linenumbers.py
index cb759e5..1f5bcd2 100644
--- a/usdmanager/linenumbers.py
+++ b/usdmanager/linenumbers.py
@@ -33,7 +33,7 @@ class PlainTextLineNumbers(QWidget):
"""
def __init__(self, parent):
""" Initialize the line numbers widget.
-
+
:Parameters:
parent : `QPlainTextEdit`
Text widget
@@ -42,17 +42,18 @@ def __init__(self, parent):
self.textWidget = parent
self._hiddenByUser = False
self._highlightCurrentLine = True
+ self._movePos = None
# Monospaced font to keep width from shifting.
font = QFont()
font.setStyleHint(QFont.Courier)
font.setFamily("Monospace")
self.setFont(font)
-
+
self.connectSignals()
self.updateLineWidth()
self.highlightCurrentLine()
-
+
def blockCount(self):
return self.textWidget.blockCount()
@@ -74,7 +75,7 @@ def highlightCurrentLine(self):
"""
if not self._highlightCurrentLine:
return False
-
+
extras = [x for x in self.textWidget.extraSelections() if x.format.property(QTextFormat.UserProperty) != "line"]
selection = QTextEdit.ExtraSelection()
lineColor = QColor(QtCore.Qt.darkGray).darker() if self.window().isDarkTheme() else \
@@ -96,7 +97,7 @@ def highlightCurrentLine(self):
'''
self.textWidget.setExtraSelections(extras)
return True
-
+
def lineWidth(self, count=0):
""" Calculate the width of the widget based on the block count.
@@ -114,6 +115,64 @@ def lineWidth(self, count=0):
# Obsolete in Qt 5.
return 6 + self.fontMetrics().width(blocks)
+ def mouseMoveEvent(self, event):
+ """ Track mouse movement to select more lines if press is active.
+
+ :Parameters:
+ event : `QMouseEvent`
+ Mouse move event
+ """
+ if event.buttons() != QtCore.Qt.LeftButton:
+ event.accept()
+ return
+
+ cursor = self.textWidget.textCursor()
+ cursor2 = self.textWidget.cursorForPosition(event.pos())
+ new = cursor2.position()
+ if new == self._movePos:
+ event.accept()
+ return
+
+ cursor.setPosition(self._movePos)
+ if new > self._movePos:
+ cursor.movePosition(cursor.StartOfLine)
+ cursor2.movePosition(cursor2.EndOfLine)
+ else:
+ cursor.movePosition(cursor.EndOfLine)
+ cursor2.movePosition(cursor2.StartOfLine)
+ cursor.setPosition(cursor2.position(), cursor.KeepAnchor)
+ self.textWidget.setTextCursor(cursor)
+ event.accept()
+
+ def mousePressEvent(self, event):
+ """ Select the line that was clicked. If moved while pressed, select
+ multiple lines as the mouse moves.
+
+ :Parameters:
+ event : `QMouseEvent`
+ Mouse press event
+ """
+ if event.buttons() != QtCore.Qt.LeftButton:
+ event.accept()
+ return
+
+ cursor = self.textWidget.cursorForPosition(event.pos())
+ cursor.select(cursor.LineUnderCursor)
+
+ # Allow Shift-selecting lines from the previous selection to new position.
+ if self.textWidget.textCursor().hasSelection() and event.modifiers() == QtCore.Qt.ShiftModifier:
+ cursor2 = self.textWidget.textCursor()
+ self._movePos = cursor2.position()
+ start = min(cursor.selectionStart(), cursor2.selectionStart())
+ end = max(cursor.selectionEnd(), cursor2.selectionEnd())
+ cursor.setPosition(start)
+ cursor.setPosition(end, cursor.KeepAnchor)
+ else:
+ self._movePos = cursor.position()
+
+ self.textWidget.setTextCursor(cursor)
+ event.accept()
+
def onEditorResize(self):
""" Adjust line numbers size if the text widget is resized.
"""
@@ -157,15 +216,15 @@ def paintEvent(self, event):
top = bottom
bottom = top + round(textWidget.blockBoundingRect(block).height())
blockNumber += 1
-
+
def setVisible(self, visible):
super(PlainTextLineNumbers, self).setVisible(visible)
self._hiddenByUser = not visible
self.updateLineWidth()
def sizeHint(self):
- return QSize(self.lineWidth(), 0)
-
+ return QSize(self.lineWidth(), self.textWidget.height())
+
@Slot(QRect, int)
def updateLineNumbers(self, rect, dY):
""" Scroll the line numbers or repaint the visible numbers.
@@ -176,7 +235,7 @@ def updateLineNumbers(self, rect, dY):
self.update(0, rect.y(), self.width(), rect.height())
if rect.contains(self.textWidget.viewport().rect()):
self.updateLineWidth()
-
+
@Slot(int)
def updateLineWidth(self, count=0):
""" Adjust display of text widget to account for the widget of the line numbers.
@@ -203,34 +262,34 @@ def connectSignals(self):
self.textWidget.currentCharFormatChanged.connect(self.resizeAndUpdate)
self.textWidget.cursorPositionChanged.connect(self.highlightCurrentLine)
self.doc.blockCountChanged.connect(self.updateLineWidth)
-
+
@Slot()
def highlightCurrentLine(self):
""" Make sure the active line number is redrawn in bold by calling update.
"""
if super(LineNumbers, self).highlightCurrentLine():
self.update()
-
+
@Slot(QTextCharFormat)
def resizeAndUpdate(self, *args):
""" Resize bar if needed.
"""
self.updateLineWidth()
super(LineNumbers, self).update()
-
+
def paintEvent(self, event):
""" Draw line numbers.
"""
painter = QPainter(self)
painter.fillRect(event.rect(), QColor(QtCore.Qt.darkGray).darker(300) if self.window().isDarkTheme() \
else QtCore.Qt.lightGray)
-
+
textWidget = self.textWidget
doc = textWidget.document()
vScrollPos = textWidget.verticalScrollBar().value()
pageBtm = vScrollPos + textWidget.viewport().height()
currBlock = doc.findBlock(textWidget.textCursor().position())
-
+
width = self.width() - 3 # 3 is magic padding number
height = textWidget.fontMetrics().height()
flags = QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter
@@ -244,19 +303,20 @@ def paintEvent(self, event):
# Find roughly the current top-most visible block.
block = doc.begin()
- lineHeight = doc.documentLayout().blockBoundingRect(block).height()
-
+ layout = doc.documentLayout()
+ lineHeight = layout.blockBoundingRect(block).height()
+
block = doc.findBlockByNumber(int(vScrollPos / lineHeight))
currLine = block.blockNumber()
-
+
while block.isValid():
currLine += 1
-
+
# Check if the position of the block is outside the visible area.
- yPos = doc.documentLayout().blockBoundingRect(block).topLeft().y()
+ yPos = layout.blockBoundingRect(block).topLeft().y()
if yPos > pageBtm:
break
-
+
# Make the line number for the selected line bold.
font.setBold(block == currBlock)
painter.setFont(font)
diff --git a/usdmanager/main_window.ui b/usdmanager/main_window.ui
index 8784c4b..b3ed66b 100644
--- a/usdmanager/main_window.ui
+++ b/usdmanager/main_window.ui
@@ -505,7 +505,7 @@
-
+