--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/HexEdit/HexEditSearchReplaceWidget.py Thu Jul 07 11:23:56 2022 +0200 @@ -0,0 +1,599 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a search and replace widget for the hex editor. +""" + +from PyQt6.QtCore import pyqtSlot, Qt, QByteArray, QRegularExpression +from PyQt6.QtGui import QRegularExpressionValidator +from PyQt6.QtWidgets import QWidget + +from EricGui.EricAction import EricAction +from EricWidgets import EricMessageBox + +import UI.PixmapCache + + +class HexEditSearchReplaceWidget(QWidget): + """ + Class implementing a search and replace widget for the hex editor. + """ + def __init__(self, editor, mainWindow, replace=False, parent=None): + """ + Constructor + + @param editor reference to the hex editor widget + @type HexEditWidget + @param mainWindow reference to the main window + @type HexEditMainWindow + @param replace flag indicating a replace widget + @type bool + @param parent reference to the parent widget + @type QWidget + """ + super().__init__(parent) + + self.__replace = replace + self.__editor = editor + + # keep this in sync with the logic in __getContent() + self.__formatAndValidators = { + "hex": (self.tr("Hex"), QRegularExpressionValidator( + QRegularExpression("[0-9a-f]*"))), + "dec": (self.tr("Dec"), QRegularExpressionValidator( + QRegularExpression("[0-9]*"))), + "oct": (self.tr("Oct"), QRegularExpressionValidator( + QRegularExpression("[0-7]*"))), + "bin": (self.tr("Bin"), QRegularExpressionValidator( + QRegularExpression("[01]*"))), + "iso-8859-1": (self.tr("Text"), None), + # text as latin-1/iso-8859-1 + "utf-8": (self.tr("UTF-8"), None), + # text as utf-8 + } + formatOrder = ["hex", "dec", "oct", "bin", "iso-8859-1", "utf-8"] + + self.__currentFindFormat = "" + self.__currentReplaceFormat = "" + + self.__findHistory = mainWindow.getSRHistory("search") + if replace: + from .Ui_HexEditReplaceWidget import Ui_HexEditReplaceWidget + self.__replaceHistory = mainWindow.getSRHistory("replace") + self.__ui = Ui_HexEditReplaceWidget() + else: + from .Ui_HexEditSearchWidget import Ui_HexEditSearchWidget + self.__ui = Ui_HexEditSearchWidget() + self.__ui.setupUi(self) + + self.__ui.closeButton.setIcon(UI.PixmapCache.getIcon("close")) + self.__ui.findPrevButton.setIcon( + UI.PixmapCache.getIcon("1leftarrow")) + self.__ui.findNextButton.setIcon( + UI.PixmapCache.getIcon("1rightarrow")) + + if replace: + self.__ui.replaceButton.setIcon( + UI.PixmapCache.getIcon("editReplace")) + self.__ui.replaceSearchButton.setIcon( + UI.PixmapCache.getIcon("editReplaceSearch")) + self.__ui.replaceAllButton.setIcon( + UI.PixmapCache.getIcon("editReplaceAll")) + + for dataFormat in formatOrder: + formatStr, validator = self.__formatAndValidators[dataFormat] + self.__ui.findFormatCombo.addItem(formatStr, dataFormat) + if replace: + for dataFormat in formatOrder: + formatStr, validator = self.__formatAndValidators[dataFormat] + self.__ui.replaceFormatCombo.addItem(formatStr, dataFormat) + + self.__ui.findtextCombo.setCompleter(None) + self.__ui.findtextCombo.lineEdit().returnPressed.connect( + self.__findByReturnPressed) + if replace: + self.__ui.replacetextCombo.setCompleter(None) + self.__ui.replacetextCombo.lineEdit().returnPressed.connect( + self.on_replaceButton_clicked) + + self.findNextAct = EricAction( + self.tr('Find Next'), + self.tr('Find Next'), + 0, 0, self, 'hexEditor_search_widget_find_next') + self.findNextAct.triggered.connect(self.on_findNextButton_clicked) + self.findNextAct.setEnabled(False) + self.__ui.findtextCombo.addAction(self.findNextAct) + + self.findPrevAct = EricAction( + self.tr('Find Prev'), + self.tr('Find Prev'), + 0, 0, self, 'hexEditor_search_widget_find_prev') + self.findPrevAct.triggered.connect(self.on_findPrevButton_clicked) + self.findPrevAct.setEnabled(False) + self.__ui.findtextCombo.addAction(self.findPrevAct) + + self.__havefound = False + + @pyqtSlot(int) + def on_findFormatCombo_currentIndexChanged(self, idx): + """ + Private slot to handle a selection from the find format. + + @param idx index of the selected entry + @type int + """ + if idx >= 0: + findFormat = self.__ui.findFormatCombo.itemData(idx) + + if findFormat != self.__currentFindFormat: + txt = self.__ui.findtextCombo.currentText() + newTxt = self.__convertText( + txt, self.__currentFindFormat, findFormat) + self.__currentFindFormat = findFormat + + self.__ui.findtextCombo.setValidator( + self.__formatAndValidators[findFormat][1]) + + self.__ui.findtextCombo.setEditText(newTxt) + + @pyqtSlot(str) + def on_findtextCombo_editTextChanged(self, txt): + """ + Private slot to enable/disable the find buttons. + + @param txt text of the find text combo + @type str + """ + if not txt: + self.__ui.findNextButton.setEnabled(False) + self.findNextAct.setEnabled(False) + self.__ui.findPrevButton.setEnabled(False) + self.findPrevAct.setEnabled(False) + if self.__replace: + self.__ui.replaceButton.setEnabled(False) + self.__ui.replaceSearchButton.setEnabled(False) + self.__ui.replaceAllButton.setEnabled(False) + else: + self.__ui.findNextButton.setEnabled(True) + self.findNextAct.setEnabled(True) + self.__ui.findPrevButton.setEnabled(True) + self.findPrevAct.setEnabled(True) + if self.__replace: + self.__ui.replaceButton.setEnabled(False) + self.__ui.replaceSearchButton.setEnabled(False) + self.__ui.replaceAllButton.setEnabled(True) + + @pyqtSlot(int) + def on_findtextCombo_activated(self, idx): + """ + Private slot to handle a selection from the find history. + + @param idx index of the selected entry + @type int + """ + if idx >= 0: + formatIndex = self.__ui.findtextCombo.itemData(idx) + if formatIndex is not None: + self.__ui.findFormatCombo.setCurrentIndex(formatIndex) + + def __getContent(self, replace=False): + """ + Private method to get the contents of the find/replace combo as + a bytearray. + + @param replace flag indicating to retrieve the replace contents + @type bool + @return search or replace term as text and binary data + @rtype tuple of bytearray and str + """ + if replace: + textCombo = self.__ui.replacetextCombo + formatCombo = self.__ui.replaceFormatCombo + history = self.__replaceHistory + else: + textCombo = self.__ui.findtextCombo + formatCombo = self.__ui.findFormatCombo + history = self.__findHistory + + txt = textCombo.currentText() + idx = formatCombo.currentIndex() + findFormat = formatCombo.itemData(idx) + ba = self.__text2bytearray(txt, findFormat) + + # This moves any previous occurrence of this statement to the head + # of the list and updates the combobox + historyEntry = (idx, txt) + if historyEntry in history: + history.remove(historyEntry) + history.insert(0, historyEntry) + textCombo.clear() + for index, text in history: + textCombo.addItem(text, index) + + return ba, txt + + @pyqtSlot() + def on_findNextButton_clicked(self): + """ + Private slot to find the next occurrence. + """ + self.findPrevNext(False) + + @pyqtSlot() + def on_findPrevButton_clicked(self): + """ + Private slot to find the previous occurrence. + """ + self.findPrevNext(True) + + def findPrevNext(self, prev=False): + """ + Public slot to find the next occurrence of the search term. + + @param prev flag indicating a backwards search + @type bool + @return flag indicating a successful search + @rtype bool + """ + if not self.__havefound or not self.__ui.findtextCombo.currentText(): + self.show() + return False + + self.__findBackwards = prev + ba, txt = self.__getContent() + + idx = -1 + if len(ba) > 0: + startIndex = self.__editor.cursorPosition() // 2 + if prev: + if ( + self.__editor.hasSelection() and + startIndex == self.__editor.getSelectionEnd() + ): + # skip to the selection start + startIndex = self.__editor.getSelectionBegin() + idx = self.__editor.lastIndexOf(ba, startIndex) + else: + if ( + self.__editor.hasSelection() and + startIndex == self.__editor.getSelectionBegin() - 1 + ): + # skip to the selection end + startIndex = self.__editor.getSelectionEnd() + idx = self.__editor.indexOf(ba, startIndex) + + if idx >= 0: + if self.__replace: + self.__ui.replaceButton.setEnabled(True) + self.__ui.replaceSearchButton.setEnabled(True) + else: + EricMessageBox.information( + self, self.windowTitle(), + self.tr("'{0}' was not found.").format(txt)) + + return idx >= 0 + + def __findByReturnPressed(self): + """ + Private slot to handle a return pressed in the find combo. + """ + if self.__findBackwards: + self.findPrevNext(True) + else: + self.findPrevNext(False) + + @pyqtSlot(int) + def on_replaceFormatCombo_currentIndexChanged(self, idx): + """ + Private slot to handle a selection from the replace format. + + @param idx index of the selected entry + @type int + """ + if idx >= 0: + replaceFormat = self.__ui.replaceFormatCombo.itemData(idx) + + if replaceFormat != self.__currentReplaceFormat: + txt = self.__ui.replacetextCombo.currentText() + newTxt = self.__convertText( + txt, self.__currentReplaceFormat, replaceFormat) + self.__currentReplaceFormat = replaceFormat + + self.__ui.replacetextCombo.setValidator( + self.__formatAndValidators[replaceFormat][1]) + + self.__ui.replacetextCombo.setEditText(newTxt) + + @pyqtSlot(int) + def on_replacetextCombo_activated(self, idx): + """ + Private slot to handle a selection from the replace history. + + @param idx index of the selected entry + @type int + """ + if idx >= 0: + formatIndex = self.__ui.replacetextCombo.itemData(idx) + if formatIndex is not None: + self.__ui.replaceFormatCombo.setCurrentIndex(formatIndex) + + @pyqtSlot() + def on_replaceButton_clicked(self): + """ + Private slot to replace one occurrence of data. + """ + self.__doReplace(False) + + @pyqtSlot() + def on_replaceSearchButton_clicked(self): + """ + Private slot to replace one occurrence of data and search for the next + one. + """ + self.__doReplace(True) + + def __doReplace(self, searchNext): + """ + Private method to replace one occurrence of data. + + @param searchNext flag indicating to search for the next occurrence + @type bool + """ + # Check enabled status due to dual purpose usage of this method + if ( + not self.__ui.replaceButton.isEnabled() and + not self.__ui.replaceSearchButton.isEnabled() + ): + return + + fba, ftxt = self.__getContent(False) + rba, rtxt = self.__getContent(True) + + ok = False + if self.__editor.hasSelection(): + # we did a successful search before + startIdx = self.__editor.getSelectionBegin() + self.__editor.replaceByteArray(startIdx, len(fba), rba) + + if searchNext: + ok = self.findPrevNext(self.__findBackwards) + + if not ok: + self.__ui.replaceButton.setEnabled(False) + self.__ui.replaceSearchButton.setEnabled(False) + + @pyqtSlot() + def on_replaceAllButton_clicked(self): + """ + Private slot to replace all occurrences of data. + """ + replacements = 0 + + cursorPosition = self.__editor.cursorPosition() + + fba, ftxt = self.__getContent(False) + rba, rtxt = self.__getContent(True) + + idx = 0 + while idx >= 0: + idx = self.__editor.indexOf(fba, idx) + if idx >= 0: + self.__editor.replaceByteArray(idx, len(fba), rba) + idx += len(rba) + replacements += 1 + + if replacements: + EricMessageBox.information( + self, self.windowTitle(), + self.tr("Replaced {0} occurrences.") + .format(replacements)) + else: + EricMessageBox.information( + self, self.windowTitle(), + self.tr("Nothing replaced because '{0}' was not found.") + .format(ftxt)) + + self.__editor.setCursorPosition(cursorPosition) + self.__editor.ensureVisible() + + def __showFind(self, text=''): + """ + Private method to display this widget in find mode. + + @param text hex encoded text to be shown in the findtext edit + @type str + """ + self.__replace = False + + self.__ui.findtextCombo.clear() + for index, txt in self.__findHistory: + self.__ui.findtextCombo.addItem(txt, index) + self.__ui.findFormatCombo.setCurrentIndex(0) # 0 is always Hex + self.on_findFormatCombo_currentIndexChanged(0) + self.__ui.findtextCombo.setEditText(text) + self.__ui.findtextCombo.lineEdit().selectAll() + self.__ui.findtextCombo.setFocus() + self.on_findtextCombo_editTextChanged(text) + + self.__havefound = True + self.__findBackwards = False + + def __showReplace(self, text=''): + """ + Private slot to display this widget in replace mode. + + @param text hex encoded text to be shown in the findtext edit + @type str + """ + self.__replace = True + + self.__ui.findtextCombo.clear() + for index, txt in self.__findHistory: + self.__ui.findtextCombo.addItem(txt, index) + self.__ui.findFormatCombo.setCurrentIndex(0) # 0 is always Hex + self.on_findFormatCombo_currentIndexChanged(0) + self.__ui.findtextCombo.setEditText(text) + self.__ui.findtextCombo.lineEdit().selectAll() + self.__ui.findtextCombo.setFocus() + self.on_findtextCombo_editTextChanged(text) + + self.__ui.replacetextCombo.clear() + for index, txt in self.__replaceHistory: + self.__ui.replacetextCombo.addItem(txt, index) + self.__ui.replaceFormatCombo.setCurrentIndex(0) # 0 is always Hex + self.on_replaceFormatCombo_currentIndexChanged(0) + self.__ui.replacetextCombo.setEditText('') + + self.__havefound = True + self.__findBackwards = False + + def show(self, text=''): + """ + Public slot to show the widget. + + @param text hex encoded text to be shown in the findtext edit + @type str + """ + if self.__replace: + self.__showReplace(text) + else: + self.__showFind(text) + super().show() + self.activateWindow() + + @pyqtSlot() + def on_closeButton_clicked(self): + """ + Private slot to close the widget. + """ + self.__editor.setFocus(Qt.FocusReason.OtherFocusReason) + self.close() + + def keyPressEvent(self, event): + """ + Protected slot to handle key press events. + + @param event reference to the key press event + @type QKeyEvent + """ + if event.key() == Qt.Key.Key_Escape: + self.close() + + def __convertText(self, txt, oldFormat, newFormat): + """ + Private method to convert text from one format into another. + + @param txt text to be converted + @type str + @param oldFormat current format of the text + @type str + @param newFormat format to convert to + @type str + @return converted text + @rtype str + """ + if txt and oldFormat and newFormat and oldFormat != newFormat: + # step 1: convert the text to a byte array using the old format + byteArray = self.__text2bytearray(txt, oldFormat) + + # step 2: convert the byte array to text using the new format + txt = self.__bytearray2text(byteArray, newFormat) + + return txt + + def __int2bytearray(self, value): + """ + Private method to convert an integer to a byte array. + + @param value value to be converted + @type int + @return byte array for the given value + @rtype bytearray + """ + ba = bytearray() + while value > 0: + value, modulus = divmod(value, 256) + ba.insert(0, modulus) + + return ba + + def __bytearray2int(self, array): + """ + Private method to convert a byte array to an integer value. + + @param array byte array to be converted + @type bytearray + @return integer value of the given array + @rtype int + """ + value = 0 + for b in array: + value = value * 256 + b + + return value + + def __text2bytearray(self, txt, dataFormat): + """ + Private method to convert a text to a byte array. + + @param txt text to be converted + @type str + @param dataFormat format of the text + @type str + @return converted text + @rtype bytearray + @exception ValueError raised to indicate an invalid dataFormat + parameter + """ + if dataFormat not in self.__formatAndValidators.keys(): + raise ValueError("Bad value for 'dataFormat' parameter.") + + if dataFormat == "hex": # hex format + ba = bytearray(QByteArray.fromHex( + bytes(txt, encoding="ascii"))) + elif dataFormat == "dec": # decimal format + ba = self.__int2bytearray(int(txt, 10)) + elif dataFormat == "oct": # octal format + ba = self.__int2bytearray(int(txt, 8)) + elif dataFormat == "bin": # binary format + ba = self.__int2bytearray(int(txt, 2)) + elif dataFormat == "iso-8859-1": # latin-1/iso-8859-1 text + ba = bytearray(txt, encoding="iso-8859-1") + elif dataFormat == "utf-8": # utf-8 text + ba = bytearray(txt, encoding="utf-8") + + return ba + + def __bytearray2text(self, array, dataFormat): + """ + Private method to convert a byte array to a text. + + @param array byte array to be converted + @type bytearray + @param dataFormat format of the text + @type str + @return formatted text + @rtype str + @exception ValueError raised to indicate an invalid dataFormat + parameter + """ + if dataFormat not in self.__formatAndValidators.keys(): + raise ValueError("Bad value for 'dataFormat' parameter.") + + if dataFormat == "hex": # hex format + txt = "{0:x}".format(self.__bytearray2int(array)) + elif dataFormat == "dec": # decimal format + txt = "{0:d}".format(self.__bytearray2int(array)) + elif dataFormat == "oct": # octal format + txt = "{0:o}".format(self.__bytearray2int(array)) + elif dataFormat == "bin": # binary format + txt = "{0:b}".format(self.__bytearray2int(array)) + elif dataFormat == "iso-8859-1": # latin-1/iso-8859-1 text + txt = str(array, encoding="iso-8859-1") + elif dataFormat == "utf-8": # utf-8 text + txt = str(array, encoding="utf-8", errors="replace") + + return txt