1# qscilib.py - Utility codes for QsciScintilla 2# 3# Copyright 2010 Steve Borho <steve@borho.org> 4# Copyright 2010 Yuya Nishihara <yuya@tcha.org> 5# 6# This software may be used and distributed according to the terms of the 7# GNU General Public License version 2 or any later version. 8 9from __future__ import absolute_import 10 11import os 12import re 13import weakref 14 15from mercurial import ( 16 pycompat, 17) 18 19from .qsci import ( 20 QSCINTILLA_VERSION, 21 QsciLexerProperties, 22 QsciScintilla, 23) 24from .qtcore import ( 25 QObject, 26 QEvent, 27 QFile, 28 QIODevice, 29 QRect, 30 QSettings, 31 QT_VERSION, 32 Qt, 33 pyqtSignal, 34 pyqtSlot, 35) 36from .qtgui import ( 37 QAction, 38 QCheckBox, 39 QDialog, 40 QDialogButtonBox, 41 QFont, 42 QInputMethodEvent, 43 QKeyEvent, 44 QKeySequence, 45 QLineEdit, 46 QMenu, 47 QToolBar, 48 QVBoxLayout, 49 qApp, 50) 51 52from ..util import hglib 53from ..util.i18n import _ 54from . import qtlib 55 56# indicator for highlighting preedit text of input method 57_IM_PREEDIT_INDIC_ID = QsciScintilla.INDIC_MAX 58# indicator for keyword highlighting 59_HIGHLIGHT_INDIC_ID = _IM_PREEDIT_INDIC_ID - 1 60 61STYLE_FILEVIEW_MARGIN = QsciScintilla.STYLE_LASTPREDEFINED + 1 62 63 64class _SciImSupport(object): 65 """Patch for QsciScintilla to implement improved input method support 66 67 See https://doc.qt.io/qt-4.8/qinputmethodevent.html 68 """ 69 70 def __init__(self, sci): 71 self._sci = weakref.proxy(sci) 72 self._preeditpos = (0, 0) # (line, index) where preedit text starts 73 self._preeditlen = 0 74 self._preeditcursorpos = 0 # relative pos where preedit cursor exists 75 self._undoactionbegun = False 76 sci.SendScintilla(QsciScintilla.SCI_INDICSETSTYLE, 77 _IM_PREEDIT_INDIC_ID, QsciScintilla.INDIC_PLAIN) 78 79 def removepreedit(self): 80 """Remove the previous preedit text 81 82 original pos: preedit cursor 83 final pos: target cursor 84 """ 85 l, i = self._sci.getCursorPosition() 86 i -= self._preeditcursorpos 87 self._preeditcursorpos = 0 88 try: 89 self._sci.setSelection( 90 self._preeditpos[0], self._preeditpos[1], 91 self._preeditpos[0], self._preeditpos[1] + self._preeditlen) 92 self._sci.removeSelectedText() 93 finally: 94 self._sci.setCursorPosition(l, i) 95 96 def commitstr(self, start, repllen, commitstr): 97 """Remove the repl string followed by insertion of the commit string 98 99 original pos: target cursor 100 final pos: end of committed text (= start of preedit text) 101 """ 102 l, i = self._sci.getCursorPosition() 103 i += start 104 self._sci.setSelection(l, i, l, i + repllen) 105 self._sci.removeSelectedText() 106 self._sci.insert(commitstr) 107 self._sci.setCursorPosition(l, i + len(commitstr)) 108 if commitstr: 109 self.endundo() 110 111 def insertpreedit(self, text): 112 """Insert preedit text 113 114 original pos: start of preedit text 115 final pos: start of preedit text (unchanged) 116 """ 117 if text and not self._preeditlen: 118 self.beginundo() 119 l, i = self._sci.getCursorPosition() 120 self._sci.insert(text) 121 self._updatepreeditpos(l, i, len(text)) 122 if not self._preeditlen: 123 self.endundo() 124 125 def movepreeditcursor(self, pos): 126 """Move the cursor to the relative pos inside preedit text""" 127 self._preeditcursorpos = min(pos, self._preeditlen) 128 l, i = self._preeditpos 129 self._sci.setCursorPosition(l, i + self._preeditcursorpos) 130 131 def beginundo(self): 132 if self._undoactionbegun: 133 return 134 self._sci.beginUndoAction() 135 self._undoactionbegun = True 136 137 def endundo(self): 138 if not self._undoactionbegun: 139 return 140 self._sci.endUndoAction() 141 self._undoactionbegun = False 142 143 def _updatepreeditpos(self, l, i, len): 144 """Update the indicator and internal state for preedit text""" 145 self._sci.SendScintilla(QsciScintilla.SCI_SETINDICATORCURRENT, 146 _IM_PREEDIT_INDIC_ID) 147 self._preeditpos = (l, i) 148 self._preeditlen = len 149 if len <= 0: # have problem on sci 150 return 151 p = self._sci.positionFromLineIndex(*self._preeditpos) 152 q = self._sci.positionFromLineIndex(self._preeditpos[0], 153 self._preeditpos[1] + len) 154 self._sci.SendScintilla(QsciScintilla.SCI_INDICATORFILLRANGE, 155 p, q - p) # q - p != len 156 157 158class ScintillaCompat(QsciScintilla): 159 """Scintilla widget with compatibility patches""" 160 161 # QScintilla 2.8.4 still can't handle input method events properly. 162 # For example, it fails to delete the last preedit text by ^H, and 163 # editing position goes wrong. So we sticks to our version. 164 if True: 165 def __init__(self, parent=None): 166 super(ScintillaCompat, self).__init__(parent) 167 self._imsupport = _SciImSupport(self) 168 169 def inputMethodQuery(self, query): 170 if query == Qt.ImMicroFocus: 171 # a rectangle (in viewport coords) including the cursor 172 l, i = self.getCursorPosition() 173 p = self.positionFromLineIndex(l, i) 174 x = self.SendScintilla(QsciScintilla.SCI_POINTXFROMPOSITION, 175 0, p) 176 y = self.SendScintilla(QsciScintilla.SCI_POINTYFROMPOSITION, 177 0, p) 178 w = self.SendScintilla(QsciScintilla.SCI_GETCARETWIDTH) 179 return QRect(x, y, w, self.textHeight(l)) 180 return super(ScintillaCompat, self).inputMethodQuery(query) 181 182 def inputMethodEvent(self, event): 183 if self.isReadOnly(): 184 return 185 186 self.removeSelectedText() 187 self._imsupport.removepreedit() 188 self._imsupport.commitstr(event.replacementStart(), 189 event.replacementLength(), 190 event.commitString()) 191 self._imsupport.insertpreedit(event.preeditString()) 192 for a in event.attributes(): 193 if a.type == QInputMethodEvent.Cursor: 194 self._imsupport.movepreeditcursor(a.start) 195 # TextFormat is not supported 196 197 event.accept() 198 199 # QScintilla 2.5 can translate Backtab to Shift+SCK_TAB (issue #82) 200 if QSCINTILLA_VERSION < 0x20500: 201 def keyPressEvent(self, event): 202 if event.key() == Qt.Key_Backtab: 203 event = QKeyEvent(event.type(), Qt.Key_Tab, Qt.ShiftModifier) 204 super(ScintillaCompat, self).keyPressEvent(event) 205 206 if not hasattr(QsciScintilla, 'createStandardContextMenu'): 207 def createStandardContextMenu(self): 208 """Create standard context menu; ownership is transferred to 209 caller""" 210 menu = QMenu(self) 211 if not self.isReadOnly(): 212 a = menu.addAction(_('&Undo'), self.undo) 213 a.setShortcuts(QKeySequence.Undo) 214 a.setEnabled(self.isUndoAvailable()) 215 a = menu.addAction(_('&Redo'), self.redo) 216 a.setShortcuts(QKeySequence.Redo) 217 a.setEnabled(self.isRedoAvailable()) 218 menu.addSeparator() 219 a = menu.addAction(_('Cu&t'), self.cut) 220 a.setShortcuts(QKeySequence.Cut) 221 a.setEnabled(self.hasSelectedText()) 222 a = menu.addAction(_('&Copy'), self.copy) 223 a.setShortcuts(QKeySequence.Copy) 224 a.setEnabled(self.hasSelectedText()) 225 if not self.isReadOnly(): 226 a = menu.addAction(_('&Paste'), self.paste) 227 a.setShortcuts(QKeySequence.Paste) 228 a = menu.addAction(_('&Delete'), self.removeSelectedText) 229 a.setShortcuts(QKeySequence.Delete) 230 a.setEnabled(self.hasSelectedText()) 231 menu.addSeparator() 232 a = menu.addAction(_('Select &All'), self.selectAll) 233 a.setShortcuts(QKeySequence.SelectAll) 234 return menu 235 236 # compability mode with QScintilla from Ubuntu 10.04 237 if not hasattr(QsciScintilla, 'HiddenIndicator'): 238 HiddenIndicator = QsciScintilla.INDIC_HIDDEN 239 if not hasattr(QsciScintilla, 'PlainIndicator'): 240 PlainIndicator = QsciScintilla.INDIC_PLAIN 241 if not hasattr(QsciScintilla, 'StrikeIndicator'): 242 StrikeIndicator = QsciScintilla.INDIC_STRIKE 243 244 if not hasattr(QsciScintilla, 'indicatorDefine'): 245 def indicatorDefine(self, style, indicatorNumber=-1): 246 # compatibility layer allows only one indicator to be defined 247 if indicatorNumber == -1: 248 indicatorNumber = 1 249 self.SendScintilla(self.SCI_INDICSETSTYLE, indicatorNumber, style) 250 return indicatorNumber 251 252 if not hasattr(QsciScintilla, 'setIndicatorDrawUnder'): 253 def setIndicatorDrawUnder(self, under, indicatorNumber): 254 self.SendScintilla(self.SCI_INDICSETUNDER, indicatorNumber, under) 255 256 if not hasattr(QsciScintilla, 'setIndicatorForegroundColor'): 257 def setIndicatorForegroundColor(self, color, indicatorNumber): 258 self.SendScintilla(self.SCI_INDICSETFORE, indicatorNumber, color) 259 self.SendScintilla(self.SCI_INDICSETALPHA, indicatorNumber, 260 color.alpha()) 261 262 if not hasattr(QsciScintilla, 'clearIndicatorRange'): 263 def clearIndicatorRange(self, lineFrom, indexFrom, lineTo, indexTo, 264 indicatorNumber): 265 start = self.positionFromLineIndex(lineFrom, indexFrom) 266 finish = self.positionFromLineIndex(lineTo, indexTo) 267 268 self.SendScintilla(self.SCI_SETINDICATORCURRENT, indicatorNumber) 269 self.SendScintilla(self.SCI_INDICATORCLEARRANGE, 270 start, finish - start) 271 272 if not hasattr(QsciScintilla, 'fillIndicatorRange'): 273 def fillIndicatorRange(self, lineFrom, indexFrom, lineTo, indexTo, 274 indicatorNumber): 275 start = self.positionFromLineIndex(lineFrom, indexFrom) 276 finish = self.positionFromLineIndex(lineTo, indexTo) 277 278 self.SendScintilla(self.SCI_SETINDICATORCURRENT, indicatorNumber) 279 self.SendScintilla(self.SCI_INDICATORFILLRANGE, 280 start, finish - start) 281 282 283class Scintilla(ScintillaCompat): 284 """Scintilla widget for rich file view or editor""" 285 286 def __init__(self, parent=None): 287 super(Scintilla, self).__init__(parent) 288 self.autoUseTabs = True 289 self.setUtf8(True) 290 self.setWrapVisualFlags(QsciScintilla.WrapFlagByBorder) 291 self.textChanged.connect(self._resetfindcond) 292 self._resetfindcond() 293 self.highlightLines = set() 294 self._setupHighlightIndicator() 295 self._setMultipleSelectionOptions() 296 unbindConflictedKeys(self) 297 298 def _setMultipleSelectionOptions(self): 299 if hasattr(QsciScintilla, 'SCI_SETMULTIPLESELECTION'): 300 self.SendScintilla(QsciScintilla.SCI_SETMULTIPLESELECTION, True) 301 self.SendScintilla(QsciScintilla.SCI_SETADDITIONALSELECTIONTYPING, 302 True) 303 self.SendScintilla(QsciScintilla.SCI_SETMULTIPASTE, 304 QsciScintilla.SC_MULTIPASTE_EACH) 305 self.SendScintilla(QsciScintilla.SCI_SETVIRTUALSPACEOPTIONS, 306 QsciScintilla.SCVS_RECTANGULARSELECTION) 307 308 def contextMenuEvent(self, event): 309 menu = self.createEditorContextMenu() 310 menu.exec_(event.globalPos()) 311 menu.setParent(None) 312 313 def createEditorContextMenu(self): 314 """Create context menu with editor options; ownership is transferred 315 to caller""" 316 menu = self.createStandardContextMenu() 317 menu.addSeparator() 318 editoptsmenu = menu.addMenu(_('&Editor Options')) 319 self._buildEditorOptionsMenu(editoptsmenu) 320 return menu 321 322 def _buildEditorOptionsMenu(self, menu): 323 qsci = QsciScintilla 324 325 wrapmenu = menu.addMenu(_('&Wrap')) 326 wrapmenu.triggered.connect(self._setWrapModeByMenu) 327 for name, mode in ((_('&None', 'wrap mode'), qsci.WrapNone), 328 (_('&Word'), qsci.WrapWord), 329 (_('&Character'), qsci.WrapCharacter)): 330 a = wrapmenu.addAction(name) 331 a.setCheckable(True) 332 a.setChecked(self.wrapMode() == mode) 333 a.setData(mode) 334 335 menu.addSeparator() 336 wsmenu = menu.addMenu(_('White&space')) 337 wsmenu.triggered.connect(self._setWhitespaceVisibilityByMenu) 338 for name, mode in ((_('&Visible'), qsci.WsVisible), 339 (_('&Invisible'), qsci.WsInvisible), 340 (_('&AfterIndent'), qsci.WsVisibleAfterIndent)): 341 a = wsmenu.addAction(name) 342 a.setCheckable(True) 343 a.setChecked(self.whitespaceVisibility() == mode) 344 a.setData(mode) 345 346 if not self.isReadOnly(): 347 tabindentsmenu = menu.addMenu(_('&TAB Inserts')) 348 tabindentsmenu.triggered.connect(self._setIndentationsUseTabsByMenu) 349 for name, mode in ((_('&Auto'), -1), 350 (_('&TAB'), True), 351 (_('&Spaces'), False)): 352 a = tabindentsmenu.addAction(name) 353 a.setCheckable(True) 354 a.setChecked(self.indentationsUseTabs() == mode 355 or (self.autoUseTabs and mode == -1)) 356 a.setData(mode) 357 358 menu.addSeparator() 359 vsmenu = menu.addMenu(_('EOL &Visibility')) 360 vsmenu.triggered.connect(self._setEolVisibilityByMenu) 361 for name, mode in ((_('&Visible'), True), 362 (_('&Invisible'), False)): 363 a = vsmenu.addAction(name) 364 a.setCheckable(True) 365 a.setChecked(self.eolVisibility() == mode) 366 a.setData(mode) 367 368 if not self.isReadOnly(): 369 eolmodemenu = menu.addMenu(_('EOL &Mode')) 370 eolmodemenu.triggered.connect(self._setEolModeByMenu) 371 for name, mode in ((_('&Windows'), qsci.EolWindows), 372 (_('&Unix'), qsci.EolUnix), 373 (_('&Mac'), qsci.EolMac)): 374 a = eolmodemenu.addAction(name) 375 a.setCheckable(True) 376 a.setChecked(self.eolMode() == mode) 377 a.setData(mode) 378 379 menu.addSeparator() 380 a = menu.addAction(_('&Auto-Complete')) 381 a.triggered.connect(self._setAutoCompletionEnabled) 382 a.setCheckable(True) 383 a.setChecked(self.autoCompletionThreshold() > 0) 384 385 def saveSettings(self, qs, prefix): 386 qs.setValue(prefix+'/wrap', self.wrapMode()) 387 qs.setValue(prefix+'/whitespace', self.whitespaceVisibility()) 388 qs.setValue(prefix+'/eol', self.eolVisibility()) 389 if self.autoUseTabs: 390 qs.setValue(prefix+'/usetabs', -1) 391 else: 392 qs.setValue(prefix+'/usetabs', self.indentationsUseTabs()) 393 qs.setValue(prefix+'/autocomplete', self.autoCompletionThreshold()) 394 395 def loadSettings(self, qs, prefix): 396 self.setWrapMode(qtlib.readInt(qs, prefix + '/wrap')) 397 self.setWhitespaceVisibility(qtlib.readInt(qs, prefix + '/whitespace')) 398 self.setEolVisibility(qtlib.readBool(qs, prefix + '/eol')) 399 # usetabs = -1, False, or True 400 usetabs = qtlib.readInt(qs, prefix + '/usetabs') 401 if usetabs != -1: 402 usetabs = qtlib.readBool(qs, prefix + '/usetabs') 403 self.setIndentationsUseTabs(usetabs) 404 self.setDefaultEolMode() 405 self.setAutoCompletionThreshold( 406 qtlib.readInt(qs, prefix + '/autocomplete', -1)) 407 408 409 @pyqtSlot(str, bool, bool, bool) 410 def find(self, exp, icase=True, wrap=False, forward=True): 411 """Find the next/prev occurence; returns True if found 412 413 This method tries to imitate the behavior of QTextEdit.find(), 414 unlike combo of QsciScintilla.findFirst() and findNext(). 415 """ 416 cond = (exp, True, not icase, False, wrap, forward) 417 if cond == self.__findcond: 418 return self.findNext() 419 else: 420 self.__findcond = cond 421 return self.findFirst(*cond) 422 423 @pyqtSlot() 424 def _resetfindcond(self): 425 self.__findcond = () 426 427 @pyqtSlot(str, bool) 428 def highlightText(self, match, icase=False): 429 """Highlight text matching to the given regexp pattern [unicode] 430 431 The previous highlight is cleared automatically. 432 """ 433 try: 434 flags = 0 435 if icase: 436 flags |= re.IGNORECASE 437 pat = re.compile(pycompat.unicode(match).encode('utf-8'), flags) 438 except re.error: 439 return # it could be partial pattern while user typing 440 441 self.clearHighlightText() 442 self.SendScintilla(self.SCI_SETINDICATORCURRENT, _HIGHLIGHT_INDIC_ID) 443 444 if len(match) == 0: 445 return 446 447 # NOTE: pat and target text are *not* unicode because scintilla 448 # requires positions in byte. For accuracy, it should do pattern 449 # match in unicode, then calculating byte length of substring:: 450 # 451 # text = unicode(self.text()) 452 # for m in pat.finditer(text): 453 # p = len(text[:m.start()].encode('utf-8')) 454 # self.SendScintilla(self.SCI_INDICATORFILLRANGE, 455 # p, len(m.group(0).encode('utf-8'))) 456 # 457 # but it doesn't to avoid possible performance issue. 458 for m in pat.finditer(pycompat.unicode(self.text()).encode('utf-8')): 459 self.SendScintilla(self.SCI_INDICATORFILLRANGE, 460 m.start(), m.end() - m.start()) 461 line = self.lineIndexFromPosition(m.start())[0] 462 self.highlightLines.add(line) 463 464 @pyqtSlot() 465 def clearHighlightText(self): 466 self.SendScintilla(self.SCI_SETINDICATORCURRENT, _HIGHLIGHT_INDIC_ID) 467 self.SendScintilla(self.SCI_INDICATORCLEARRANGE, 0, self.length()) 468 self.highlightLines.clear() 469 470 def _setupHighlightIndicator(self): 471 id = _HIGHLIGHT_INDIC_ID 472 self.SendScintilla(self.SCI_INDICSETSTYLE, id, self.INDIC_ROUNDBOX) 473 self.SendScintilla(self.SCI_INDICSETUNDER, id, True) 474 self.SendScintilla(self.SCI_INDICSETFORE, id, 0x00ffff) # 0xbbggrr 475 # alpha range is 0 to 255, but old Scintilla rejects value > 100 476 self.SendScintilla(self.SCI_INDICSETALPHA, id, 100) 477 478 def showHScrollBar(self, show=True): 479 self.SendScintilla(self.SCI_SETHSCROLLBAR, show) 480 481 def setDefaultEolMode(self): 482 if self.lines(): 483 mode = qsciEolModeFromLine(pycompat.unicode(self.text(0))) 484 else: 485 mode = qsciEolModeFromOs() 486 self.setEolMode(mode) 487 return mode 488 489 @pyqtSlot(QAction) 490 def _setWrapModeByMenu(self, action): 491 mode = action.data() 492 self.setWrapMode(mode) 493 494 @pyqtSlot(QAction) 495 def _setWhitespaceVisibilityByMenu(self, action): 496 mode = action.data() 497 self.setWhitespaceVisibility(mode) 498 499 @pyqtSlot(QAction) 500 def _setEolVisibilityByMenu(self, action): 501 visible = action.data() 502 self.setEolVisibility(visible) 503 504 @pyqtSlot(QAction) 505 def _setEolModeByMenu(self, action): 506 mode = action.data() 507 self.setEolMode(mode) 508 509 @pyqtSlot(QAction) 510 def _setIndentationsUseTabsByMenu(self, action): 511 mode = action.data() 512 self.setIndentationsUseTabs(mode) 513 514 def setIndentationsUseTabs(self, tabs): 515 self.autoUseTabs = (tabs == -1) 516 if self.autoUseTabs and self.lines(): 517 tabs = findTabIndentsInLines(self.text().splitlines()) 518 super(Scintilla, self).setIndentationsUseTabs(tabs) 519 520 @pyqtSlot(bool) 521 def _setAutoCompletionEnabled(self, enabled): 522 self.setAutoCompletionThreshold(enabled and 2 or -1) 523 524 def lineNearPoint(self, point): 525 """Return the closest line to the pixel position; similar to lineAt(), 526 but returns valid line number even if no character fount at point""" 527 # lineAt() uses the strict request, SCI_POSITIONFROMPOINTCLOSE 528 chpos = self.SendScintilla(self.SCI_POSITIONFROMPOINT, 529 # no implicit cast to ulong in old QScintilla 530 # unsigned long wParam, long lParam 531 max(point.x(), 0), point.y()) 532 return self.SendScintilla(self.SCI_LINEFROMPOSITION, chpos) 533 534 535class SearchToolBar(QToolBar): 536 conditionChanged = pyqtSignal(str, bool, bool) 537 """Emitted (pattern, icase, wrap) when search condition changed""" 538 539 searchRequested = pyqtSignal(str, bool, bool, bool) 540 """Emitted (pattern, icase, wrap, forward) when requested""" 541 542 def __init__(self, parent=None): 543 super(SearchToolBar, self).__init__(_('Search'), parent, 544 objectName='search') 545 self.setIconSize(qtlib.smallIconSize()) 546 547 a = self.addAction(qtlib.geticon('window-close'), '') 548 a.setShortcut(Qt.Key_Escape) 549 a.setShortcutContext(Qt.WidgetWithChildrenShortcut) 550 a.triggered.connect(self.hide) 551 self.addWidget(qtlib.Spacer(2, 2)) 552 553 self._le = QLineEdit() 554 self._le.setPlaceholderText(_('### regular expression ###')) 555 self._le.returnPressed.connect(self._emitSearchRequested) 556 self.addWidget(self._le) 557 self.addWidget(qtlib.Spacer(4, 4)) 558 self._chk = QCheckBox(_('Ignore case')) 559 self.addWidget(self._chk) 560 self._wrapchk = QCheckBox(_('Wrap search')) 561 self.addWidget(self._wrapchk) 562 563 self._prevact = self.addAction(qtlib.geticon('go-up'), _('Prev')) 564 self._prevact.setShortcuts(QKeySequence.FindPrevious) 565 self._nextact = self.addAction(qtlib.geticon('go-down'), _('Next')) 566 self._nextact.setShortcuts(QKeySequence.FindNext) 567 for a in [self._prevact, self._nextact]: 568 a.setShortcutContext(Qt.WidgetWithChildrenShortcut) 569 a.triggered.connect(self._emitSearchRequested) 570 w = self.widgetForAction(a) 571 w.setAutoRaise(False) # no flat button 572 w.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) 573 574 self._le.textChanged.connect(self._updateSearchButtons) 575 576 self.setFocusProxy(self._le) 577 self.setStyleSheet(qtlib.tbstylesheet) 578 579 self._settings = QSettings() 580 self._settings.beginGroup('searchtoolbar') 581 self.searchRequested.connect(self._writesettings) 582 self._readsettings() 583 584 self._le.textChanged.connect(self._emitConditionChanged) 585 self._chk.toggled.connect(self._emitConditionChanged) 586 self._wrapchk.toggled.connect(self._emitConditionChanged) 587 588 self._updateSearchButtons() 589 590 def keyPressEvent(self, event): 591 if event.key() in (Qt.Key_Enter, Qt.Key_Return): 592 return # handled by returnPressed 593 super(SearchToolBar, self).keyPressEvent(event) 594 595 def wheelEvent(self, event): 596 if QT_VERSION >= 0x50000: 597 d = event.angleDelta().y() 598 else: 599 d = event.delta() 600 if d > 0: 601 self._prevact.trigger() 602 return 603 if d < 0: 604 self._nextact.trigger() 605 return 606 super(SearchToolBar, self).wheelEvent(event) 607 608 def setVisible(self, visible=True): 609 super(SearchToolBar, self).setVisible(visible) 610 if visible: 611 self._le.setFocus() 612 self._le.selectAll() 613 614 def _readsettings(self): 615 self.setCaseInsensitive(qtlib.readBool(self._settings, 'icase', False)) 616 self.setWrapAround(qtlib.readBool(self._settings, 'wrap', False)) 617 618 @pyqtSlot() 619 def _writesettings(self): 620 self._settings.setValue('icase', self.caseInsensitive()) 621 self._settings.setValue('wrap', self.wrapAround()) 622 623 @pyqtSlot() 624 def _emitConditionChanged(self): 625 self.conditionChanged.emit(self.pattern(), self.caseInsensitive(), 626 self.wrapAround()) 627 628 @pyqtSlot() 629 def _emitSearchRequested(self): 630 forward = self.sender() is not self._prevact 631 self.searchRequested.emit(self.pattern(), self.caseInsensitive(), 632 self.wrapAround(), forward) 633 634 def editorActions(self): 635 """List of actions that should be available in main editor widget""" 636 return [self._prevact, self._nextact] 637 638 @pyqtSlot() 639 def _updateSearchButtons(self): 640 enabled = bool(self._le.text()) 641 for a in [self._prevact, self._nextact]: 642 a.setEnabled(enabled) 643 644 def pattern(self): 645 """Returns the current search pattern [unicode]""" 646 return self._le.text() 647 648 def setPattern(self, text): 649 """Set the search pattern [unicode]""" 650 self._le.setText(text) 651 652 def caseInsensitive(self): 653 """True if case-insensitive search is requested""" 654 return self._chk.isChecked() 655 656 def setCaseInsensitive(self, icase): 657 self._chk.setChecked(icase) 658 659 def wrapAround(self): 660 """True if wrap search is requested""" 661 return self._wrapchk.isChecked() 662 663 def setWrapAround(self, wrap): 664 self._wrapchk.setChecked(wrap) 665 666 @pyqtSlot(str) 667 def search(self, text): 668 """Request search with the given pattern""" 669 self.setPattern(text) 670 self._emitSearchRequested() 671 672class KeyPressInterceptor(QObject): 673 """Grab key press events important for dialogs 674 675 Usage:: 676 sci = qscilib.Scintilla(self) 677 sci.installEventFilter(KeyPressInterceptor(self)) 678 """ 679 680 def __init__(self, parent=None, keys=None, keyseqs=None): 681 super(KeyPressInterceptor, self).__init__(parent) 682 self._keys = {Qt.Key_Escape} 683 self._keyseqs = [QKeySequence.Refresh] 684 if keys: 685 self._keys.update(keys) 686 if keyseqs: 687 self._keyseqs.extend(keyseqs) 688 689 def eventFilter(self, watched, event): 690 if event.type() != QEvent.KeyPress: 691 return super(KeyPressInterceptor, self).eventFilter( 692 watched, event) 693 if self._isinterceptable(event): 694 event.ignore() 695 return True 696 return False 697 698 def _isinterceptable(self, event): 699 if event.key() in self._keys: 700 return True 701 if any(event.matches(e) for e in self._keyseqs): 702 return True 703 return False 704 705def unbindConflictedKeys(sci): 706 cmdset = sci.standardCommands() 707 try: 708 cmd = cmdset.boundTo(Qt.CTRL + Qt.Key_L) 709 if cmd: 710 cmd.setKey(0) 711 except AttributeError: # old QScintilla does not have boundTo() 712 pass 713 714def qsciEolModeFromOs(): 715 if os.name.startswith('nt'): 716 return QsciScintilla.EolWindows 717 else: 718 return QsciScintilla.EolUnix 719 720def qsciEolModeFromLine(line): 721 if line.endswith('\r\n'): 722 return QsciScintilla.EolWindows 723 elif line.endswith('\r'): 724 return QsciScintilla.EolMac 725 elif line.endswith('\n'): 726 return QsciScintilla.EolUnix 727 else: 728 return qsciEolModeFromOs() 729 730def findTabIndentsInLines(lines, linestocheck=100): 731 for line in lines[:linestocheck]: 732 if line.startswith(' '): 733 return False 734 elif line.startswith('\t'): 735 return True 736 return False # Use spaces for indents default 737 738def readFile(editor, filename, encoding=None): 739 f = QFile(filename) 740 if not f.open(QIODevice.ReadOnly): 741 qtlib.WarningMsgBox(_('Unable to read file'), 742 _('Could not open the specified file for reading.'), 743 f.errorString(), parent=editor) 744 return False 745 try: 746 earlybytes = f.read(4096) 747 if b'\0' in earlybytes: 748 qtlib.WarningMsgBox(_('Unable to read file'), 749 _('This appears to be a binary file.'), 750 parent=editor) 751 return False 752 753 f.seek(0) 754 data = bytes(f.readAll()) 755 if f.error(): 756 qtlib.WarningMsgBox(_('Unable to read file'), 757 _('An error occurred while reading the file.'), 758 f.errorString(), parent=editor) 759 return False 760 finally: 761 f.close() 762 763 if encoding: 764 try: 765 text = data.decode(encoding) 766 except UnicodeDecodeError as inst: 767 qtlib.WarningMsgBox(_('Text Translation Failure'), 768 _('Could not translate the file content from ' 769 'native encoding.'), 770 (_('Several characters would be lost.') 771 + '\n\n' + hglib.tounicode(str(inst))), 772 parent=editor) 773 text = data.decode(encoding, 'replace') 774 else: 775 text = hglib.tounicode(data) 776 editor.setText(text) 777 editor.setDefaultEolMode() 778 editor.setModified(False) 779 return True 780 781def writeFile(editor, filename, encoding=None): 782 text = editor.text() 783 try: 784 if encoding: 785 data = pycompat.unicode(text).encode(encoding) 786 else: 787 data = hglib.fromunicode(text) 788 except UnicodeEncodeError as inst: 789 qtlib.WarningMsgBox(_('Unable to write file'), 790 _('Could not translate the file content to ' 791 'native encoding.'), 792 hglib.tounicode(str(inst)), parent=editor) 793 return False 794 795 f = QFile(filename) 796 if not f.open(QIODevice.WriteOnly): 797 qtlib.WarningMsgBox(_('Unable to write file'), 798 _('Could not open the specified file for writing.'), 799 f.errorString(), parent=editor) 800 return False 801 try: 802 if f.write(data) < 0: 803 qtlib.WarningMsgBox(_('Unable to write file'), 804 _('An error occurred while writing the file.'), 805 f.errorString(), parent=editor) 806 return False 807 finally: 808 f.close() 809 return True 810 811def fileEditor(filename, **opts): 812 'Open a simple modal file editing dialog' 813 dialog = QDialog() 814 dialog.setWindowFlags(dialog.windowFlags() 815 & ~Qt.WindowContextHelpButtonHint 816 | Qt.WindowMaximizeButtonHint) 817 dialog.setWindowTitle(filename) 818 dialog.setLayout(QVBoxLayout()) 819 editor = Scintilla() 820 editor.setBraceMatching(QsciScintilla.SloppyBraceMatch) 821 editor.installEventFilter(KeyPressInterceptor(dialog)) 822 editor.setMarginLineNumbers(1, True) 823 editor.setMarginWidth(1, '000') 824 825 lexer = QsciLexerProperties() 826 lexer.setFont(QFont('Monospace', 10), -1) 827 828 editor.setLexer(lexer) 829 830 if opts.get('foldable'): 831 editor.setFolding(QsciScintilla.BoxedTreeFoldStyle) 832 dialog.layout().addWidget(editor) 833 834 searchbar = SearchToolBar(dialog) 835 searchbar.searchRequested.connect(editor.find) 836 searchbar.conditionChanged.connect(editor.highlightText) 837 searchbar.hide() 838 def showsearchbar(): 839 text = editor.selectedText() 840 if text: 841 searchbar.setPattern(text) 842 searchbar.show() 843 searchbar.setFocus(Qt.OtherFocusReason) 844 qtlib.newshortcutsforstdkey(QKeySequence.Find, dialog, showsearchbar) 845 dialog.addActions(searchbar.editorActions()) 846 dialog.layout().addWidget(searchbar) 847 848 BB = QDialogButtonBox 849 bb = QDialogButtonBox(BB.Save|BB.Cancel) 850 bb.accepted.connect(dialog.accept) 851 bb.rejected.connect(dialog.reject) 852 dialog.layout().addWidget(bb) 853 854 s = QSettings() 855 geomname = 'editor-geom' 856 desktopgeom = qApp.desktop().availableGeometry() 857 dialog.resize(desktopgeom.size() * 0.5) 858 dialog.restoreGeometry(qtlib.readByteArray(s, geomname)) 859 860 if not readFile(editor, filename): 861 return QDialog.Rejected 862 ret = dialog.exec_() 863 if ret != QDialog.Accepted: 864 return ret 865 if not writeFile(editor, filename): 866 return QDialog.Rejected 867 s.setValue(geomname, dialog.saveGeometry()) 868 return ret 869