Browse Source

wxGUI refactoring: New WorkspaceManager class (#1437)

Linda Kladivova 4 years ago
parent
commit
a6d76822cc

+ 3 - 0
gui/wxpython/core/giface.py

@@ -239,6 +239,9 @@ class StandaloneGrassInterface(GrassInterface):
         # Signal emitted to request updating of map
         self.updateMap = Signal('StandaloneGrassInterface.updateMap')
 
+        # Signal emitted when workspace is changed
+        self.workspaceChanged = Signal('StandaloneGrassInterface.workspaceChanged')
+
         # workaround, standalone grass interface should be moved to sep. file
         from core.gconsole import GConsole, \
             EVT_CMD_OUTPUT, EVT_CMD_PROGRESS

+ 24 - 486
gui/wxpython/lmgr/frame.py

@@ -20,14 +20,9 @@ This program is free software under the GNU General Public License
 
 import sys
 import os
-import tempfile
 import stat
 import platform
 import re
-try:
-    import xml.etree.ElementTree as etree
-except ImportError:
-    import elementtree.ElementTree as etree  # Python <= 2.4
 
 from core import globalvar
 import wx
@@ -53,7 +48,6 @@ from gui_core.preferences import MapsetAccess, PreferencesDialog
 from lmgr.layertree import LayerTree, LMIcons
 from lmgr.menudata import LayerManagerMenuData, LayerManagerModuleTree
 from gui_core.widgets import GNotebook, FormNotebook
-from core.workspace import ProcessWorkspaceFile, ProcessGrcFile, WriteWorkspaceFile
 from core.gconsole import GConsole, EVT_IGNORED_CMD_RUN
 from core.giface import Notification
 from gui_core.goutput import GConsoleWindow, GC_PROMPT
@@ -63,6 +57,7 @@ from gui_core.menu import Menu as GMenu
 from core.debug import Debug
 from lmgr.toolbars import LMWorkspaceToolbar, LMToolsToolbar
 from lmgr.toolbars import LMMiscToolbar, LMNvizToolbar, DisplayPanelToolbar
+from lmgr.workspace import WorkspaceManager
 from lmgr.pyshell import PyShellWindow
 from lmgr.giface import LayerManagerGrassInterface
 from datacatalog.catalog import DataCatalog
@@ -97,14 +92,16 @@ class GMFrame(wx.Frame):
         self.displayIndex = 0          # index value for map displays and layer trees
         self.currentPage = None       # currently selected page for layer tree notebook
         self.currentPageNum = None       # currently selected page number for layer tree notebook
-        self.workspaceFile = workspace    # workspace file
-        self.workspaceChanged = False     # track changes in workspace
-        # if we are currently loading workspace to ignore some events
-        self.loadingWorkspace = False
         self.cwdPath = None               # current working directory
 
         wx.Frame.__init__(self, parent=parent, id=id, size=size,
                           style=style, **kwargs)
+
+        self._giface = LayerManagerGrassInterface(self)
+
+        # workspace manager
+        self.workspace_manager = WorkspaceManager(lmgr=self,
+                                                  giface=self._giface)
         self._setTitle()
         self.SetName("LayerManager")
 
@@ -115,8 +112,6 @@ class GMFrame(wx.Frame):
                     'grass.ico'),
                 wx.BITMAP_TYPE_ICO))
 
-        self._giface = LayerManagerGrassInterface(self)
-
         menu_errors = []
 
         def add_menu_error(message):
@@ -218,12 +213,9 @@ class GMFrame(wx.Frame):
         self.Show()
 
         # load workspace file if requested
-        if self.workspaceFile:
-            # load given workspace file
-            if self.LoadWorkspaceFile(self.workspaceFile):
+        if workspace:
+            if self.workspace_manager.Load(workspace):
                 self._setTitle()
-            else:
-                self.workspaceFile = None
         else:
             # start default initial display
             self.NewDisplay(show=False)
@@ -239,8 +231,6 @@ class GMFrame(wx.Frame):
         # fix goutput's pane size (required for Mac OSX)`
         self.goutput.SetSashPosition(int(self.GetSize()[1] * .8))
 
-        self.workspaceChanged = False
-
         show_menu_errors(menu_errors)
 
         # start with layer manager on top
@@ -255,8 +245,8 @@ class GMFrame(wx.Frame):
         gisenv = grass.gisenv()
         location = gisenv["LOCATION_NAME"]
         mapset = gisenv["MAPSET"]
