Add search and replace features
offlate/ui/editor.py
| 21 | 21 | from .spellcheckedit import SpellCheckEdit | |
| 22 | 22 | from .tagclickedit import TagClickEdit | |
| 23 | 23 | from .parallel import RunnableCallback, RunnableSignals | |
| 24 | + | from .search import SearchWindow | |
| 24 | 25 | from ..systems.callback import SystemCallback | |
| 25 | 26 | ||
| 26 | 27 | import math | |
… | |||
| 32 | 33 | from watchdog.events import FileSystemEventHandler | |
| 33 | 34 | from pathlib import Path | |
| 34 | 35 | from time import sleep | |
| 36 | + | import re | |
| 35 | 37 | ||
| 36 | 38 | class ProjectTab(QTabWidget): | |
| 37 | 39 | def __init__(self, parent = None): | |
… | |||
| 364 | 366 | item.setBackground(1, self.emptyColor) | |
| 365 | 367 | self.translationModified.emit() | |
| 366 | 368 | ||
| 367 | - | ||
| 368 | 369 | def save(self): | |
| 369 | 370 | self.project.save() | |
| 370 | 371 | ||
… | |||
| 384 | 385 | self.showFuzzy = showFuzzy | |
| 385 | 386 | self.updateContent() | |
| 386 | 387 | ||
| 388 | + | def searchNext(self, text, options): | |
| 389 | + | regexp = self._getRegexp(text, options) | |
| 390 | + | item = self.treeWidget.currentItem() | |
| 391 | + | index = self.treeWidget.indexOfTopLevelItem(item) | |
| 392 | + | nextItem = item | |
| 393 | + | while True: | |
| 394 | + | item = nextItem | |
| 395 | + | nextItem = self.treeWidget.itemBelow(item) | |
| 396 | + | if nextItem is None: | |
| 397 | + | if not options.wrap: | |
| 398 | + | return | |
| 399 | + | nextItem = self.treeWidget.topLevelItem(0) | |
| 400 | + | if self.treeWidget.indexOfTopLevelItem(nextItem) == index: | |
| 401 | + | return | |
| 402 | + | if index == -1 and self.treeWidget.indexOfTopLevelItem(nextItem) == 0: | |
| 403 | + | index = 0 | |
| 404 | + | if self._match(nextItem, regexp, options): | |
| 405 | + | self.treeWidget.setCurrentItem(nextItem) | |
| 406 | + | return | |
| 407 | + | ||
| 408 | + | def searchPrevious(self, text, options): | |
| 409 | + | regexp = self._getRegexp(text, options) | |
| 410 | + | item = self.treeWidget.currentItem() | |
| 411 | + | index = self.treeWidget.indexOfTopLevelItem(item) | |
| 412 | + | if index == -1: | |
| 413 | + | index = 0 | |
| 414 | + | previous = item | |
| 415 | + | while True: | |
| 416 | + | item = previous | |
| 417 | + | previous = self.treeWidget.itemAbove(item) | |
| 418 | + | if previous is None: | |
| 419 | + | if not options.wrap: | |
| 420 | + | return | |
| 421 | + | previous = self.treeWidget.topLevelItem(self.treeWidget.topLevelItemCount()-1) | |
| 422 | + | if self.treeWidget.indexOfTopLevelItem(previous) == index: | |
| 423 | + | return | |
| 424 | + | if self._match(previous, regexp, options): | |
| 425 | + | self.treeWidget.setCurrentItem(previous) | |
| 426 | + | return | |
| 427 | + | ||
| 428 | + | def _getRegexp(self, text, options): | |
| 429 | + | if options.word: | |
| 430 | + | regexp = '\b'+text+'\b' | |
| 431 | + | else: | |
| 432 | + | regexp = text | |
| 433 | + | if options.case: | |
| 434 | + | regexp = re.compile(regexp, re.IGNORECASE) | |
| 435 | + | else: | |
| 436 | + | regexp = re.compile(regexp) | |
| 437 | + | return regexp | |
| 438 | + | ||
| 439 | + | def _match(self, item, regexp, options): | |
| 440 | + | data = item.data(0, Qt.UserRole) | |
| 441 | + | ||
| 442 | + | if options.trans: | |
| 443 | + | if isinstance(data.msgstrs, list): | |
| 444 | + | for i in range(0, len(data.msgstrs)): | |
| 445 | + | if regexp.search(data.msgstrs[i]) is not None: | |
| 446 | + | return True | |
| 447 | + | else: # dict | |
| 448 | + | if regexp.search(data.msgstrs) is not None: | |
| 449 | + | return True | |
| 450 | + | ||
| 451 | + | if options.orig: | |
| 452 | + | if isinstance(data.msgids, list): | |
| 453 | + | for i in range(0, len(data.msgids)): | |
| 454 | + | if regexp.search(data.msgids[i]) is not None: | |
| 455 | + | return True | |
| 456 | + | else: #dict | |
| 457 | + | for k in data.msgids: | |
| 458 | + | if regexp.search(data.msgids[k]) is not None: | |
| 459 | + | return True | |
| 460 | + | return False | |
| 461 | + | ||
| 462 | + | def replaceAll(self, search, replace, options): | |
| 463 | + | regexp = self._getRegexp(search, options) | |
| 464 | + | item = self.treeWidget.currentItem() | |
| 465 | + | index = self.treeWidget.indexOfTopLevelItem(item) | |
| 466 | + | for i in range(0 if options.wrap else index, self.treeWidget.topLevelItemCount()): | |
| 467 | + | item = self.treeWidget.topLevelItem(i) | |
| 468 | + | self._replaceItem(regexp, replace, options, item) | |
| 469 | + | ||
| 470 | + | def replaceHere(self, search, replace, options): | |
| 471 | + | regexp = self._getRegexp(search, options) | |
| 472 | + | item = self.treeWidget.currentItem() | |
| 473 | + | self._replaceItem(regexp, replace, options, item) | |
| 474 | + | ||
| 475 | + | def _replaceItem(self, regexp, replace, options, item): | |
| 476 | + | data = item.data(0, Qt.UserRole) | |
| 477 | + | if isinstance(data.msgstrs, list): | |
| 478 | + | for i in range(0, len(data.msgstrs)): | |
| 479 | + | msgstr = data.get(i) | |
| 480 | + | msgstr = regexp.sub(replace, msgstr) | |
| 481 | + | data.update(i, msgstr) | |
| 482 | + | msgstr = data.get(0) | |
| 483 | + | item.setText(1, msgstr.replace('\n', ' ')) | |
| 484 | + | else: # dict | |
| 485 | + | for k in data.msgstrs: | |
| 486 | + | msgstr = data.msgstrs[k] | |
| 487 | + | msgstr = regexp.sub(replace, msgstr) | |
| 488 | + | data.update(i, msgstr) | |
| 489 | + | msgstr = data.get(0) | |
| 490 | + | item.setText(1, msgstr.replace('\n', ' ')) | |
| 491 | + | ||
| 492 | + | item.setForeground(1, QBrush()) | |
| 493 | + | if data.isTranslated(): | |
| 494 | + | item.setBackground(1, QBrush()) | |
| 495 | + | else: | |
| 496 | + | item.setBackground(1, self.emptyColor) | |
| 497 | + | self.translationModified.emit() | |
| 498 | + | ||
| 387 | 499 | def setFont(self, monospace): | |
| 388 | 500 | self.monospace = monospace | |
| 389 | 501 | current = self.treeWidget.currentItem() | |
… | |||
| 492 | 604 | def settings(self): | |
| 493 | 605 | self.projectManagerWindow.settings() | |
| 494 | 606 | ||
| 607 | + | def search(self): | |
| 608 | + | w = SearchWindow(self) | |
| 609 | + | w.exec() | |
| 610 | + | ||
| 611 | + | def replace(self): | |
| 612 | + | w = SearchWindow(self, True) | |
| 613 | + | w.exec() | |
| 614 | + | ||
| 615 | + | def searchNext(self, text, options): | |
| 616 | + | self.tabs.currentWidget().searchNext(text, options) | |
| 617 | + | ||
| 618 | + | def searchPrevious(self, text, options): | |
| 619 | + | self.tabs.currentWidget().searchPrevious(text, options) | |
| 620 | + | ||
| 621 | + | def replaceAll(self, search, replace, options): | |
| 622 | + | self.tabs.currentWidget().replaceAll(search, replace, options) | |
| 623 | + | ||
| 624 | + | def replaceHere(self, search, replace, options): | |
| 625 | + | self.tabs.currentWidget().replaceHere(search, replace, options) | |
| 626 | + | ||
| 495 | 627 | def filter(self): | |
| 496 | 628 | for i in range(0, self.tabs.count()): | |
| 497 | 629 | self.tabs.widget(i).filter( | |
… | |||
| 551 | 683 | settingsAct.setStatusTip(self.tr('Set parameters')) | |
| 552 | 684 | settingsAct.triggered.connect(self.settings) | |
| 553 | 685 | ||
| 686 | + | searchAct = QAction(QIcon('search.png'), self.tr('Search'), self) | |
| 687 | + | searchAct.setShortcut('Ctrl+F') | |
| 688 | + | searchAct.setStatusTip(self.tr('Search in the document')) | |
| 689 | + | searchAct.triggered.connect(self.search) | |
| 690 | + | ||
| 691 | + | replaceAct = QAction(QIcon('search.png'), self.tr('Replace'), self) | |
| 692 | + | replaceAct.setShortcut('Ctrl+R') | |
| 693 | + | replaceAct.setStatusTip(self.tr('Replace content in the document')) | |
| 694 | + | replaceAct.triggered.connect(self.replace) | |
| 695 | + | ||
| 554 | 696 | self.showTranslatedAct = QAction(self.tr('Show Translated'), self, checkable=True) | |
| 555 | 697 | self.showTranslatedAct.setChecked(True) | |
| 556 | 698 | self.showTranslatedAct.triggered.connect(self.filter) | |
… | |||
| 606 | 748 | ||
| 607 | 749 | editMenu = menubar.addMenu(self.tr('&Edit')) | |
| 608 | 750 | editMenu.addAction(settingsAct) | |
| 751 | + | editMenu.addAction(searchAct) | |
| 752 | + | editMenu.addAction(replaceAct) | |
| 609 | 753 | ||
| 610 | 754 | viewMenu = menubar.addMenu(self.tr('&View')) | |
| 611 | 755 | viewMenu.addAction(self.showTranslatedAct) | |
offlate/ui/search.py unknown status 1
| 1 | + | # Copyright (c) 2019 Julien Lepiller <julien@lepiller.eu> | |
| 2 | + | # | |
| 3 | + | # This program is free software: you can redistribute it and/or modify | |
| 4 | + | # it under the terms of the GNU Affero General Public License as | |
| 5 | + | # published by the Free Software Foundation, either version 3 of the | |
| 6 | + | # License, or (at your option) any later version. | |
| 7 | + | # | |
| 8 | + | # This program is distributed in the hope that it will be useful, | |
| 9 | + | # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| 10 | + | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| 11 | + | # GNU Affero General Public License for more details. | |
| 12 | + | # | |
| 13 | + | # You should have received a copy of the GNU Affero General Public License | |
| 14 | + | # along with this program. If not, see <https://www.gnu.org/licenses/>. | |
| 15 | + | #### | |
| 16 | + | ||
| 17 | + | from PyQt5.QtWidgets import * | |
| 18 | + | from PyQt5.QtGui import * | |
| 19 | + | from PyQt5.QtCore import * | |
| 20 | + | ||
| 21 | + | class SearchOptions: | |
| 22 | + | def __init__(self, orig, trans, comm, case, wrap, word): | |
| 23 | + | self.orig = orig | |
| 24 | + | self.trans = trans | |
| 25 | + | self.comm = comm | |
| 26 | + | self.case = case | |
| 27 | + | self.wrap = wrap | |
| 28 | + | self.word = word | |
| 29 | + | ||
| 30 | + | class SearchWindow(QDialog): | |
| 31 | + | def __init__(self, parent = None, replace = False): | |
| 32 | + | super().__init__(parent) | |
| 33 | + | self.editor = parent | |
| 34 | + | self.replace = replace | |
| 35 | + | self.initUI() | |
| 36 | + | ||
| 37 | + | def showOrHideReplace(self): | |
| 38 | + | self.replaceField.setVisible(self.replace) | |
| 39 | + | self.searchRadio.setChecked(not self.replace) | |
| 40 | + | self.replaceRadio.setChecked(self.replace) | |
| 41 | + | self.replaceAllButton.setVisible(self.replace) | |
| 42 | + | self.replaceButton.setVisible(self.replace) | |
| 43 | + | self.origCheckBox.setChecked(self.origCheckBox.isChecked() and not self.replace) | |
| 44 | + | self.commCheckBox.setChecked(self.origCheckBox.isChecked() and not self.replace) | |
| 45 | + | self.origCheckBox.setEnabled(not self.replace) | |
| 46 | + | self.commCheckBox.setEnabled(not self.replace) | |
| 47 | + | ||
| 48 | + | def setReplace(self, check): | |
| 49 | + | if not check: | |
| 50 | + | return | |
| 51 | + | self.replace = True | |
| 52 | + | self.showOrHideReplace() | |
| 53 | + | ||
| 54 | + | def setSearch(self, check): | |
| 55 | + | if not check: | |
| 56 | + | return | |
| 57 | + | self.replace = False | |
| 58 | + | self.showOrHideReplace() | |
| 59 | + | ||
| 60 | + | def _getOptionObject(self): | |
| 61 | + | return SearchOptions(self.origCheckBox.isChecked(), | |
| 62 | + | self.transCheckBox.isChecked(), self.commCheckBox.isChecked(), | |
| 63 | + | self.caseCheckBox.isChecked(), self.wrapCheckBox.isChecked(), | |
| 64 | + | self.wordCheckBox.isChecked()) | |
| 65 | + | ||
| 66 | + | def searchNext(self): | |
| 67 | + | self.editor.searchNext(self.searchField.text(), self._getOptionObject()) | |
| 68 | + | ||
| 69 | + | def searchPrevious(self): | |
| 70 | + | self.editor.searchPrevious(self.searchField.text(), | |
| 71 | + | self._getOptionObject()) | |
| 72 | + | ||
| 73 | + | def replaceAll(self): | |
| 74 | + | self.editor.replaceAll(self.searchField.text(), self.replaceField.text(), | |
| 75 | + | self._getOptionObject()) | |
| 76 | + | ||
| 77 | + | def replaceHere(self): | |
| 78 | + | self.editor.replaceHere(self.searchField.text(), self.replaceField.text(), | |
| 79 | + | self._getOptionObject()) | |
| 80 | + | ||
| 81 | + | def initUI(self): | |
| 82 | + | vbox = QVBoxLayout() | |
| 83 | + | self.searchField = QLineEdit() | |
| 84 | + | self.searchField.setPlaceholderText(self.tr('String to search for')) | |
| 85 | + | vbox.addWidget(self.searchField) | |
| 86 | + | self.replaceField = QLineEdit() | |
| 87 | + | self.replaceField.setPlaceholderText(self.tr('String to replace into')) | |
| 88 | + | vbox.addWidget(self.replaceField) | |
| 89 | + | ||
| 90 | + | typeBox = QHBoxLayout() | |
| 91 | + | group = QGroupBox() | |
| 92 | + | self.searchRadio = QRadioButton(self.tr('Search')) | |
| 93 | + | self.replaceRadio = QRadioButton(self.tr('Replace')) | |
| 94 | + | self.searchRadio.clicked.connect(self.setSearch) | |
| 95 | + | self.replaceRadio.clicked.connect(self.setReplace) | |
| 96 | + | typeBox.addWidget(self.searchRadio) | |
| 97 | + | typeBox.addWidget(self.replaceRadio) | |
| 98 | + | group.setLayout(typeBox) | |
| 99 | + | vbox.addWidget(group) | |
| 100 | + | ||
| 101 | + | options = QGridLayout() | |
| 102 | + | self.caseCheckBox = QCheckBox(self.tr('Case sensitive')) | |
| 103 | + | self.wrapCheckBox = QCheckBox(self.tr('Wrap around')) | |
| 104 | + | self.wrapCheckBox.setChecked(True) | |
| 105 | + | self.wordCheckBox = QCheckBox(self.tr('Match whole word only')) | |
| 106 | + | self.origCheckBox = QCheckBox(self.tr('Search in original text')) | |
| 107 | + | self.origCheckBox.setChecked(True) | |
| 108 | + | self.transCheckBox = QCheckBox(self.tr('Search in translated text')) | |
| 109 | + | self.transCheckBox.setChecked(True) | |
| 110 | + | self.commCheckBox = QCheckBox(self.tr('Search in comments')) | |
| 111 | + | options.addWidget(self.caseCheckBox, 0, 0) | |
| 112 | + | options.addWidget(self.wrapCheckBox, 1, 0) | |
| 113 | + | options.addWidget(self.wordCheckBox, 2, 0) | |
| 114 | + | options.addWidget(self.origCheckBox, 0, 1) | |
| 115 | + | options.addWidget(self.transCheckBox, 1, 1) | |
| 116 | + | options.addWidget(self.commCheckBox, 2, 1) | |
| 117 | + | vbox.addLayout(options) | |
| 118 | + | ||
| 119 | + | vbox.addStretch(1) | |
| 120 | + | ||
| 121 | + | buttons = QHBoxLayout() | |
| 122 | + | closeButton = QPushButton(self.tr('Close')) | |
| 123 | + | closeButton.clicked.connect(self.close) | |
| 124 | + | buttons.addWidget(closeButton) | |
| 125 | + | buttons.addStretch(1) | |
| 126 | + | self.replaceAllButton = QPushButton(self.tr('Replace all')) | |
| 127 | + | self.replaceAllButton.clicked.connect(self.replaceAll) | |
| 128 | + | self.replaceButton = QPushButton(self.tr('Replace one')) | |
| 129 | + | self.replaceButton.clicked.connect(self.replaceHere) | |
| 130 | + | previousButton = QPushButton(self.tr('< Previous')) | |
| 131 | + | previousButton.clicked.connect(self.searchPrevious) | |
| 132 | + | nextButton = QPushButton(self.tr('Next >')) | |
| 133 | + | nextButton.setDefault(True) | |
| 134 | + | nextButton.setAutoDefault(True) | |
| 135 | + | nextButton.clicked.connect(self.searchNext) | |
| 136 | + | buttons.addWidget(self.replaceAllButton) | |
| 137 | + | buttons.addWidget(self.replaceButton) | |
| 138 | + | buttons.addWidget(previousButton) | |
| 139 | + | buttons.addWidget(nextButton) | |
| 140 | + | vbox.addLayout(buttons) | |
| 141 | + | ||
| 142 | + | self.setLayout(vbox) | |
| 143 | + | self.showOrHideReplace() |