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