From b0deb489e14f57aa41dad96c4d77f108c0cfe701 Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakladivova@users.noreply.github.com> Date: Thu, 18 Jan 2024 15:36:05 +0100 Subject: [PATCH] wxGUI/history: add pop-up command menu with an item for delete command from history + history tree refactoring (#3342) --- gui/wxpython/core/gconsole.py | 17 ++- gui/wxpython/core/giface.py | 11 +- gui/wxpython/gui_core/goutput.py | 23 ++-- gui/wxpython/gui_core/prompt.py | 15 ++- gui/wxpython/history/browser.py | 114 +++-------------- gui/wxpython/history/tree.py | 206 +++++++++++++++++++++++++++---- gui/wxpython/lmgr/giface.py | 11 +- python/grass/grassdb/history.py | 57 +++++++-- 8 files changed, 300 insertions(+), 154 deletions(-) diff --git a/gui/wxpython/core/gconsole.py b/gui/wxpython/core/gconsole.py index a1753bb50a3..31598712bb1 100644 --- a/gui/wxpython/core/gconsole.py +++ b/gui/wxpython/core/gconsole.py @@ -40,6 +40,11 @@ from grass.pydispatch.signal import Signal +from grass.grassdb.history import ( + get_current_mapset_gui_history_path, + add_entry_to_history, +) + from core import globalvar from core.gcmd import CommandThread, GError, GException from gui_core.forms import GUI @@ -495,8 +500,16 @@ def RunCmd( Debug.msg(2, "GPrompt:RunCmd(): empty command") return - # update history file, command prompt history and history model - self._giface.updateHistory.emit(cmd=cmd_save_to_history) + # add entry to command history log + history_path = get_current_mapset_gui_history_path() + try: + add_entry_to_history(cmd_save_to_history, history_path) + except OSError as e: + GError(str(e)) + + # update command prompt and history model + if self._giface: + self._giface.entryToHistoryAdded.emit(cmd=cmd_save_to_history) if command[0] in globalvar.grassCmd: # send GRASS command without arguments to GUI command interface diff --git a/gui/wxpython/core/giface.py b/gui/wxpython/core/giface.py index 0c17ca21788..39ed2e877c7 100644 --- a/gui/wxpython/core/giface.py +++ b/gui/wxpython/core/giface.py @@ -232,8 +232,15 @@ 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") + # Signal emitted when entry to history is added + self.entryToHistoryAdded = Signal( + "StandaloneGrassInterface.entryToHistoryAdded" + ) + + # Signal emitted when entry from history is removed + self.entryFromHistoryRemoved = Signal( + "StandaloneGrassInterface.entryFromHistoryRemoved" + ) # workaround, standalone grass interface should be moved to sep. file from core.gconsole import GConsole, EVT_CMD_OUTPUT, EVT_CMD_PROGRESS diff --git a/gui/wxpython/gui_core/goutput.py b/gui/wxpython/gui_core/goutput.py index dcd22f83d05..65a8cfd98ad 100644 --- a/gui/wxpython/gui_core/goutput.py +++ b/gui/wxpython/gui_core/goutput.py @@ -29,7 +29,6 @@ from grass.grassdb.history import ( read_history, create_history_file, - update_history, copy_history, get_current_mapset_gui_history_path, ) @@ -149,12 +148,14 @@ def __init__( 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)) + if self._gcstyle == GC_PROMPT: + # connect update history signals only for main Console Window + self.giface.entryToHistoryAdded.connect( + lambda cmd: self.cmdPrompt.AddEntryToCmdHistoryBuffer(cmd) + ) + self.giface.entryFromHistoryRemoved.connect( + lambda index: self.cmdPrompt.RemoveEntryFromCmdHistoryBuffer(index) + ) # buttons self.btnClear = ClearButton(parent=self.panelPrompt) @@ -448,14 +449,6 @@ 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.""" diff --git a/gui/wxpython/gui_core/prompt.py b/gui/wxpython/gui_core/prompt.py index 92b478f842f..81e09b094af 100644 --- a/gui/wxpython/gui_core/prompt.py +++ b/gui/wxpython/gui_core/prompt.py @@ -304,8 +304,8 @@ def SetTextAndFocus(self, text): self.SetCurrentPos(pos) self.SetFocus() - def UpdateCmdHistory(self, cmd): - """Update command history + def AddEntryToCmdHistoryBuffer(self, cmd): + """Add entry to command history buffer :param cmd: command given as a string """ @@ -319,6 +319,17 @@ def UpdateCmdHistory(self, cmd): del self.cmdbuffer[0] self.cmdindex = len(self.cmdbuffer) + def RemoveEntryFromCmdHistoryBuffer(self, index): + """Remove entry from command history buffer + :param index: index of deleted command + """ + # remove command at the given index from history buffer + if index < len(self.cmdbuffer): + self.cmdbuffer.pop(index) + + # update cmd index size + self.cmdindex = len(self.cmdbuffer) + def EntityToComplete(self): """Determines which part of command (flags, parameters) should be completed at current cursor position""" diff --git a/gui/wxpython/history/browser.py b/gui/wxpython/history/browser.py index dd2f12b74a7..4a94aa9b1c5 100644 --- a/gui/wxpython/history/browser.py +++ b/gui/wxpython/history/browser.py @@ -13,20 +13,12 @@ for details. @author Linda Karlovska (Kladivova) linda.karlovska@seznam.cz +@author Anna Petrasova (kratochanna gmail com) +@author Tomas Zigo """ import wx -import re - -from core import globalvar -from core.gcmd import GError, GException -from core.utils import ( - parse_mapcalc_cmd, - replace_module_cmd_special_flags, - split, -) -from gui_core.forms import GUI -from gui_core.treeview import CTreeView + from gui_core.wrap import SearchCtrl from history.tree import HistoryBrowserTree @@ -34,7 +26,7 @@ class HistoryBrowser(wx.Panel): - """History browser for executing the commands from history log. + """History browser panel for executing the commands from history log. Signal: showNotification - attribute 'message' @@ -52,22 +44,24 @@ def __init__( self.parent = parent self._giface = giface - self.showNotification = Signal("HistoryBrowser.showNotification") - self.runIgnoredCmdPattern = Signal("HistoryBrowser.runIgnoredCmdPattern") wx.Panel.__init__(self, parent=parent, id=id, **kwargs) + self.SetName("HistoryBrowser") - self._createTree() - - self._giface.currentMapsetChanged.connect(self.UpdateHistoryModelFromScratch) - self._giface.updateHistory.connect( - lambda cmd: self.UpdateHistoryModelByCommand(cmd) - ) + self.showNotification = Signal("HistoryBrowser.showNotification") + self.runIgnoredCmdPattern = Signal("HistoryBrowser.runIgnoredCmdPattern") + # search box self.search = SearchCtrl(self) self.search.SetDescriptiveText(_("Search")) self.search.ShowCancelButton(True) - self.search.Bind(wx.EVT_TEXT, lambda evt: self.Filter(evt.GetString())) - self.search.Bind(wx.EVT_SEARCHCTRL_CANCEL_BTN, lambda evt: self.Filter("")) + self.search.Bind(wx.EVT_TEXT, lambda evt: self.tree.Filter(evt.GetString())) + self.search.Bind(wx.EVT_SEARCHCTRL_CANCEL_BTN, lambda evt: self.tree.Filter("")) + + # tree with layers + self.tree = HistoryBrowserTree(self, giface=giface) + self.tree.SetToolTip(_("Double-click to run selected tool")) + self.tree.showNotification.connect(self.showNotification) + self.tree.runIgnoredCmdPattern.connect(self.runIgnoredCmdPattern) self._layout() @@ -81,83 +75,9 @@ def _layout(self): border=5, ) sizer.Add( - self._tree, proportion=1, flag=wx.EXPAND | wx.LEFT | wx.RIGHT, border=5 + 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 Filter(self, text): - """Filter history - - :param str text: text string - """ - model = self._model.GetModel() - if text: - model = self._model.model.Filtered(key=["command"], value=text) - self._tree.SetModel(model) - else: - self._tree.SetModel(model) - - 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"] - if ( - globalvar.ignoredCmdPattern - and re.compile(globalvar.ignoredCmdPattern).search(command) - and "--help" not in command - and "--ui" not in command - ): - self.runIgnoredCmdPattern.emit(cmd=split(command)) - return - if re.compile(r"^r[3]?\.mapcalc").search(command): - command = parse_mapcalc_cmd(command) - command = replace_module_cmd_special_flags(command) - lst = split(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 index 48f378bcd47..2ecfff9c7b9 100644 --- a/gui/wxpython/history/tree.py +++ b/gui/wxpython/history/tree.py @@ -1,11 +1,10 @@ """ @package history.tree -@brief History browser tree +@brief History browser tree classes Classes: - - - browser::HistoryBrowserTree + - history::HistoryBrowserTree (C) 2023 by Linda Karlovska, and the GRASS Development Team @@ -14,35 +13,200 @@ for details. @author Linda Karlovska (Kladivova) linda.karlovska@seznam.cz +@author Anna Petrasova (kratochanna gmail com) +@author Tomas Zigo """ +import wx +import re import copy +from core import globalvar + +from core.gcmd import GError, GException +from core.utils import ( + parse_mapcalc_cmd, + replace_module_cmd_special_flags, + split, +) +from gui_core.forms import GUI from core.treemodel import TreeModel, ModuleNode +from gui_core.treeview import CTreeView +from gui_core.wrap import Menu -from grass.grassdb.history import read_history, get_current_mapset_gui_history_path +from grass.pydispatch.signal import Signal +from grass.grassdb.history import ( + get_current_mapset_gui_history_path, + read_history, + remove_entry_from_history, +) -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() +class HistoryBrowserTree(CTreeView): + """Tree structure visualizing and managing history of executed commands. + Uses virtual tree and model defined in core/treemodel.py. + """ - 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) + def __init__( + self, + parent, + model=None, + giface=None, + style=wx.TR_HIDE_ROOT + | wx.TR_LINES_AT_ROOT + | wx.TR_HAS_BUTTONS + | wx.TR_FULL_ROW_HIGHLIGHT, + ): + """History Browser Tree constructor.""" + self._model = TreeModel(ModuleNode) + self._orig_model = self._model + super().__init__(parent=parent, model=self._model, id=wx.ID_ANY, style=style) + + self._giface = giface + self.parent = parent + + self._initHistoryModel() + + self.showNotification = Signal("HistoryBrowserTree.showNotification") + self.runIgnoredCmdPattern = Signal("HistoryBrowserTree.runIgnoredCmdPattern") + + self._giface.currentMapsetChanged.connect(self.UpdateHistoryModelFromScratch) + self._giface.entryToHistoryAdded.connect( + lambda cmd: self.UpdateHistoryModelByCommand(cmd) + ) + + self.SetToolTip(_("Double-click to open the tool")) + self.selectionChanged.connect(self.OnItemSelected) + self.itemActivated.connect(lambda node: self.Run(node)) + self.contextMenu.connect(self.OnRightClick) + + def _initHistoryModel(self): + """Fill tree history model based on the current history log.""" + self._history_path = get_current_mapset_gui_history_path() + if self._history_path: + cmd_list = read_history(self._history_path) for label in cmd_list: - self.UpdateModel(label.strip()) + data = {"command": label.strip()} + self._model.AppendNode( + parent=self._model.root, + label=data["command"], + data=data, + ) + self._refreshTree() + + def _refreshTree(self): + """Refresh tree models""" + self.SetModel(copy.deepcopy(self._model)) + self._orig_model = self._model + + def _getSelectedNode(self): + selection = self.GetSelected() + if not selection: + return None + return selection[0] + + def _confirmDialog(self, question, title): + """Confirm dialog""" + dlg = wx.MessageDialog(self, question, title, wx.YES_NO) + res = dlg.ShowModal() + dlg.Destroy() + return res - def UpdateModel(self, label): + def _popupMenuLayer(self): + """Create popup menu for commands""" + menu = Menu() + + item = wx.MenuItem(menu, wx.ID_ANY, _("&Remove")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnRemoveCmd, item) + + self.PopupMenu(menu) + menu.Destroy() + + def Filter(self, text): + """Filter history + :param str text: text string + """ + if text: + self._model = self._orig_model.Filtered(key=["command"], value=text) + else: + self._model = self._orig_model + self.RefreshItems() + + def UpdateHistoryModelFromScratch(self): + """Reload tree history model based on the current history log from scratch.""" + self._model.RemoveNode(self._model.root) + self._initHistoryModel() + + def UpdateHistoryModelByCommand(self, label): + """Update the model by the command and refresh the tree. + + :param label: model node label""" data = {"command": label} - self.model.AppendNode(parent=self.model.root, label=data["command"], data=data) + self._model.AppendNode( + parent=self._model.root, + label=data["command"], + data=data, + ) + self._refreshTree() + + 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) + if ( + globalvar.ignoredCmdPattern + and re.compile(globalvar.ignoredCmdPattern).search(command) + and "--help" not in command + and "--ui" not in command + ): + self.runIgnoredCmdPattern.emit(cmd=lst) + self.runIgnoredCmdPattern.emit(cmd=split(command)) + return + if re.compile(r"^r[3]?\.mapcalc").search(command): + command = parse_mapcalc_cmd(command) + command = replace_module_cmd_special_flags(command) + lst = split(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, + ) + + def RemoveEntryFromHistory(self, del_line_number): + """Remove entry from command history log""" + history_path = get_current_mapset_gui_history_path() + try: + remove_entry_from_history(del_line_number, history_path) + except OSError as e: + GError(str(e)) + + def OnRemoveCmd(self, event): + """Remove cmd from the history file""" + tree_node = self._getSelectedNode() + cmd = tree_node.data["command"] + question = _("Do you really want to remove <{}> command?").format(cmd) + if self._confirmDialog(question, title=_("Remove command")) == wx.ID_YES: + self.showNotification.emit(message=_("Removing <{}>").format(cmd)) + tree_index = self._model.GetIndexOfNode(tree_node)[0] + self.RemoveEntryFromHistory(tree_index) + self._giface.entryFromHistoryRemoved.emit(index=tree_index) + self._model.RemoveNode(tree_node) + self._refreshTree() + self.showNotification.emit(message=_("<{}> removed").format(cmd)) + + def OnItemSelected(self, node): + """Item selected""" + command = node.data["command"] + self.showNotification.emit(message=command) - def GetModel(self): - """Returns a deep copy of the model.""" - return copy.deepcopy(self.model) + def OnRightClick(self, node): + """Display popup menu""" + self._popupMenuLayer() diff --git a/gui/wxpython/lmgr/giface.py b/gui/wxpython/lmgr/giface.py index b2dea80d1e8..8c5e065aa08 100644 --- a/gui/wxpython/lmgr/giface.py +++ b/gui/wxpython/lmgr/giface.py @@ -212,8 +212,15 @@ 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") + # Signal emitted when entry to history is added + self.entryToHistoryAdded = Signal( + "LayerManagerGrassInterface.entryToHistoryAdded" + ) + + # Signal emitted when entry from history is removed + self.entryFromHistoryRemoved = Signal( + "LayerManagerGrassInterface.entryFromHistoryRemoved" + ) def RunCmd(self, *args, **kwargs): self.lmgr._gconsole.RunCmd(*args, **kwargs) diff --git a/python/grass/grassdb/history.py b/python/grass/grassdb/history.py index 8ff068313d2..a85f4fd6c7e 100644 --- a/python/grass/grassdb/history.py +++ b/python/grass/grassdb/history.py @@ -26,7 +26,7 @@ def get_current_mapset_gui_history_path(): def create_history_file(history_path): """Set up a new GUI history file.""" try: - fileHistory = open( + file_history = open( history_path, encoding="utf-8", mode="w", @@ -34,14 +34,14 @@ def create_history_file(history_path): except OSError as e: raise OSError(_("Unable to create history file {}").format(history_path)) from e finally: - fileHistory.close() + file_history.close() def read_history(history_path): """Get list of commands from history file.""" hist = list() try: - fileHistory = open( + file_history = open( history_path, encoding="utf-8", mode="r", @@ -52,30 +52,61 @@ def read_history(history_path): _("Unable to read commands from history file {}").format(history_path) ) from e try: - for line in fileHistory.readlines(): + for line in file_history.readlines(): hist.append(line.replace("\n", "")) finally: - fileHistory.close() + file_history.close() return hist -def update_history(command, history_path=None): - """Update history file. +def add_entry_to_history(command, history_path=None): + """Add entry to history file. - :param command: the command given as a string + :param str command: the command given as a string + :param str|None history_path: history file path string """ + file_history = None 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") + file_history = open(history_path, encoding="utf-8", mode="a") else: - fileHistory = open(history_path, encoding="utf-8", mode="w") - fileHistory.write(command + "\n") + file_history = open(history_path, encoding="utf-8", mode="w") + file_history.write(command + "\n") except OSError as e: - raise OSError(_("Unable to update history file {}").format(history_path)) from e + raise OSError( + _("Unable to add entry to history file {}").format(history_path) + ) from e + finally: + if file_history: + file_history.close() + + +def remove_entry_from_history(del_line_number, history_path=None): + """Remove entry from history file. + + :param int del_line_number: line number of the command to be removed + :param str|None history_path: history file path string + """ + file_history = None + if not history_path: + history_path = get_current_mapset_gui_history_path() + try: + file_history = open(history_path, encoding="utf-8", mode="r+") + lines = file_history.readlines() + file_history.seek(0) + file_history.truncate() + for number, line in enumerate(lines): + if number not in [del_line_number]: + file_history.write(line) + except OSError as e: + raise OSError( + _("Unable to remove entry from history file {}").format(history_path) + ) from e finally: - fileHistory.close() + if file_history: + file_history.close() def copy_history(target_path, history_path):