-        if self.workspaceFile:
-            filename = os.path.splitext(os.path.basename(self.workspaceFile))[0]
+        if self.workspace_manager.workspaceFile:
+            filename = os.path.splitext(os.path.basename(self.workspace_manager.workspaceFile))[0]
             self.SetTitle(
                 "{workspace} - {location}/{mapset} - {program}".format(
                     location=location,
@@ -514,14 +504,6 @@ class GMFrame(wx.Frame):
             self._auimgr.GetPane(toolbar).Row(1).Position(pos)
         self._auimgr.Update()
 
-    def WorkspaceChanged(self):
-        """Update window title"""
-        if not self.workspaceChanged:
-            self.workspaceChanged = True
-
-        if self.workspaceFile:
-            self._setTitle()
-
     def OnLocationWizard(self, event):
         """Launch location wizard"""
         gisenv = grass.gisenv()
@@ -682,7 +664,7 @@ class GMFrame(wx.Frame):
         # save changes in the workspace
         name = self.notebookLayers.GetPageText(event.GetSelection())
         caption = _("Close Map Display {}").format(name)
-        if not self.CanClosePage(caption):
+        if not self.workspace_manager.CanClosePage(caption):
             event.Veto()
             return
 
@@ -702,37 +684,6 @@ class GMFrame(wx.Frame):
         self.notebookLayers.Bind(FN.EVT_FLATNOTEBOOK_PAGE_CLOSING,
                                  self.OnCBPageClosing)
 
-    def CanClosePage(self, caption):
-        """Ask if page with map display(s) can be closed
-        """
-        # save changes in the workspace
-        maptree = self.GetLayerTree()
-        if  self.workspaceChanged and UserSettings.Get(
-                group='manager', key='askOnQuit', subkey='enabled'):
-            if self.workspaceFile:
-                message = _("Do you want to save changes in the workspace?")
-            else:
-                message = _("Do you want to store current settings "
-                            "to workspace file?")
-
-            # ask user to save current settings
-            if maptree.GetCount() > 0:
-                dlg = wx.MessageDialog(self,
-                                       message=message,
-                                       caption=caption,
-                                       style=wx.YES_NO | wx.YES_DEFAULT |
-                                       wx.CANCEL | wx.ICON_QUESTION | wx.CENTRE)
-                ret = dlg.ShowModal()
-                dlg.Destroy()
-                if ret == wx.ID_YES:
-                    if not self.workspaceFile:
-                        self.OnWorkspaceSaveAs()
-                    else:
-                        self.SaveToWorkspaceFile(self.workspaceFile)
-                elif ret == wx.ID_CANCEL:
-                    return False
-        return True
-
     def _switchPageHandler(self, event, notification):
         self._switchPage(notification=notification)
         event.Skip()
@@ -1315,437 +1266,24 @@ class GMFrame(wx.Frame):
         menu.Destroy()
 
     def OnWorkspaceNew(self, event=None):
-        """Create new workspace file
-
-        Erase current workspace settings first
-        """
-        Debug.msg(4, "GMFrame.OnWorkspaceNew():")
-
-        # start new map display if no display is available
-        if not self.currentPage:
-            self.NewDisplay()
-
-        maptrees = [self.notebookLayers.GetPage(i).maptree for i in range(self.notebookLayers.GetPageCount())]
-
-        # ask user to save current settings
-        if self.workspaceFile and self.workspaceChanged:
-            self.OnWorkspaceSave()
-        elif self.workspaceFile is None and any(tree.GetCount() for tree in maptrees):
-            dlg = wx.MessageDialog(
-                self,
-                message=_(
-                    "Current workspace is not empty. "
-                    "Do you want to store current settings "
-                    "to workspace file?"),
-                caption=_("Create new workspace?"),
-                style=wx.YES_NO | wx.YES_DEFAULT | wx.CANCEL | wx.ICON_QUESTION)
-            ret = dlg.ShowModal()
-            if ret == wx.ID_YES:
-                self.OnWorkspaceSaveAs()
-            elif ret == wx.ID_CANCEL:
-                dlg.Destroy()
-                return
-
-            dlg.Destroy()
-
-        # delete all layers in map displays
-        for maptree in maptrees:
-            maptree.DeleteAllLayers()
-
-        # delete all decorations
-        for display in self.GetAllMapDisplays():
-            for overlayId in display.decorations.keys():
-                display.RemoveOverlay(overlayId)
-
-        # no workspace file loaded
-        self.workspaceFile = None
-        self.workspaceChanged = False
-        self._setTitle()
+        """Create new workspace file"""
+        self.workspace_manager.New()
 
     def OnWorkspaceOpen(self, event=None):
         """Open file with workspace definition"""
-        dlg = wx.FileDialog(
-            parent=self,
-            message=_("Choose workspace file"),
-            defaultDir=os.getcwd(),
-            wildcard=_("GRASS Workspace File (*.gxw)|*.gxw"))
-
-        filename = ''
-        if dlg.ShowModal() == wx.ID_OK:
-            filename = dlg.GetPath()
-
-        if filename == '':
-            return
-
-        Debug.msg(4, "GMFrame.OnWorkspaceOpen(): filename=%s" % filename)
-
-        # delete current layer tree content
-        self.OnWorkspaceClose()
-        self.loadingWorkspace = True
-        self.LoadWorkspaceFile(filename)
-        self.loadingWorkspace = False
-
-        self.workspaceFile = filename
-        self._setTitle()
-
-    def _tryToSwitchMapsetFromWorkspaceFile(self, gxwXml):
-        returncode, errors = RunCommand('g.mapset',
-                      dbase=gxwXml.database,
-                      location=gxwXml.location,
-                      mapset=gxwXml.mapset,
-                      getErrorMsg=True,
-                                        )
-        if returncode != 0:
-            # TODO: use the function from grass.py
-            reason = _("Most likely the database, location or mapset"
-                       " does not exist")
-            details = errors
-            message = _("Unable to change to location and mapset"
-                        " specified in the workspace.\n"
-                        "Reason: {reason}\nDetails: {details}\n\n"
-                        "Do you want to proceed with opening"
-                        " the workspace anyway?"
-                        ).format(**locals())
-            dlg = wx.MessageDialog(
-                parent=self, message=message, caption=_(
-                    "Proceed with opening of the workspace?"),
-                style=wx.YES_NO | wx.NO_DEFAULT | wx.ICON_QUESTION)
-            dlg.CenterOnParent()
-            if dlg.ShowModal() in [wx.ID_NO, wx.ID_CANCEL]:
-                return False
-        else:
-            # TODO: copy from ChangeLocation function
-            GMessage(
-                parent=self,
-                message=_("Current location is <%(loc)s>.\n"
-                          "Current mapset is <%(mapset)s>.") %
-                {'loc': gxwXml.location,
-                           'mapset': gxwXml.mapset})
-        return True
-
-    def LoadWorkspaceFile(self, filename):
-        """Load layer tree definition stored in GRASS Workspace XML file (gxw)
-
-        .. todo::
-            Validate against DTD
-
-        :return: True on success
-        :return: False on error
-        """
-        # parse workspace file
-        try:
-            gxwXml = ProcessWorkspaceFile(etree.parse(filename))
-        except Exception as e:
-            GError(
-                parent=self, message=_(
-                    "Reading workspace file <%s> failed.\n"
-                    "Invalid file, unable to parse XML document.") %
-                filename)
-            return False
-
-        if gxwXml.database and gxwXml.location and gxwXml.mapset:
-            if not self._tryToSwitchMapsetFromWorkspaceFile(gxwXml):
-                return False
-
-        # the really busy part starts here (mapset change is fast)
-        busy = wx.BusyInfo(_("Please wait, loading workspace..."),
-                           parent=self)
-        wx.GetApp().Yield()
-
-        #
-        # load layer manager window properties
-        #
-        if UserSettings.Get(group='general', key='workspace', subkey=[
-                            'posManager', 'enabled']) is False:
-            if gxwXml.layerManager['pos']:
-                self.SetPosition(gxwXml.layerManager['pos'])
-            if gxwXml.layerManager['size']:
-                self.SetSize(gxwXml.layerManager['size'])
-            if gxwXml.layerManager['cwd']:
-                self.cwdPath = gxwXml.layerManager['cwd']
-                if os.path.isdir(self.cwdPath):
-                    os.chdir(self.cwdPath)
-
-        #
-        # start map displays first (list of layers can be empty)
-        #
-        displayId = 0
-        mapdisplay = list()
-        for display in gxwXml.displays:
-            mapdisp = self.NewDisplay(name=display['name'], show=False)
-            mapdisplay.append(mapdisp)
-            maptree = self.notebookLayers.GetPage(displayId).maptree
-
-            # set windows properties
-            mapdisp.SetProperties(render=display['render'],
-                                  mode=display['mode'],
-                                  showCompExtent=display['showCompExtent'],
-                                  alignExtent=display['alignExtent'],
-                                  constrainRes=display['constrainRes'],
-                                  projection=display['projection']['enabled'])
-
-            if display['projection']['enabled']:
-                if display['projection']['epsg']:
-                    UserSettings.Set(
-                        group='display',
-                        key='projection',
-                        subkey='epsg',
-                        value=display['projection']['epsg'])
-                    if display['projection']['proj']:
-                        UserSettings.Set(
-                            group='display',
-                            key='projection',
-                            subkey='proj4',
-                            value=display['projection']['proj'])
-
-            # set position and size of map display
-            if not UserSettings.Get(
-                    group='general', key='workspace',
-                    subkey=['posDisplay', 'enabled']):
-                if display['pos']:
-                    mapdisp.SetPosition(display['pos'])
-                if display['size']:
-                    mapdisp.SetSize(display['size'])
-
-            # set extent if defined
-            if display['extent']:
-                w, s, e, n, b, t = display['extent']
-                region = maptree.Map.region = maptree.Map.GetRegion(
-                    w=w, s=s, e=e, n=n)
-                mapdisp.GetWindow().ResetZoomHistory()
-                mapdisp.GetWindow().ZoomHistory(region['n'],
-                                                region['s'],
-                                                region['e'],
-                                                region['w'])
-            if 'showStatusbar' in display and not display['showStatusbar']:
-                mapdisp.statusbarManager.Show(False)
-            if 'showToolbars' in display and not display['showToolbars']:
-                for toolbar in mapdisp.GetToolbarNames():
-                    mapdisp.RemoveToolbar(toolbar)
-
-            displayId += 1
-            mapdisp.Show()  # show mapdisplay
-            # set render property to False to speed up loading layers
-            mapdisp.mapWindowProperties.autoRender = False
-
-        maptree = None
-        selectList = []  # list of selected layers
-        #
-        # load list of map layers
-        #
-        for layer in gxwXml.layers:
-            display = layer['display']
-            maptree = self.notebookLayers.GetPage(display).maptree
-            newItem = maptree.AddLayer(ltype=layer['type'],
-                                       lname=layer['name'],
-                                       lchecked=layer['checked'],
-                                       lopacity=layer['opacity'],
-                                       lcmd=layer['cmd'],
-                                       lgroup=layer['group'],
-                                       lnviz=layer['nviz'],
-                                       lvdigit=layer['vdigit'],
-                                       loadWorkspace=True)
-
-            if 'selected' in layer:
-                selectList.append((maptree, newItem, layer['selected']))
-
-        for maptree, layer, selected in selectList:
-            if selected:
-                if not layer.IsSelected():
-                    maptree.SelectItem(layer, select=True)
-            else:
-                maptree.SelectItem(layer, select=False)
-
-        del busy
-
-        # set render property again when all layers are loaded
-        for i, display in enumerate(gxwXml.displays):
-            mapdisplay[i].mapWindowProperties.autoRender = display['render']
-
-            for overlay in gxwXml.overlays:
-                # overlay["cmd"][0] name of command e.g. d.barscale, d.legend
-                # overlay["cmd"][1:] parameters and flags
-                if overlay['display'] == i:
-                    if overlay['cmd'][0] == "d.legend.vect":
-                        mapdisplay[i].AddLegendVect(overlay['cmd'])
-                    if overlay['cmd'][0] == "d.legend":
-                        mapdisplay[i].AddLegendRast(overlay['cmd'])
-                    if overlay['cmd'][0] == "d.barscale":
-                        mapdisplay[i].AddBarscale(overlay['cmd'])
-                    if overlay['cmd'][0] == "d.northarrow":
-                        mapdisplay[i].AddArrow(overlay['cmd'])
-                    if overlay['cmd'][0] == "d.text":
-                        mapdisplay[i].AddDtext(overlay['cmd'])
-
-            # avoid double-rendering when loading workspace
-            # mdisp.MapWindow2D.UpdateMap()
-            # nviz
-            if gxwXml.displays[i]['viewMode'] == '3d':
-                mapdisplay[i].AddNviz()
-                self.nviz.UpdateState(view=gxwXml.nviz_state['view'],
-                                      iview=gxwXml.nviz_state['iview'],
-                                      light=gxwXml.nviz_state['light'])
-                mapdisplay[i].MapWindow3D.constants = gxwXml.nviz_state['constants']
-                for idx, constant in enumerate(mapdisplay[i].MapWindow3D.constants):
-                    mapdisplay[i].MapWindow3D.AddConstant(constant, i + 1)
-                for page in ('view', 'light', 'fringe', 'constant', 'cplane'):
-                    self.nviz.UpdatePage(page)
-                self.nviz.UpdateSettings()
-                mapdisplay[i].toolbars['map'].combo.SetSelection(1)
-
-        return True
-
-    def OnWorkspaceLoadGrcFile(self, event):
-        """Load map layers from GRC file (Tcl/Tk GUI) into map layer tree"""
-        dlg = wx.FileDialog(
-            parent=self,
-            message=_("Choose GRC file to load"),
-            defaultDir=os.getcwd(),
-            wildcard=_("Old GRASS Workspace File (*.grc)|*.grc"))
-
-        filename = ''
-        if dlg.ShowModal() == wx.ID_OK:
-            filename = dlg.GetPath()
-
-        if filename == '':
-            return
-
-        Debug.msg(
-            4,
-            "GMFrame.OnWorkspaceLoadGrcFile(): filename=%s" %
-            filename)
-
-        # start new map display if no display is available
-        if not self.currentPage:
-            self.NewDisplay()
-
-        busy = wx.BusyInfo(_("Please wait, loading workspace..."),
-                           parent=self)
-        wx.GetApp().Yield()
-
-        maptree = None
-        for layer in ProcessGrcFile(filename).read(self):
-            maptree = self.notebookLayers.GetPage(layer['display']).maptree
-            newItem = maptree.AddLayer(ltype=layer['type'],
-                                       lname=layer['name'],
-                                       lchecked=layer['checked'],
-                                       lopacity=layer['opacity'],
-                                       lcmd=layer['cmd'],
-                                       lgroup=layer['group'])
-
-        del busy
-
-        if maptree:
-            # reverse list of map layers
-            maptree.Map.ReverseListOfLayers()
-
-    def OnWorkspaceSaveAs(self, event=None):
-        """Save workspace definition to selected file"""
-        dlg = wx.FileDialog(
-            parent=self,
-            message=_("Choose file to save current workspace"),
-            defaultDir=os.getcwd(),
-            wildcard=_("GRASS Workspace File (*.gxw)|*.gxw"),
-            style=wx.FD_SAVE)
-
-        filename = ''
-        if dlg.ShowModal() == wx.ID_OK:
-            filename = dlg.GetPath()
-
-        if filename == '':
-            return False
-
-        # check for extension
-        if filename[-4:] != ".gxw":
-            filename += ".gxw"
-
-        if os.path.exists(filename):
-            dlg = wx.MessageDialog(
-                self,
-                message=_(
-                    "Workspace file <%s> already exists. "
-                    "Do you want to overwrite this file?") %
-                filename,
-                caption=_("Save workspace"),
-                style=wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION)
-            if dlg.ShowModal() != wx.ID_YES:
-                dlg.Destroy()
-                return False
-
-        Debug.msg(4, "GMFrame.OnWorkspaceSaveAs(): filename=%s" % filename)
-
-        self.SaveToWorkspaceFile(filename)
-        self.workspaceFile = filename
-        self._setTitle()
+        self.workspace_manager.Open()
 
     def OnWorkspaceSave(self, event=None):
         """Save file with workspace definition"""
-        if self.workspaceFile:
-            dlg = wx.MessageDialog(
-                self,
-                message=_(
-                    "Workspace file <%s> already exists. "
-                    "Do you want to overwrite this file?") %
-                self.workspaceFile,
-                caption=_("Save workspace"),
-                style=wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION)
-            if dlg.ShowModal() == wx.ID_NO:
-                dlg.Destroy()
-            else:
-                Debug.msg(
-                    4, "GMFrame.OnWorkspaceSave(): filename=%s" %
-                    self.workspaceFile)
-                self.SaveToWorkspaceFile(self.workspaceFile)
-                self._setTitle()
-                self.workspaceChanged = False
-        else:
-            self.OnWorkspaceSaveAs()
-
-    def SaveToWorkspaceFile(self, filename):
-        """Save layer tree layout to workspace file
-
-        :return: True on success, False on error
-        """
-        tmpfile = tempfile.TemporaryFile(mode='w+b')
-        try:
-            WriteWorkspaceFile(lmgr=self, file=tmpfile)
-        except Exception as e:
-            GError(parent=self,
-                   message=_("Writing current settings to workspace file "
-                             "failed."))
-            return False
-
-        try:
-            mfile = open(filename, "wb")
-            tmpfile.seek(0)
-            for line in tmpfile.readlines():
-                mfile.write(line)
-        except IOError:
-            GError(
-                parent=self,
-                message=_("Unable to open file <%s> for writing.") %
-                filename)
-            return False
-
-        mfile.close()
+        self.workspace_manager.Save()
 
-        return True
+    def OnWorkspaceSaveAs(self, event=None):
+        """Save workspace definition to selected file"""
+        self.workspace_manager.SaveAs()
 
     def OnWorkspaceClose(self, event=None):
-        """Close file with workspace definition
-
-        If workspace has been modified ask user to save the changes.
-        """
-        Debug.msg(
-            4, "GMFrame.OnWorkspaceClose(): file=%s" %
-            self.workspaceFile)
-
-        self.DisplayCloseAll()
-        self.workspaceFile = None
-        self.workspaceChanged = False
-        self._setTitle()
-        self.displayIndex = 0
-        self.currentPage = None
+        """Close file with workspace definition"""
+        self.workspace_manager.Close()
 
     def OnDisplayClose(self, event=None):
         """Close current map display window
@@ -1756,7 +1294,7 @@ class GMFrame(wx.Frame):
     def OnDisplayCloseAll(self, event):
         """Close all open map display windows (from menu)
         """
-        if not self.CanClosePage(caption=_("Close all Map Displays")):
+        if not self.workspace_manager.CanClosePage(caption=_("Close all Map Displays")):
             return
         self.DisplayCloseAll()
 
@@ -2167,7 +1705,7 @@ class GMFrame(wx.Frame):
         # moved from mapdisp/frame.py
         # TODO: why it is called 3 times when getting focus?
         # and one times when loosing focus?
-        if self.loadingWorkspace:
+        if self.workspace_manager.loadingWorkspace:
             return
         pgnum = self.notebookLayers.GetPageIndex(notebookLayerPage)
         if pgnum > -1:
@@ -2565,7 +2103,7 @@ class GMFrame(wx.Frame):
             self._auimgr.UnInit()
             self.Destroy()
             return
-        if not self.CanClosePage(caption=_("Quit GRASS GUI")):
+        if not self.workspace_manager.CanClosePage(caption=_("Quit GRASS GUI")):
             # when called from menu, it gets CommandEvent and not
             # CloseEvent
             if hasattr(event, 'Veto'):

+ 3 - 0
gui/wxpython/lmgr/giface.py

@@ -204,6 +204,9 @@ class LayerManagerGrassInterface(object):
         # Signal emitted to request updating of map
         self.updateMap = Signal('LayerManagerGrassInterface.updateMap')
 
+        # Signal emitted when workspace is changed
+        self.workspaceChanged = Signal('LayerManagerGrassInterface.workspaceChanged')
+
     def RunCmd(self, *args, **kwargs):
         self.lmgr._gconsole.RunCmd(*args, **kwargs)
 

+ 7 - 5
gui/wxpython/lmgr/layertree.py

@@ -1382,6 +1382,8 @@ class LayerTree(treemixin.DragAndDrop, CT.CustomTreeCtrl):
         :param bool multiple: True to allow multiple map layers in layer tree
         :param bool loadWorkspace: True if called when loading workspace
         """
+        self._giface.workspaceChanged.emit()
+
         if lname and not multiple:
             # check for duplicates
             item = self.GetFirstChild(self.root)[0]
@@ -1629,7 +1631,7 @@ class LayerTree(treemixin.DragAndDrop, CT.CustomTreeCtrl):
         """Double click on the layer item.
         Launch property dialog, or expand/collapse group of items, etc.
         """
-        self.lmgr.WorkspaceChanged()
+        self._giface.workspaceChanged.emit()
         layer = event.GetItem()
 
         if self.GetLayerInfo(layer, key='type') == 'group':
@@ -1643,7 +1645,7 @@ class LayerTree(treemixin.DragAndDrop, CT.CustomTreeCtrl):
 
     def OnDeleteLayer(self, event):
         """Remove selected layer item from the layer tree"""
-        self.lmgr.WorkspaceChanged()
+        self._giface.workspaceChanged.emit()
         item = event.GetItem()
 
         try:
@@ -1719,7 +1721,7 @@ class LayerTree(treemixin.DragAndDrop, CT.CustomTreeCtrl):
                         if mapLayer:
                             # ignore when map layer is edited
                             self.Map.ChangeLayerActive(mapLayer, checked)
-                        self.lmgr.WorkspaceChanged()
+                        self._giface.workspaceChanged.emit()
                     child = self.GetNextSibling(child)
             else:
                 mapLayer = self.GetLayerInfo(item, key='maplayer')
@@ -1727,7 +1729,7 @@ class LayerTree(treemixin.DragAndDrop, CT.CustomTreeCtrl):
                         digitToolbar and digitToolbar.GetLayer() != mapLayer)):
                     # ignore when map layer is edited
                     self.Map.ChangeLayerActive(mapLayer, checked)
