From 6b9e8804ab94a503b69097dec789508f6ab925e2 Mon Sep 17 00:00:00 2001 From: John Haddon Date: Tue, 24 Jul 2018 17:07:23 +0100 Subject: [PATCH] Layouts/LayoutMenu : Improve management of custom layouts - LayoutMenu : - New "Default/..." submenu allows the default layout to be chosen - New "Save As/*" options allow previously saved layouts to be replaced - Layouts : - Added `persistent` argument to `add()` method, mirroring the `Bookmarks.add()` API. This automatically takes care of saving persistent layouts into the startup location. - Added `setDefault()/getDefault()` and `createDefault()` methods to allow the management of a default layout. Breaking Changes : - Layouts : - Removed `save()` method. Use the `persistent` argument to `add()` and `setDefault()` instead. - Added applicationRoot argument to constructor. You should use `acquire()` instead anyway. - LayoutMenu : - Removed `delete()` method. - GUI config : Renamed standard layout from "Default" to "Standard". Fixes #51 --- python/GafferUI/LayoutMenu.py | 76 ++++++++++---------- python/GafferUI/Layouts.py | 107 +++++++++++++++++++---------- python/GafferUI/ScriptWindow.py | 4 +- python/GafferUITest/LayoutsTest.py | 84 ++++++++++++++++++++++ python/GafferUITest/__init__.py | 1 + startup/gui/layouts.py | 4 +- 6 files changed, 196 insertions(+), 80 deletions(-) create mode 100644 python/GafferUITest/LayoutsTest.py diff --git a/python/GafferUI/LayoutMenu.py b/python/GafferUI/LayoutMenu.py index 3e0bc770c50..42c1a2a8d41 100644 --- a/python/GafferUI/LayoutMenu.py +++ b/python/GafferUI/LayoutMenu.py @@ -35,8 +35,7 @@ # ########################################################################## -import os -import re +import functools import IECore @@ -57,13 +56,6 @@ def restore( menu, name ) : scriptWindow.setLayout( layout ) -## A function suitable as the command for a Layout/Delete/LayoutName menu item. -def delete( name, menu ) : - - scriptWindow, layouts = __scriptWindowAndLayouts( menu ) - layouts.remove( name ) - __saveLayouts( scriptWindow.scriptNode().applicationRoot() ) - ## A function suitable as the command for a Layout/Save... menu item. It must be invoked from # a menu which has a ScriptWindow in its ancestry. def save( menu ) : @@ -75,29 +67,18 @@ def save( menu ) : while True : layoutName = "Layout " + str( i ) i += 1 - if "user:" + layoutName not in layoutNames : + if layoutName not in layoutNames : break d = GafferUI.TextInputDialogue( initialText=layoutName, title="Save Layout", confirmLabel="Save" ) t = d.waitForText( parentWindow = scriptWindow ) d.setVisible( False ) - if t is None : + if not t : return layout = scriptWindow.getLayout() - - layouts.add( "user:" + t, layout ) - - __saveLayouts( scriptWindow.scriptNode().applicationRoot() ) - -def __saveLayouts( applicationRoot ) : - - f = open( os.path.join( applicationRoot.preferencesLocation(), "layouts.py" ), "w" ) - f.write( "# This file was automatically generated by Gaffer.\n" ) - f.write( "# Do not edit this file - it will be overwritten.\n\n" ) - - GafferUI.Layouts.acquire( applicationRoot ).save( f, re.compile( "user:.*" ) ) + layouts.add( t, layout, persistent = True ) def fullScreen( menu, checkBox ) : @@ -118,37 +99,50 @@ def layoutMenuCallable( menu ) : menuDefinition = IECore.MenuDefinition() - layoutNames = sorted( layouts.names() ) + # Menu items to set layout + layoutNames = sorted( layouts.names() ) + for name in layoutNames : + menuDefinition.append( "/" + name, { "command" : functools.partial( restore, name = name ) } ) if layoutNames : + menuDefinition.append( "/SetDivider", { "divider" : True } ) - def restoreWrapper( name ) : - - return lambda menu : restore( menu, name ) - - for name in layoutNames : + # Menu items to choose default - label = name - if label.startswith( "user:" ) : - label = label[5:] + def __setDefault( layouts, name, *unused ) : - menuDefinition.append( label, { "command" : restoreWrapper( name ) } ) + layouts.setDefault( name, persistent = True ) - menuDefinition.append( "/SetDivider", { "divider" : True } ) + for name in layoutNames : + menuDefinition.append( + "/Default/" + name, + { + "command" : functools.partial( __setDefault, layouts, name ), + "checkBox" : layouts.getDefault() == name + } + ) + if layoutNames : + menuDefinition.append( "/DefaultDivider", { "divider" : True } ) - def deleteWrapper( name ) : + # Menu items to save layout - return lambda menu, : delete( name, menu ) + persistentLayoutNames = sorted( layouts.names( persistent = True ) ) + for name in persistentLayoutNames : + menuDefinition.append( "/Save As/" + name, { "command" : functools.partial( layouts.add, name, scriptWindow.getLayout(), persistent = True ) } ) + if persistentLayoutNames : + menuDefinition.append( "/Save As/Divider", { "divider" : True } ) - for name in layoutNames : + menuDefinition.append( "/Save As/New Layout...", { "command" : save } ) - if name.startswith( "user:" ) : + # Menu items to delete layouts - menuDefinition.append( "/Delete/%s" % name[5:], { "command" : deleteWrapper( name ) } ) + if persistentLayoutNames : + for name in persistentLayoutNames : + menuDefinition.append( "/Delete/" + name, { "command" : functools.partial( layouts.remove, name = name ) } ) - menuDefinition.append( "/Save...", { "command" : save } ) + menuDefinition.append( "/SaveDeleteDivider", { "divider" : True } ) - menuDefinition.append( "/SaveDivider", { "divider" : True } ) + # Other menu items menuDefinition.append( "/Full Screen", { "command" : fullScreen, "checkBox" : fullScreenCheckBox, "shortCut" : "`" } ) diff --git a/python/GafferUI/Layouts.py b/python/GafferUI/Layouts.py index 0b5a98f8f4b..557c2f5c2da 100644 --- a/python/GafferUI/Layouts.py +++ b/python/GafferUI/Layouts.py @@ -35,7 +35,10 @@ # ########################################################################## +import collections +import os import re +import weakref import Gaffer import GafferUI @@ -49,11 +52,15 @@ # Layouts.acquire() method. class Layouts( object ) : - ## Typically acquire() should be used in preference - # to this constructor. - def __init__( self ) : + __Layout = collections.namedtuple( "__Layout", [ "repr", "persistent"] ) - self.__namedLayouts = {} + ## Use acquire() in preference to this constructor. + def __init__( self, applicationRoot ) : + + self.__applicationRoot = weakref.ref( applicationRoot ) + self.__namedLayouts = collections.OrderedDict() + self.__default = None + self.__defaultPersistent = False self.__registeredEditors = [] ## Acquires the set of layouts for the specified application. @@ -69,30 +76,44 @@ def acquire( cls, applicationOrApplicationRoot ) : try : return applicationRoot.__layouts except AttributeError : - pass - - applicationRoot.__layouts = Layouts() + applicationRoot.__layouts = Layouts( applicationRoot ) return applicationRoot.__layouts ## Serialises the passed Editor and stores it using the given name. This - # layout can then be recreated using the create() method below. - def add( self, name, editor ) : + # layout can then be recreated using the create() method below. If + # `persistent` is `True`, then the layout will be saved in the application + # preferences and restored when the application next runs. + def add( self, name, editor, persistent = False ) : if not isinstance( editor, basestring ) : editor = repr( editor ) - self.__namedLayouts[name] = editor + if name.startswith( "user:" ) : + # Backwards compatibility with old persistent layouts, which + # were differentiated by being prefixed with "user:". + persistent = True + name = name[5:] + + self.__namedLayouts[name] = self.__Layout( editor, persistent ) + + if persistent : + self.__save() ## Removes a layout previously stored with add(). def remove( self, name ) : - del self.__namedLayouts[name] + l = self.__namedLayouts.pop( name ) + if l.persistent : + self.__save() ## Returns a list of the names of currently defined layouts - def names( self ) : + def names( self, persistent = None ) : - return self.__namedLayouts.keys() + return [ + item[0] for item in self.__namedLayouts.items() + if persistent is None or item[1].persistent == persistent + ] ## Recreates a previously stored layout for the specified script, # returning it in the form of a CompoundEditor. @@ -104,39 +125,37 @@ def create( self, name, scriptNode ) : contextDict = { "scriptNode" : scriptNode } imported = set() classNameRegex = re.compile( "[a-zA-Z]*Gaffer[^(,]*\(" ) - for className in classNameRegex.findall( layout ) : + for className in classNameRegex.findall( layout.repr ) : moduleName = className.partition( "." )[0] if moduleName not in imported : exec( "import %s" % moduleName, contextDict, contextDict ) imported.add( moduleName ) - return eval( layout, contextDict, contextDict ) + return eval( layout.repr, contextDict, contextDict ) - ## Saves all layouts whose name matches the optional regular expression into the file object - # specified. If the file is later evaluated during application startup, it will reregister - # the layouts with the application. - ## \todo Remove this method and follow the model in Bookmarks.py, where user bookmarks - # are saved automatically. This wasn't possible when Layouts.py was first introduced, - # because at that point in time, the Layouts class didn't have access to an application. - def save( self, fileObject, nameRegex = None ) : + def setDefault( self, name, persistent = False ) : - # decide what to write - namesToWrite = [] - for name in self.names() : - if nameRegex.match( name ) or nameRegex is None : - namesToWrite.append( name ) + if name == self.__default and persistent == self.__defaultPersistent : + return - # write the necessary import statement and acquire the layouts - fileObject.write( "import GafferUI\n\n" ) - fileObject.write( "layouts = GafferUI.Layouts.acquire( application )\n\n" ) + if name not in self.__namedLayouts : + raise KeyError( name ) - # finally write out the layouts - for name in namesToWrite : - fileObject.write( "layouts.add( {0}, {1} )\n\n".format( repr( name ), repr( self.__namedLayouts[name] ) ) ) + self.__default = name + self.__defaultPersistent = persistent + if persistent : + self.__save() - # tidy up by deleting the temporary variable, keeping the namespace clean for - # subsequently executed config files. - fileObject.write( "del layouts\n" ) + def getDefault( self ) : + + return self.__default + + def createDefault( self, scriptNode ) : + + if self.__default in self.__namedLayouts : + return self.create( self.__default, scriptNode ) + else : + return GafferUI.CompoundEditor( scriptNode ) ## The Editor factory provides access to every single registered subclass of # editor, but specific applications may wish to only provide a subset of those @@ -157,3 +176,19 @@ def deregisterEditor( self, editorName ) : def registeredEditors( self ) : return self.__registeredEditors + + def __save( self ) : + + f = open( os.path.join( self.__applicationRoot().preferencesLocation(), "layouts.py" ), "w" ) + f.write( "# This file was automatically generated by Gaffer.\n" ) + f.write( "# Do not edit this file - it will be overwritten.\n\n" ) + + f.write( "import GafferUI\n\n" ) + f.write( "layouts = GafferUI.Layouts.acquire( application )\n" ) + + for name, layout in self.__namedLayouts.items() : + if layout.persistent : + f.write( "layouts.add( {0}, {1}, persistent = True )\n".format( repr( name ), repr( layout.repr ) ) ) + + if self.__defaultPersistent and self.__default in self.__namedLayouts : + f.write( "layouts.setDefault( {0}, persistent = True )\n".format( repr( self.__default ) ) ) diff --git a/python/GafferUI/ScriptWindow.py b/python/GafferUI/ScriptWindow.py index 486a1fcac55..b820e44f47b 100644 --- a/python/GafferUI/ScriptWindow.py +++ b/python/GafferUI/ScriptWindow.py @@ -57,8 +57,8 @@ def __init__( self, script, **kw ) : applicationRoot = self.__script.ancestor( Gaffer.ApplicationRoot ) layouts = GafferUI.Layouts.acquire( applicationRoot ) if applicationRoot is not None else None - if layouts is not None and "Default" in layouts.names() : - self.setLayout( layouts.create( "Default", script ) ) + if layouts is not None : + self.setLayout( layouts.createDefault( script ) ) else : self.setLayout( GafferUI.CompoundEditor( script ) ) diff --git a/python/GafferUITest/LayoutsTest.py b/python/GafferUITest/LayoutsTest.py new file mode 100644 index 00000000000..343075d2ef1 --- /dev/null +++ b/python/GafferUITest/LayoutsTest.py @@ -0,0 +1,84 @@ +########################################################################## +# +# Copyright (c) 2018, Image Engine Design Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above +# copyright notice, this list of conditions and the following +# disclaimer. +# +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided with +# the distribution. +# +# * Neither the name of John Haddon nor the names of +# any other contributors to this software may be used to endorse or +# promote products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +########################################################################## + +import Gaffer +import GafferUI +import GafferUITest + +class LayoutsTest( GafferUITest.TestCase ) : + + def testAcquire( self ) : + + a = Gaffer.ApplicationRoot( "testApp" ) + self.assertIsInstance( GafferUI.Layouts.acquire( a ), GafferUI.Layouts ) + self.assertIs( GafferUI.Layouts.acquire( a ), GafferUI.Layouts.acquire( a ) ) + + def testAddAndRemove( self ) : + + a = Gaffer.ApplicationRoot( "testApp" ) + l = GafferUI.Layouts.acquire( a ) + self.assertEqual( l.names(), [] ) + + l.add( "JustTheGraphEditor", "GafferUI.GraphEditor( script )" ) + self.assertEqual( l.names(), [ "JustTheGraphEditor" ] ) + + l.add( "JustTheNodeEditor", "GafferUI.NodeEditor( script )" ) + self.assertEqual( l.names(), [ "JustTheGraphEditor", "JustTheNodeEditor" ] ) + + l.remove( "JustTheGraphEditor" ) + self.assertEqual( l.names(), [ "JustTheNodeEditor" ] ) + + l.remove( "JustTheNodeEditor" ) + self.assertEqual( l.names(), [] ) + + def testPersistence( self ) : + + a = Gaffer.ApplicationRoot( "testApp" ) + l = GafferUI.Layouts.acquire( a ) + self.assertEqual( l.names(), [] ) + + l.add( "JustTheGraphEditor", "GafferUI.GraphEditor( script )" ) + self.assertEqual( l.names(), [ "JustTheGraphEditor" ] ) + self.assertEqual( l.names( persistent = False ), [ "JustTheGraphEditor" ] ) + self.assertEqual( l.names( persistent = True ), [] ) + + l.add( "JustTheNodeEditor", "GafferUI.NodeEditor( script )", persistent = True ) + self.assertEqual( l.names(), [ "JustTheGraphEditor", "JustTheNodeEditor" ] ) + self.assertEqual( l.names( persistent = False ), [ "JustTheGraphEditor" ] ) + self.assertEqual( l.names( persistent = True ), [ "JustTheNodeEditor" ] ) + +if __name__ == "__main__": + unittest.main() diff --git a/python/GafferUITest/__init__.py b/python/GafferUITest/__init__.py index fd5c22e6dd1..7f9a2aadb16 100644 --- a/python/GafferUITest/__init__.py +++ b/python/GafferUITest/__init__.py @@ -112,6 +112,7 @@ from ErrorDialogueTest import ErrorDialogueTest from WidgetAlgoTest import WidgetAlgoTest from BackupsTest import BackupsTest +from LayoutsTest import LayoutsTest if __name__ == "__main__": unittest.main() diff --git a/startup/gui/layouts.py b/startup/gui/layouts.py index c99b6e46a96..9186610513d 100644 --- a/startup/gui/layouts.py +++ b/startup/gui/layouts.py @@ -52,6 +52,8 @@ # register some predefined layouts -layouts.add( "Default", "GafferUI.CompoundEditor( scriptNode, children = ( GafferUI.SplitContainer.Orientation.Vertical, 0.97, ( ( GafferUI.SplitContainer.Orientation.Horizontal, 0.70, ( ( GafferUI.SplitContainer.Orientation.Vertical, 0.48, ( {'tabs': (GafferUI.Viewer( scriptNode ),), 'tabsVisible': True, 'currentTab': 0}, {'tabs': (GafferUI.GraphEditor( scriptNode ),), 'tabsVisible': True, 'currentTab': 0} ) ), ( GafferUI.SplitContainer.Orientation.Vertical, 0.54, ( {'tabs': (GafferUI.NodeEditor( scriptNode ), GafferSceneUI.SceneInspector( scriptNode )), 'tabsVisible': True, 'currentTab': 0}, {'tabs': (GafferSceneUI.HierarchyView( scriptNode ), GafferUI.ScriptEditor( scriptNode )), 'tabsVisible': True, 'currentTab': 0} ) ) ) ), {'tabs': (GafferUI.Timeline( scriptNode ),), 'tabsVisible': False, 'currentTab': 0} ) ) )" ) +layouts.add( "Standard", "GafferUI.CompoundEditor( scriptNode, children = ( GafferUI.SplitContainer.Orientation.Vertical, 0.97, ( ( GafferUI.SplitContainer.Orientation.Horizontal, 0.70, ( ( GafferUI.SplitContainer.Orientation.Vertical, 0.48, ( {'tabs': (GafferUI.Viewer( scriptNode ),), 'tabsVisible': True, 'currentTab': 0}, {'tabs': (GafferUI.GraphEditor( scriptNode ),), 'tabsVisible': True, 'currentTab': 0} ) ), ( GafferUI.SplitContainer.Orientation.Vertical, 0.54, ( {'tabs': (GafferUI.NodeEditor( scriptNode ), GafferSceneUI.SceneInspector( scriptNode )), 'tabsVisible': True, 'currentTab': 0}, {'tabs': (GafferSceneUI.HierarchyView( scriptNode ), GafferUI.ScriptEditor( scriptNode )), 'tabsVisible': True, 'currentTab': 0} ) ) ) ), {'tabs': (GafferUI.Timeline( scriptNode ),), 'tabsVisible': False, 'currentTab': 0} ) ) )" ) layouts.add( "Scene", "GafferUI.CompoundEditor( scriptNode, children = ( GafferUI.SplitContainer.Orientation.Horizontal, 0.772395, ( ( GafferUI.SplitContainer.Orientation.Horizontal, 0.256257, ( {'tabs': (GafferSceneUI.HierarchyView( scriptNode ),), 'tabsVisible': True, 'currentTab': 0, 'pinned': [False]}, ( GafferUI.SplitContainer.Orientation.Vertical, 0.500620, ( ( GafferUI.SplitContainer.Orientation.Vertical, 0.929648, ( {'tabs': (GafferUI.Viewer( scriptNode ),), 'tabsVisible': True, 'currentTab': 0, 'pinned': [False]}, {'tabs': (GafferUI.Timeline( scriptNode ),), 'tabsVisible': False, 'currentTab': 0, 'pinned': [None]} ) ), {'tabs': (GafferUI.GraphEditor( scriptNode ),), 'tabsVisible': True, 'currentTab': 0, 'pinned': [None]} ) ) ) ), ( GafferUI.SplitContainer.Orientation.Vertical, 0.500620, ( {'tabs': (GafferUI.NodeEditor( scriptNode ),), 'tabsVisible': True, 'currentTab': 0, 'pinned': [False]}, {'tabs': (GafferSceneUI.SceneInspector( scriptNode ),), 'tabsVisible': True, 'currentTab': 0, 'pinned': [False]} ) ) ) ) )" ) layouts.add( "Empty", "GafferUI.CompoundEditor( scriptNode )" ) + +layouts.setDefault( "Standard" )