diff --git a/gui/wxpython/Makefile b/gui/wxpython/Makefile index d580c828da3..a0e63e46273 100644 --- a/gui/wxpython/Makefile +++ b/gui/wxpython/Makefile @@ -9,7 +9,7 @@ include $(MODULE_TOPDIR)/include/Make/Python.make DSTDIR = $(GUIDIR)/wxpython SRCFILES := $(wildcard icons/*.py scripts/*.py xml/*) \ - $(wildcard animation/*.py core/*.py datacatalog/*.py dbmgr/*.py gcp/*.py gmodeler/*.py \ + $(wildcard animation/*.py core/*.py datacatalog/*.py history/*.py dbmgr/*.py gcp/*.py gmodeler/*.py \ gui_core/*.py iclass/*.py lmgr/*.py location_wizard/*.py main_window/*.py mapwin/*.py mapdisp/*.py \ mapswipe/*.py modules/*.py nviz/*.py psmap/*.py rdigit/*.py \ rlisetup/*.py startup/*.py timeline/*.py vdigit/*.py \ @@ -19,7 +19,7 @@ SRCFILES := $(wildcard icons/*.py scripts/*.py xml/*) \ DSTFILES := $(patsubst %,$(DSTDIR)/%,$(SRCFILES)) \ $(patsubst %.py,$(DSTDIR)/%.pyc,$(filter %.py,$(SRCFILES))) -PYDSTDIRS := $(patsubst %,$(DSTDIR)/%,animation core datacatalog dbmgr gcp gmodeler \ +PYDSTDIRS := $(patsubst %,$(DSTDIR)/%,animation core datacatalog history dbmgr gcp gmodeler \ gui_core iclass lmgr location_wizard main_window mapwin mapdisp modules nviz psmap \ mapswipe vdigit wxplot web_services rdigit rlisetup startup \ vnet timeline iscatt tplot photo2image image2target) diff --git a/gui/wxpython/core/gconsole.py b/gui/wxpython/core/gconsole.py index a056368efde..27653d964ae 100644 --- a/gui/wxpython/core/gconsole.py +++ b/gui/wxpython/core/gconsole.py @@ -495,8 +495,8 @@ def RunCmd( Debug.msg(2, "GPrompt:RunCmd(): empty command") return - # update history file - self.UpdateHistoryFile(cmd_save_to_history) + # update history file, command prompt history and history model + self._giface.updateHistory.emit(cmd=cmd_save_to_history) if command[0] in globalvar.grassCmd: # send GRASS command without arguments to GUI command interface @@ -810,31 +810,3 @@ def OnCmdDone(self, event): def OnProcessPendingOutputWindowEvents(self, event): wx.GetApp().ProcessPendingEvents() - - def UpdateHistoryFile(self, command): - """Update history file - - :param command: the command given as a string - """ - env = grass.gisenv() - try: - filePath = os.path.join( - env["GISDBASE"], env["LOCATION_NAME"], env["MAPSET"], ".wxgui_history" - ) - fileHistory = codecs.open(filePath, encoding="utf-8", mode="a") - except IOError as e: - GError( - _("Unable to write file '%(filePath)s'.\n\nDetails: %(error)s") - % {"filePath": filePath, "error": e}, - parent=self._guiparent, - ) - return - - try: - fileHistory.write(command + os.linesep) - finally: - fileHistory.close() - - # update wxGUI prompt - if self._giface: - self._giface.UpdateCmdHistory(command) diff --git a/gui/wxpython/core/giface.py b/gui/wxpython/core/giface.py index 43e55033b54..31d528412d1 100644 --- a/gui/wxpython/core/giface.py +++ b/gui/wxpython/core/giface.py @@ -199,15 +199,6 @@ def GetProgress(self): """ raise NotImplementedError() - def UpdateCmdHistory(self, cmd): - """Add the command to the current history list shown to the user - - .. note:: - - Some implementations may not implement this method or do nothing. - """ - raise NotImplementedError() - class StandaloneGrassInterface(GrassInterface): """@implements GrassInterface""" @@ -241,6 +232,9 @@ def __init__(self): # Signal emitted when workspace is changed self.workspaceChanged = Signal("StandaloneGrassInterface.workspaceChanged") + # Signal emitted when history should be updated + self.updateHistory = Signal("StandaloneGrassInterface.updateHistory") + # workaround, standalone grass interface should be moved to sep. file from core.gconsole import GConsole, EVT_CMD_OUTPUT, EVT_CMD_PROGRESS @@ -343,7 +337,3 @@ def GetProgress(self): # TODO: implement some progress with same inface as gui one # (probably using g.message or similarly to Write... functions) raise NotImplementedError() - - def UpdateCmdHistory(self, cmd): - """There is no history displayed to the user, doing nothing""" - pass diff --git a/gui/wxpython/core/settings.py b/gui/wxpython/core/settings.py index 6117a6f7352..472069d0709 100644 --- a/gui/wxpython/core/settings.py +++ b/gui/wxpython/core/settings.py @@ -169,10 +169,7 @@ def _defaultSettings(self): # ask when quitting wxGUI or closing display "askOnQuit": {"enabled": True}, # hide tabs - "hideTabs": { - "search": False, - "pyshell": False, - }, + "hideTabs": {"search": False, "pyshell": False, "history": False}, "copySelectedTextToClipboard": {"enabled": False}, }, # diff --git a/gui/wxpython/gui_core/goutput.py b/gui/wxpython/gui_core/goutput.py index 0dfa2f5da78..5266986c811 100644 --- a/gui/wxpython/gui_core/goutput.py +++ b/gui/wxpython/gui_core/goutput.py @@ -21,10 +21,18 @@ import textwrap +import os import wx from wx import stc from grass.pydispatch.signal import Signal +from grass.grassdb.history import ( + read_history, + create_history_file, + update_history, + copy_history, + get_current_mapset_gui_history_path, +) # needed just for testing if __name__ == "__main__": @@ -84,6 +92,7 @@ def __init__( self.panelPrompt = wx.Panel(parent=self, id=wx.ID_ANY) # initialize variables self.parent = parent # GMFrame | CmdPanel | ? + self.giface = giface self._gconsole = gconsole self._menuModel = menuModel @@ -135,6 +144,18 @@ def __init__( if not self._gcstyle & GC_PROMPT: self.cmdPrompt.Hide() + # read history file + self._loadHistory() + if self.giface: + self.giface.currentMapsetChanged.connect(self._loadHistory) + + if self._gcstyle == GC_PROMPT: + # connect update history signal only for main Console Window + self.giface.updateHistory.connect( + lambda cmd: self.cmdPrompt.UpdateCmdHistory(cmd) + ) + self.giface.updateHistory.connect(lambda cmd: self.UpdateHistory(cmd)) + # buttons self.btnClear = ClearButton(parent=self.panelPrompt) self.btnClear.SetToolTip(_("Clear prompt and output window")) @@ -238,6 +259,17 @@ def _layout(self): self.SetAutoLayout(True) self.Layout() + def _loadHistory(self): + """Load history from a history file to data structures""" + history_path = get_current_mapset_gui_history_path() + try: + if not os.path.exists(history_path): + create_history_file(history_path) + self.cmdPrompt.cmdbuffer = read_history(history_path) + self.cmdPrompt.cmdindex = len(self.cmdPrompt.cmdbuffer) + except OSError as e: + GError(str(e)) + def GetPanel(self, prompt=True): """Get panel @@ -416,6 +448,14 @@ def OnCmdProgress(self, event): self.progressbar.SetValue(event.value) event.Skip() + def UpdateHistory(self, cmd): + """Update command history""" + history_path = get_current_mapset_gui_history_path() + try: + update_history(cmd, history_path) + except OSError as e: + GError(str(e)) + def OnCmdExportHistory(self, event): """Export the history of executed commands stored in a .wxgui_history file to a selected file.""" @@ -431,10 +471,14 @@ def OnCmdExportHistory(self, event): if dlg.ShowModal() == wx.ID_OK: path = dlg.GetPath() - if self.cmdPrompt.CopyHistory(path): + history_path = get_current_mapset_gui_history_path() + try: + copy_history(path, history_path) self.showNotification.emit( message=_("Command history saved to '{}'".format(path)) ) + except OSError as e: + GError(str(e)) dlg.Destroy() event.Skip() diff --git a/gui/wxpython/gui_core/preferences.py b/gui/wxpython/gui_core/preferences.py index 9ce2ca4b954..7415ec87398 100644 --- a/gui/wxpython/gui_core/preferences.py +++ b/gui/wxpython/gui_core/preferences.py @@ -334,6 +334,20 @@ def _createGeneralPage(self, notebook): gridSizer.Add(hideSearch, pos=(row, 0), span=(1, 2)) + row += 1 + hideHistory = wx.CheckBox( + parent=panel, + id=wx.ID_ANY, + label=_("Hide '%s' tab (requires GUI restart)") % _("History"), + name="IsChecked", + ) + hideHistory.SetValue( + self.settings.Get(group="manager", key="hideTabs", subkey="history") + ) + self.winId["manager:hideTabs:history"] = hideHistory.GetId() + + gridSizer.Add(hideHistory, pos=(row, 0), span=(1, 2)) + row += 1 hidePyShell = wx.CheckBox( parent=panel, diff --git a/gui/wxpython/gui_core/prompt.py b/gui/wxpython/gui_core/prompt.py index ef868c3d3d7..d2cc6efb36b 100644 --- a/gui/wxpython/gui_core/prompt.py +++ b/gui/wxpython/gui_core/prompt.py @@ -18,11 +18,8 @@ @author Wolf Bergenheim (#962) """ -import os import difflib -import codecs import sys -import shutil import wx import wx.stc @@ -34,7 +31,7 @@ from core import globalvar from core import utils -from core.gcmd import EncodeString, DecodeString, GError +from core.gcmd import EncodeString, DecodeString class GPrompt(object): @@ -65,10 +62,6 @@ def __init__(self, parent, giface, menuModel): # command description (gtask.grassTask) self.cmdDesc = None - self._loadHistory() - if giface: - giface.currentMapsetChanged.connect(self._loadHistory) - # list of traced commands self.commands = list() @@ -77,38 +70,6 @@ def __init__(self, parent, giface, menuModel): giface.currentMapsetChanged.connect(self._reloadListOfMaps) giface.grassdbChanged.connect(self._reloadListOfMaps) - def _readHistory(self): - """Get list of commands from history file""" - hist = list() - env = grass.gisenv() - try: - fileHistory = codecs.open( - os.path.join( - env["GISDBASE"], - env["LOCATION_NAME"], - env["MAPSET"], - ".wxgui_history", - ), - encoding="utf-8", - mode="r", - errors="replace", - ) - except IOError: - return hist - - try: - for line in fileHistory.readlines(): - hist.append(line.replace("\n", "")) - finally: - fileHistory.close() - - return hist - - def _loadHistory(self): - """Load history from a history file to data structures""" - self.cmdbuffer = self._readHistory() - self.cmdindex = len(self.cmdbuffer) - def _getListOfMaps(self): """Get list of maps""" result = dict() @@ -140,27 +101,6 @@ def _runCmd(self, cmdString): self.CmdErase() self.ShowStatusText("") - def CopyHistory(self, targetFile): - """Copy history file to the target location. - Returns True if file is successfully copied.""" - env = grass.gisenv() - historyFile = os.path.join( - env["GISDBASE"], - env["LOCATION_NAME"], - env["MAPSET"], - ".wxgui_history", - ) - try: - shutil.copyfile(historyFile, targetFile) - except (IOError, OSError) as e: - GError( - _("Unable to copy file {} to {}'.\n\nDetails: {}").format( - historyFile, targetFile, e - ) - ) - return False - return True - def GetCommands(self): """Get list of launched commands""" return self.commands diff --git a/gui/wxpython/history/browser.py b/gui/wxpython/history/browser.py new file mode 100644 index 00000000000..c586b2cb777 --- /dev/null +++ b/gui/wxpython/history/browser.py @@ -0,0 +1,120 @@ +""" +@package history.browser + +@brief History browser + +Classes: + - browser::HistoryBrowser + +(C) 2023 by Linda Karlovska, and 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. + +@author Linda Karlovska (Kladivova) linda.karlovska@seznam.cz +""" + +import wx +import re + +from core.gcmd import GError, GException +from gui_core.forms import GUI +from gui_core.treeview import CTreeView +from history.tree import HistoryBrowserTree + +from grass.pydispatch.signal import Signal + + +class HistoryBrowser(wx.Panel): + """History browser for executing the commands from history log. + + Signal: + showNotification - attribute 'message' + """ + + def __init__( + self, + parent, + giface, + id=wx.ID_ANY, + title=_("History browser"), + name="history", + **kwargs, + ): + self.parent = parent + self._giface = giface + + self.showNotification = Signal("HistoryBrowser.showNotification") + wx.Panel.__init__(self, parent=parent, id=id, **kwargs) + + self._createTree() + + self._giface.currentMapsetChanged.connect(self.UpdateHistoryModelFromScratch) + self._giface.updateHistory.connect( + lambda cmd: self.UpdateHistoryModelByCommand(cmd) + ) + + self._layout() + + def _layout(self): + """Dialog layout""" + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add( + self._tree, proportion=1, flag=wx.EXPAND | wx.LEFT | wx.RIGHT, border=5 + ) + + self.SetSizerAndFit(sizer) + self.SetAutoLayout(True) + self.Layout() + + def _createTree(self): + """Create tree based on the model""" + self._model = HistoryBrowserTree() + self._tree = self._getTreeInstance() + self._tree.SetToolTip(_("Double-click to open the tool")) + self._tree.selectionChanged.connect(self.OnItemSelected) + self._tree.itemActivated.connect(lambda node: self.Run(node)) + + def _getTreeInstance(self): + return CTreeView(model=self._model.GetModel(), parent=self) + + def _getSelectedNode(self): + selection = self._tree.GetSelected() + if not selection: + return None + return selection[0] + + def _refreshTree(self): + self._tree.SetModel(self._model.GetModel()) + + def UpdateHistoryModelFromScratch(self): + """Update the model from scratch and refresh the tree""" + self._model.CreateModel() + self._refreshTree() + + def UpdateHistoryModelByCommand(self, cmd): + """Update the model by the command and refresh the tree""" + self._model.UpdateModel(cmd) + self._refreshTree() + + def OnItemSelected(self, node): + """Item selected""" + command = node.data["command"] + self.showNotification.emit(message=command) + + def Run(self, node=None): + """Parse selected history command into list and launch module dialog.""" + node = node or self._getSelectedNode() + if node: + command = node.data["command"] + lst = re.split(r"\s+", command) + try: + GUI(parent=self, giface=self._giface).ParseCommand(lst) + except GException as e: + GError( + parent=self, + message=str(e), + caption=_("Cannot be parsed into command"), + showTraceback=False, + ) diff --git a/gui/wxpython/history/tree.py b/gui/wxpython/history/tree.py new file mode 100644 index 00000000000..48f378bcd47 --- /dev/null +++ b/gui/wxpython/history/tree.py @@ -0,0 +1,48 @@ +""" +@package history.tree + +@brief History browser tree + +Classes: + + - browser::HistoryBrowserTree + +(C) 2023 by Linda Karlovska, and 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. + +@author Linda Karlovska (Kladivova) linda.karlovska@seznam.cz +""" + +import copy + +from core.treemodel import TreeModel, ModuleNode + +from grass.grassdb.history import read_history, get_current_mapset_gui_history_path + + +class HistoryBrowserTree: + """Data class for the history browser tree of executed commands.""" + + def __init__(self, max_length=50): + self.model = TreeModel(ModuleNode) + self.max_length = max_length + self.CreateModel() + + def CreateModel(self): + self.model.RemoveNode(self.model.root) + history_path = get_current_mapset_gui_history_path() + if history_path: + cmd_list = read_history(history_path) + for label in cmd_list: + self.UpdateModel(label.strip()) + + def UpdateModel(self, label): + data = {"command": label} + self.model.AppendNode(parent=self.model.root, label=data["command"], data=data) + + def GetModel(self): + """Returns a deep copy of the model.""" + return copy.deepcopy(self.model) diff --git a/gui/wxpython/lmgr/frame.py b/gui/wxpython/lmgr/frame.py index 5e940c67dc3..9156039dda1 100644 --- a/gui/wxpython/lmgr/frame.py +++ b/gui/wxpython/lmgr/frame.py @@ -73,6 +73,7 @@ from lmgr.giface import LayerManagerGrassInterface from mapdisp.frame import MapDisplay from datacatalog.catalog import DataCatalog +from history.browser import HistoryBrowser from gui_core.forms import GUI from gui_core.wrap import Menu, TextEntryDialog from startup.guiutils import ( @@ -164,6 +165,7 @@ def show_menu_errors(messages): self._createDisplay(self.notebook) self._createSearchModule(self.notebook) self._createConsole(self.notebook) + self._createHistoryBrowser(self.notebook) self._createPythonShell(self.notebook) self._addPagesToNotebook() self.toolbars = { @@ -445,6 +447,16 @@ def _createConsole(self, parent): self._setCopyingOfSelectedText() + def _createHistoryBrowser(self, parent): + """Initialize history browser widget""" + if not UserSettings.Get(group="manager", key="hideTabs", subkey="history"): + self.history = HistoryBrowser(parent=parent, giface=self._giface) + self.history.showNotification.connect( + lambda message: self.SetStatusText(message) + ) + else: + self.history = None + def _createPythonShell(self, parent): """Initialize Python shell widget""" if not UserSettings.Get(group="manager", key="hideTabs", subkey="pyshell"): @@ -628,6 +640,10 @@ def _addPagesToNotebook(self): lambda notification: self._switchPage(notification) ) + # add 'history module' widget to main notebook page + if self.history: + self.notebook.AddPage(page=self.history, text=_("History"), name="history") + # add 'python shell' widget to main notebook page if self.pyshell: self.notebook.AddPage(page=self.pyshell, text=_("Python"), name="pyshell") diff --git a/gui/wxpython/lmgr/giface.py b/gui/wxpython/lmgr/giface.py index a9a5b8e53cb..d4b0dd3a310 100644 --- a/gui/wxpython/lmgr/giface.py +++ b/gui/wxpython/lmgr/giface.py @@ -212,6 +212,9 @@ def __init__(self, lmgr): # Signal emitted when workspace is changed self.workspaceChanged = Signal("LayerManagerGrassInterface.workspaceChanged") + # Signal emitted when history should be updated + self.updateHistory = Signal("LayerManagerGrassInterface.updateHistory") + def RunCmd(self, *args, **kwargs): self.lmgr._gconsole.RunCmd(*args, **kwargs) @@ -257,9 +260,6 @@ def GetMapWindow(self): def GetProgress(self): return self.lmgr.goutput.GetProgressBar() - def UpdateCmdHistory(self, cmd): - self.lmgr.goutput.GetPrompt().UpdateCmdHistory(cmd) - class LayerManagerGrassInterfaceForMapDisplay(object): """Provides reference only to the given layer list (according to tree), diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index 24e5d8240b5..e42f4db901a 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -80,6 +80,7 @@ from lmgr.giface import LayerManagerGrassInterface from mapdisp.frame import MapPanel from datacatalog.catalog import DataCatalog +from history.browser import HistoryBrowser from gui_core.forms import GUI from gui_core.wrap import Menu, TextEntryDialog, SimpleTabArt from startup.guiutils import ( @@ -393,6 +394,16 @@ def _createConsole(self, parent): self._setCopyingOfSelectedText() + def _createHistoryBrowser(self, parent): + """Initialize history browser widget""" + if not UserSettings.Get(group="manager", key="hideTabs", subkey="history"): + self.history = HistoryBrowser(parent=parent, giface=self._giface) + self.history.showNotification.connect( + lambda message: self.SetStatusText(message) + ) + else: + self.history = None + def _createPythonShell(self, parent): """Initialize Python shell widget""" if not UserSettings.Get(group="manager", key="hideTabs", subkey="pyshell"): @@ -566,6 +577,7 @@ def BuildPanes(self): self._createDisplay(parent=self) self._createSearchModule(parent=self) self._createConsole(parent=self) + self._createHistoryBrowser(parent=self) self._createPythonShell(parent=self) self.toolbars = { "workspace": LMWorkspaceToolbar(parent=self), @@ -686,6 +698,20 @@ def BuildPanes(self): target=self._auimgr.GetPane("tools"), ) + self._auimgr.AddPane( + self.history, + aui.AuiPaneInfo() + .Name("history") + .Caption(_("History")) + .Right() + .BestSize(self.PANE_BEST_SIZE) + .MinSize(self.PANE_MIN_SIZE) + .CloseButton(False) + .MinimizeButton(True) + .MaximizeButton(True), + target=self._auimgr.GetPane("tools"), + ) + self._auimgr.AddPane( self.pyshell, aui.AuiPaneInfo() diff --git a/python/grass/grassdb/Makefile b/python/grass/grassdb/Makefile index ce3d6fb3d01..d1e7a970c83 100644 --- a/python/grass/grassdb/Makefile +++ b/python/grass/grassdb/Makefile @@ -5,7 +5,7 @@ include $(MODULE_TOPDIR)/include/Make/Python.make DSTDIR = $(ETC)/python/grass/grassdb -MODULES = checks create data manage config +MODULES = checks create data manage config history PYFILES := $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__) PYCFILES := $(patsubst %,$(DSTDIR)/%.pyc,$(MODULES) __init__) diff --git a/python/grass/grassdb/history.py b/python/grass/grassdb/history.py new file mode 100644 index 00000000000..8ff068313d2 --- /dev/null +++ b/python/grass/grassdb/history.py @@ -0,0 +1,90 @@ +""" +Managing existing history files included within mapset + +(C) 2023 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. + +.. sectionauthor:: Linda Karlovska (Kladivova) linda.karlovska@seznam.cz +""" + +import os +import shutil + +from grass.script import gisenv + + +def get_current_mapset_gui_history_path(): + """Return path to the current mapset history file.""" + env = gisenv() + return os.path.join( + env["GISDBASE"], env["LOCATION_NAME"], env["MAPSET"], ".wxgui_history" + ) + + +def create_history_file(history_path): + """Set up a new GUI history file.""" + try: + fileHistory = open( + history_path, + encoding="utf-8", + mode="w", + ) + except OSError as e: + raise OSError(_("Unable to create history file {}").format(history_path)) from e + finally: + fileHistory.close() + + +def read_history(history_path): + """Get list of commands from history file.""" + hist = list() + try: + fileHistory = open( + history_path, + encoding="utf-8", + mode="r", + errors="replace", + ) + except OSError as e: + raise OSError( + _("Unable to read commands from history file {}").format(history_path) + ) from e + try: + for line in fileHistory.readlines(): + hist.append(line.replace("\n", "")) + finally: + fileHistory.close() + return hist + + +def update_history(command, history_path=None): + """Update history file. + + :param command: the command given as a string + """ + if not history_path: + history_path = get_current_mapset_gui_history_path() + try: + if os.path.exists(history_path): + fileHistory = open(history_path, encoding="utf-8", mode="a") + else: + fileHistory = open(history_path, encoding="utf-8", mode="w") + fileHistory.write(command + "\n") + except OSError as e: + raise OSError(_("Unable to update history file {}").format(history_path)) from e + finally: + fileHistory.close() + + +def copy_history(target_path, history_path): + """Copy history file to the target location. + Returns True if file is successfully copied.""" + try: + shutil.copyfile(history_path, target_path) + except OSError as e: + raise OSError( + _("Unable to copy file {} to {}").format(history_path, target_path) + ) from e + return True