-                    self.lmgr.WorkspaceChanged()
+                    self._giface.workspaceChanged.emit()
 
         # nviz
         if self.mapdisplay.IsPaneShown('3d') and \
@@ -1739,7 +1741,7 @@ class LayerTree(treemixin.DragAndDrop, CT.CustomTreeCtrl):
 
             self.mapdisplay.SetStatusText(
                 _("Please wait, updating data..."), 0)
-            self.lmgr.WorkspaceChanged()
+            self._giface.workspaceChanged.emit()
 
             if checked:  # enable
                 if mapLayer.type == 'raster':

+ 486 - 0
gui/wxpython/lmgr/workspace.py

@@ -0,0 +1,486 @@
+"""
+@package lmgr::workspace
+
+@brief Workspace manager class for creating, loading and saving workspaces
+
+Class:
+ - lmgr::WorkspaceManager
+
+(C) 2021 by the GRASS Development Team
+
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS
+for details.
+"""
+
+import os
+import tempfile
+
+import xml.etree.ElementTree as etree
+
+import wx
+import wx.aui
+
+from core.settings import UserSettings
+from core.gcmd import RunCommand, GError, GMessage
+from core.workspace import ProcessWorkspaceFile, WriteWorkspaceFile
+from core.debug import Debug
+
+
+class WorkspaceManager:
+    """Workspace Manager for creating, loading and saving workspaces."""
+
+    def __init__(self, lmgr, giface):
+
+        self.lmgr = lmgr
+        self.workspaceFile = None
+        self._giface = giface
+        self.workspaceChanged = False  # track changes in workspace
+        self.loadingWorkspace = False
+
+        Debug.msg(1, "WorkspaceManager.__init__()")
+
+        self._giface.workspaceChanged.connect(self.WorkspaceChanged)
+
+    def WorkspaceChanged(self):
+        "Update window title"
+        self.workspaceChanged = True
+
+    def New(self):
+        """Create new workspace file
+        Erase current workspace settings first
+        """
+        Debug.msg(4, "WorkspaceManager.New():")
+
+        # start new map display if no display is available
+        if not self.lmgr.currentPage:
+            self.lmgr.NewDisplay()
+
+        maptrees = [
+            self.lmgr.notebookLayers.GetPage(i).maptree
+            for i in range(self.lmgr.notebookLayers.GetPageCount())
+        ]
+
+        # ask user to save current settings
+        if self.workspaceFile and self.workspaceChanged:
+            self.Save()
+        elif self.workspaceFile is None and any(tree.GetCount() for tree in maptrees):
+            dlg = wx.MessageDialog(
+                self.lmgr,
+                message=_(
+                    "Current workspace is not empty. "
+                    "Do you want to store current settings "
+                    "to workspace file?"
+                ),
+                caption=_("Create new workspace?"),
+                style=wx.YES_NO | wx.YES_DEFAULT | wx.CANCEL | wx.ICON_QUESTION,
+            )
+            ret = dlg.ShowModal()
+            if ret == wx.ID_YES:
+                self.SaveAs()
+            elif ret == wx.ID_CANCEL:
+                dlg.Destroy()
+                return
+
+            dlg.Destroy()
+
+        # delete all layers in map displays
+        for maptree in maptrees:
+            maptree.DeleteAllLayers()
+
+        # delete all decorations
+        for display in self.lmgr.GetAllMapDisplays():
+            for overlayId in display.decorations.keys():
+                display.RemoveOverlay(overlayId)
+
+        self.workspaceFile = None
+        self.workspaceChanged = False
+        self.lmgr._setTitle()
+
+    def Open(self):
+        """Open file with workspace definition"""
+        dlg = wx.FileDialog(
+            parent=self.lmgr,
+            message=_("Choose workspace file"),
+            defaultDir=os.getcwd(),
+            wildcard=_("GRASS Workspace File (*.gxw)|*.gxw"),
+        )
+
+        filename = ""
+        if dlg.ShowModal() == wx.ID_OK:
+            filename = dlg.GetPath()
+
+        if filename == "":
+            return
+
+        Debug.msg(4, "WorkspaceManager.Open(): filename=%s" % filename)
+
+        # delete current layer tree content
+        self.Close()
+        self.loadingWorkspace = True
+        self.Load(filename)
+        self.loadingWorkspace = False
+        self.lmgr._setTitle()
+
+    def _tryToSwitchMapsetFromWorkspaceFile(self, gxwXml):
+        returncode, errors = RunCommand(
+            "g.mapset",
+            dbase=gxwXml.database,
+            location=gxwXml.location,
+            mapset=gxwXml.mapset,
+            getErrorMsg=True,
+        )
+        if returncode != 0:
+            # TODO: use the function from grass.py
+            reason = _("Most likely the database, location or mapset" " does not exist")
+            details = errors
+            message = _(
+                "Unable to change to location and mapset"
+                " specified in the workspace.\n"
+                "Reason: {reason}\nDetails: {details}\n\n"
+                "Do you want to proceed with opening"
+                " the workspace anyway?"
+            ).format(**locals())
+            dlg = wx.MessageDialog(
+                parent=self.lmgr,
+                message=message,
+                caption=_("Proceed with opening of the workspace?"),
+                style=wx.YES_NO | wx.NO_DEFAULT | wx.ICON_QUESTION,
+            )
+            dlg.CenterOnParent()
+            if dlg.ShowModal() in [wx.ID_NO, wx.ID_CANCEL]:
+                return False
+        else:
+            # TODO: copy from ChangeLocation function
+            GMessage(
+                parent=self.lmgr,
+                message=_(
+                    "Current location is <%(loc)s>.\n" "Current mapset is <%(mapset)s>."
+                )
+                % {"loc": gxwXml.location, "mapset": gxwXml.mapset},
+            )
+        return True
+
+    def Load(self, filename):
+        """Load layer tree definition stored in GRASS Workspace XML file (gxw)
+        .. todo::
+            Validate against DTD
+        :return: True on success
+        :return: False on error
+        """
+        # parse workspace file
+        try:
+            gxwXml = ProcessWorkspaceFile(etree.parse(filename))
+        except Exception as e:
+            GError(
+                parent=self.lmgr,
+                message=_(
+                    "Reading workspace file <%s> failed.\n"
+                    "Invalid file, unable to parse XML document."
+                )
+                % filename,
+            )
+            return False
+
+        if gxwXml.database and gxwXml.location and gxwXml.mapset:
+            if not self._tryToSwitchMapsetFromWorkspaceFile(gxwXml):
+                return False
+
+        # the really busy part starts here (mapset change is fast)
+        busy = wx.BusyInfo(_("Please wait, loading workspace..."), parent=self.lmgr)
+        wx.GetApp().Yield()
+
+        #
+        # load layer manager window properties
+        #
+        if (
+            UserSettings.Get(
+                group="general", key="workspace", subkey=["posManager", "enabled"]
+            )
+            is False
+        ):
+            if gxwXml.layerManager["pos"]:
+                self.lmgr.SetPosition(gxwXml.layerManager["pos"])
+            if gxwXml.layerManager["size"]:
+                self.lmgr.SetSize(gxwXml.layerManager["size"])
+            if gxwXml.layerManager["cwd"]:
+                self.lmgr.cwdPath = gxwXml.layerManager["cwd"]
+                if os.path.isdir(self.lmgr.cwdPath):
+                    os.chdir(self.lmgr.cwdPath)
+
+        #
+        # start map displays first (list of layers can be empty)
+        #
+        displayId = 0
+        mapdisplay = list()
+        for display in gxwXml.displays:
+            mapdisp = self.lmgr.NewDisplay(name=display["name"], show=False)
+            mapdisplay.append(mapdisp)
+            maptree = self.lmgr.notebookLayers.GetPage(displayId).maptree
+
+            # set windows properties
+            mapdisp.SetProperties(
+                render=display["render"],
+                mode=display["mode"],
+                showCompExtent=display["showCompExtent"],
+                alignExtent=display["alignExtent"],
+                constrainRes=display["constrainRes"],
+                projection=display["projection"]["enabled"],
+            )
+
+            if display["projection"]["enabled"]:
+                if display["projection"]["epsg"]:
+                    UserSettings.Set(
+                        group="display",
+                        key="projection",
+                        subkey="epsg",
+                        value=display["projection"]["epsg"],
+                    )
+                    if display["projection"]["proj"]:
+                        UserSettings.Set(
+                            group="display",
+                            key="projection",
+                            subkey="proj4",
+                            value=display["projection"]["proj"],
+                        )
+
+            # set position and size of map display
+            if not UserSettings.Get(
+                group="general", key="workspace", subkey=["posDisplay", "enabled"]
+            ):
+                if display["pos"]:
+                    mapdisp.SetPosition(display["pos"])
+                if display["size"]:
+                    mapdisp.SetSize(display["size"])
+
+            # set extent if defined
+            if display["extent"]:
+                w, s, e, n, b, t = display["extent"]
+                region = maptree.Map.region = maptree.Map.GetRegion(w=w, s=s, e=e, n=n)
+                mapdisp.GetWindow().ResetZoomHistory()
+                mapdisp.GetWindow().ZoomHistory(
+                    region["n"], region["s"], region["e"], region["w"]
+                )
+            if "showStatusbar" in display and not display["showStatusbar"]:
+                mapdisp.statusbarManager.Show(False)
+            if "showToolbars" in display and not display["showToolbars"]:
+                for toolbar in mapdisp.GetToolbarNames():
+                    mapdisp.RemoveToolbar(toolbar)
+
+            displayId += 1
+            mapdisp.Show()  # show mapdisplay
+            # set render property to False to speed up loading layers
+            mapdisp.mapWindowProperties.autoRender = False
+
+        maptree = None
+        selectList = []  # list of selected layers
+        #
+        # load list of map layers
+        #
+        for layer in gxwXml.layers:
+            display = layer["display"]
+            maptree = self.lmgr.notebookLayers.GetPage(display).maptree
+            newItem = maptree.AddLayer(
+                ltype=layer["type"],
+                lname=layer["name"],
+                lchecked=layer["checked"],
+                lopacity=layer["opacity"],
+                lcmd=layer["cmd"],
+                lgroup=layer["group"],
+                lnviz=layer["nviz"],
+                lvdigit=layer["vdigit"],
+                loadWorkspace=True,
+            )
+
+            if "selected" in layer:
+                selectList.append((maptree, newItem, layer["selected"]))
+
+        for maptree, layer, selected in selectList:
+            if selected:
+                if not layer.IsSelected():
+                    maptree.SelectItem(layer, select=True)
+            else:
+                maptree.SelectItem(layer, select=False)
+
+        del busy
+
+        # set render property again when all layers are loaded
+        for i, display in enumerate(gxwXml.displays):
+            mapdisplay[i].mapWindowProperties.autoRender = display["render"]
+
+            for overlay in gxwXml.overlays:
+                # overlay["cmd"][0] name of command e.g. d.barscale, d.legend
+                # overlay["cmd"][1:] parameters and flags
+                if overlay["display"] == i:
+                    if overlay["cmd"][0] == "d.legend.vect":
+                        mapdisplay[i].AddLegendVect(overlay["cmd"])
+                    if overlay["cmd"][0] == "d.legend":
+                        mapdisplay[i].AddLegendRast(overlay["cmd"])
+                    if overlay["cmd"][0] == "d.barscale":
+                        mapdisplay[i].AddBarscale(overlay["cmd"])
+                    if overlay["cmd"][0] == "d.northarrow":
+                        mapdisplay[i].AddArrow(overlay["cmd"])
+                    if overlay["cmd"][0] == "d.text":
+                        mapdisplay[i].AddDtext(overlay["cmd"])
+
+            # avoid double-rendering when loading workspace
+            # mdisp.MapWindow2D.UpdateMap()
+            # nviz
+            if gxwXml.displays[i]["viewMode"] == "3d":
+                mapdisplay[i].AddNviz()
+                self.lmgr.nvizUpdateState(
+                    view=gxwXml.nviz_state["view"],
+                    iview=gxwXml.nviz_state["iview"],
+                    light=gxwXml.nviz_state["light"],
+                )
+                mapdisplay[i].MapWindow3D.constants = gxwXml.nviz_state["constants"]
+                for idx, constant in enumerate(mapdisplay[i].MapWindow3D.constants):
+                    mapdisplay[i].MapWindow3D.AddConstant(constant, i + 1)
+                for page in ("view", "light", "fringe", "constant", "cplane"):
+                    self.lmgr.nvizUpdatePage(page)
+                self.lmgr.nvizUpdateSettings()
+                mapdisplay[i].toolbars["map"].combo.SetSelection(1)
+
+        self.workspaceFile = filename
+        return True
+
+    def SaveAs(self):
+        """Save workspace definition to selected file"""
+        dlg = wx.FileDialog(
+            parent=self.lmgr,
+            message=_("Choose file to save current workspace"),
+            defaultDir=os.getcwd(),
+            wildcard=_("GRASS Workspace File (*.gxw)|*.gxw"),
+            style=wx.FD_SAVE,
+        )
+
+        filename = ""
+        if dlg.ShowModal() == wx.ID_OK:
+            filename = dlg.GetPath()
+
+        if filename == "":
+            return False
+
+        # check for extension
+        if filename[-4:] != ".gxw":
+            filename += ".gxw"
+
+        if os.path.exists(filename):
+            dlg = wx.MessageDialog(
+                self.lmgr,
+                message=_(
+                    "Workspace file <%s> already exists. "
+                    "Do you want to overwrite this file?"
+                )
+                % filename,
+                caption=_("Save workspace"),
+                style=wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION,
+            )
+            if dlg.ShowModal() != wx.ID_YES:
+                dlg.Destroy()
+                return False
+
+        Debug.msg(4, "WorkspaceManager.SaveAs(): filename=%s" % filename)
+
+        self.SaveToFile(filename)
+        self.workspaceFile = filename
+        self.lmgr._setTitle()
+
+    def Save(self):
+        """Save file with workspace definition"""
+        if self.workspaceFile:
+            dlg = wx.MessageDialog(
+                self.lmgr,
+                message=_(
+                    "Workspace file <%s> already exists. "
+                    "Do you want to overwrite this file?"
+                )
+                % self.workspaceFile,
+                caption=_("Save workspace"),
+                style=wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION,
+            )
+            if dlg.ShowModal() == wx.ID_NO:
+                dlg.Destroy()
+            else:
+                Debug.msg(
+                    4, "WorkspaceManager.Save(): filename=%s" % self.workspaceFile
+                )
+                self.SaveToFile(self.workspaceFile)
+                self.lmgr._setTitle()
+                self.workspaceChanged = False
+        else:
+            self.SaveAs()
+
+    def SaveToFile(self, filename):
+        """Save layer tree layout to workspace file
+        :return: True on success, False on error
+        """
+        tmpfile = tempfile.TemporaryFile(mode="w+b")
+        try:
+            WriteWorkspaceFile(lmgr=self.lmgr, file=tmpfile)
+        except Exception as e:
+            GError(
+                parent=self.lmgr,
+                message=_("Writing current settings to workspace file " "failed."),
+            )
+            return False
+
+        try:
+            mfile = open(filename, "wb")
+            tmpfile.seek(0)
+            for line in tmpfile.readlines():
+                mfile.write(line)
+        except IOError:
+            GError(
+                parent=self.lmgr,
+                message=_("Unable to open file <%s> for writing.") % filename,
+            )
+            return False
+
+        mfile.close()
+
+        return True
+
+    def CanClosePage(self, caption):
+        """Ask if page with map display(s) can be closed
+        """
+        # save changes in the workspace
+        maptree = self._giface.GetLayerTree()
+        if self.workspaceChanged and UserSettings.Get(
+                group='manager', key='askOnQuit', subkey='enabled'):
+            if self.workspaceFile:
+                message = _("Do you want to save changes in the workspace?")
+            else:
+                message = _("Do you want to store current settings "
+                            "to workspace file?")
+
+            # ask user to save current settings
+            if maptree.GetCount() > 0:
+                dlg = wx.MessageDialog(self.lmgr,
+                                       message=message,
+                                       caption=caption,
+                                       style=wx.YES_NO | wx.YES_DEFAULT |
+                                       wx.CANCEL | wx.ICON_QUESTION | wx.CENTRE)
+                ret = dlg.ShowModal()
+                dlg.Destroy()
+                if ret == wx.ID_YES:
+                    if not self.workspaceFile:
+                        self.SaveAs()
+                    else:
+                        self.SaveToFile()
+                elif ret == wx.ID_CANCEL:
+                    return False
+        return True
+
+    def Close(self):
+        """Close file with workspace definition
+        If workspace has been modified ask user to save the changes.
+        """
+        Debug.msg(4, "WorkspaceManager.Close(): file=%s" % self.workspaceFile)
+
+        self.lmgr.DisplayCloseAll()
+        self.workspaceFile = None
+        self.workspaceChanged = False
+        self.lmgr._setTitle()
+        self.lmgr.displayIndex = 0
+        self.lmgr.currentPage = None

+ 1 - 1
gui/wxpython/mapdisp/frame.py

@@ -856,7 +856,7 @@ class MapFrame(SingleMapFrame):
             name = self.layerbook.GetPageText(pgnum)
             caption = _("Close Map Display {}").format(name)
             if not askIfSaveWorkspace or \
-               (askIfSaveWorkspace and self._layerManager.CanClosePage(caption)):
+               (askIfSaveWorkspace and self._layerManager.workspace_manager.CanClosePage(caption)):
                 self.CleanUp()
                 if pgnum > -1:
                     self.closingDisplay.emit(page_index=pgnum)

+ 1 - 3
gui/wxpython/vdigit/preferences.py

@@ -979,10 +979,8 @@ class VDigitSettingsDialog(wx.Dialog):
         .. todo::
             Needs refactoring
         """
-        # TODO: it seems that it needs to be replaced by signal
-        # but if it makes sense generally for wxGUI it can be added to giface
         if self.parent.GetLayerManager():
-            self.parent.GetLayerManager().WorkspaceChanged()  # geometry attributes
+            self._giface.workspaceChanged.emit()
         # symbology
         for key, (enabled, color) in six.iteritems(self.symbology):
             if enabled:

+ 0 - 2
gui/wxpython/xml/toolboxes.xml

@@ -227,8 +227,6 @@
       <wxgui-item name="Save"/>
       <wxgui-item name="SaveAs"/>
       <wxgui-item name="Close"/>
-      <separator/>
-      <wxgui-item name="LoadGRCFileTclTkGUI"/>
     </items>
   </toolbox>
   <toolbox name="MapDisplay">

+ 0 - 5
gui/wxpython/xml/wxgui_items.xml

@@ -154,11 +154,6 @@
     <description>Close workspace file</description>
     <wx-id>ID_CLOSE</wx-id>
   </wxgui-item>
-  <wxgui-item name="LoadGRCFileTclTkGUI">
-    <label>Load GRC file (Tcl/Tk GUI)</label>
-    <handler>OnWorkspaceLoadGrcFile</handler>
-    <description>Load map layers from GRC file to layer tree</description>
-  </wxgui-item>
   <wxgui-item name="AddRaster">
     <label>Add raster</label>
     <handler>OnAddRaster</handler>