PageRenderTime 146ms CodeModel.GetById 107ms app.highlight 33ms RepoModel.GetById 1ms app.codeStats 1ms

/tortoisehg/hgqt/qscilib.py

https://bitbucket.org/tortoisehg/hgtk/
Python | 495 lines | 363 code | 64 blank | 68 comment | 50 complexity | 6b4305489a7540dde3f997d8e89b247c MD5 | raw file
  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
  9import re
 10
 11from mercurial import util
 12
 13from tortoisehg.util import hglib
 14from tortoisehg.hgqt import qtlib
 15from tortoisehg.hgqt.i18n import _
 16
 17from PyQt4.QtCore import *
 18from PyQt4.QtGui import *
 19from PyQt4.Qsci import *
 20
 21class _SciImSupport(object):
 22    """Patch for QsciScintilla to implement improved input method support
 23
 24    See http://doc.trolltech.com/4.7/qinputmethodevent.html
 25    """
 26
 27    PREEDIT_INDIC_ID = QsciScintilla.INDIC_MAX
 28    """indicator for highlighting preedit text"""
 29
 30    def __init__(self, sci):
 31        self._sci = sci
 32        self._preeditpos = (0, 0)  # (line, index) where preedit text starts
 33        self._preeditlen = 0
 34        self._preeditcursorpos = 0  # relative pos where preedit cursor exists
 35        self._undoactionbegun = False
 36        self._setuppreeditindic()
 37
 38    def removepreedit(self):
 39        """Remove the previous preedit text
 40
 41        original pos: preedit cursor
 42        final pos: target cursor
 43        """
 44        l, i = self._sci.getCursorPosition()
 45        i -= self._preeditcursorpos
 46        self._preeditcursorpos = 0
 47        try:
 48            self._sci.setSelection(
 49                self._preeditpos[0], self._preeditpos[1],
 50                self._preeditpos[0], self._preeditpos[1] + self._preeditlen)
 51            self._sci.removeSelectedText()
 52        finally:
 53            self._sci.setCursorPosition(l, i)
 54
 55    def commitstr(self, start, repllen, commitstr):
 56        """Remove the repl string followed by insertion of the commit string
 57
 58        original pos: target cursor
 59        final pos: end of committed text (= start of preedit text)
 60        """
 61        l, i = self._sci.getCursorPosition()
 62        i += start
 63        self._sci.setSelection(l, i, l, i + repllen)
 64        self._sci.removeSelectedText()
 65        self._sci.insert(commitstr)
 66        self._sci.setCursorPosition(l, i + len(commitstr))
 67        if commitstr:
 68            self.endundo()
 69
 70    def insertpreedit(self, text):
 71        """Insert preedit text
 72
 73        original pos: start of preedit text
 74        final pos: start of preedit text (unchanged)
 75        """
 76        if text and not self._preeditlen:
 77            self.beginundo()
 78        l, i = self._sci.getCursorPosition()
 79        self._sci.insert(text)
 80        self._updatepreeditpos(l, i, len(text))
 81        if not self._preeditlen:
 82            self.endundo()
 83
 84    def movepreeditcursor(self, pos):
 85        """Move the cursor to the relative pos inside preedit text"""
 86        self._preeditcursorpos = pos
 87        l, i = self._preeditpos
 88        self._sci.setCursorPosition(l, i + self._preeditcursorpos)
 89
 90    def beginundo(self):
 91        if self._undoactionbegun:
 92            return
 93        self._sci.beginUndoAction()
 94        self._undoactionbegun = True
 95
 96    def endundo(self):
 97        if not self._undoactionbegun:
 98            return
 99        self._sci.endUndoAction()
100        self._undoactionbegun = False
101
102    def _updatepreeditpos(self, l, i, len):
103        """Update the indicator and internal state for preedit text"""
104        self._sci.SendScintilla(QsciScintilla.SCI_SETINDICATORCURRENT,
105                                self.PREEDIT_INDIC_ID)
106        self._preeditpos = (l, i)
107        self._preeditlen = len
108        if len <= 0:  # have problem on sci
109            return
110        p = self._sci.positionFromLineIndex(*self._preeditpos)
111        q = self._sci.positionFromLineIndex(self._preeditpos[0],
112                                            self._preeditpos[1] + len)
113        self._sci.SendScintilla(QsciScintilla.SCI_INDICATORFILLRANGE,
114                                p, q - p)  # q - p != len
115
116    def _setuppreeditindic(self):
117        """Configure the style of preedit text indicator"""
118        self._sci.SendScintilla(QsciScintilla.SCI_INDICSETSTYLE,
119                                self.PREEDIT_INDIC_ID,
120                                QsciScintilla.INDIC_PLAIN)
121
122class Scintilla(QsciScintilla):
123
124    _stdMenu = None
125
126    def __init__(self, parent=None):
127        super(Scintilla, self).__init__(parent)
128        self.setUtf8(True)
129        self.textChanged.connect(self._resetfindcond)
130        self._resetfindcond()
131
132    def inputMethodQuery(self, query):
133        if query == Qt.ImMicroFocus:
134            return self.cursorRect()
135        return super(Scintilla, self).inputMethodQuery(query)
136
137    def inputMethodEvent(self, event):
138        if self.isReadOnly():
139            return
140
141        self.removeSelectedText()
142        self._imsupport.removepreedit()
143        self._imsupport.commitstr(event.replacementStart(),
144                                  event.replacementLength(),
145                                  event.commitString())
146        self._imsupport.insertpreedit(event.preeditString())
147        for a in event.attributes():
148            if a.type == QInputMethodEvent.Cursor:
149                self._imsupport.movepreeditcursor(a.start)
150            # TODO TextFormat
151
152        event.accept()
153
154    @util.propertycache
155    def _imsupport(self):
156        return _SciImSupport(self)
157
158    def cursorRect(self):
159        """Return a rectangle (in viewport coords) including the cursor"""
160        l, i = self.getCursorPosition()
161        p = self.positionFromLineIndex(l, i)
162        x = self.SendScintilla(QsciScintilla.SCI_POINTXFROMPOSITION, 0, p)
163        y = self.SendScintilla(QsciScintilla.SCI_POINTYFROMPOSITION, 0, p)
164        w = self.SendScintilla(QsciScintilla.SCI_GETCARETWIDTH)
165        return QRect(x, y, w, self.textHeight(l))
166
167    def createStandardContextMenu(self):
168        """Create standard context menu"""
169        if not self._stdMenu:
170            self._stdMenu = QMenu(self)
171        else:
172            self._stdMenu.clear()
173        if not self.isReadOnly():
174            a = self._stdMenu.addAction(_('Undo'), self.undo, QKeySequence.Undo)
175            a.setEnabled(self.isUndoAvailable())
176            a = self._stdMenu.addAction(_('Redo'), self.redo, QKeySequence.Redo)
177            a.setEnabled(self.isRedoAvailable())
178            self._stdMenu.addSeparator()
179            a = self._stdMenu.addAction(_('Cut'), self.cut, QKeySequence.Cut)
180            a.setEnabled(self.hasSelectedText())
181        a = self._stdMenu.addAction(_('Copy'), self.copy, QKeySequence.Copy)
182        a.setEnabled(self.hasSelectedText())
183        if not self.isReadOnly():
184            self._stdMenu.addAction(_('Paste'), self.paste, QKeySequence.Paste)
185            a = self._stdMenu.addAction(_('Delete'), self.removeSelectedText,
186                               QKeySequence.Delete)
187            a.setEnabled(self.hasSelectedText())
188        self._stdMenu.addSeparator()
189        self._stdMenu.addAction(_('Select All'),
190                                self.selectAll, QKeySequence.SelectAll)
191        self._stdMenu.addSeparator()
192        qsci = QsciScintilla
193        wrapmenu = QMenu(_('Wrap'), self)
194        for name, mode in ((_('None'), qsci.WrapNone),
195                           (_('Word'), qsci.WrapWord),
196                           (_('Character'), qsci.WrapCharacter)):
197            def mkaction(n, m):
198                a = wrapmenu.addAction(n)
199                a.triggered.connect(lambda: self.setWrapMode(m))
200            mkaction(name, mode)
201        wsmenu = QMenu(_('Whitespace'), self)
202        for name, mode in ((_('Visible'), qsci.WsVisible),
203                           (_('Invisible'), qsci.WsInvisible),
204                           (_('AfterIndent'), qsci.WsVisibleAfterIndent)):
205            def mkaction(n, m):
206                a = wsmenu.addAction(n)
207                a.triggered.connect(lambda: self.setWhitespaceVisibility(m))
208            mkaction(name, mode)
209        self._stdMenu.addMenu(wrapmenu)
210        self._stdMenu.addMenu(wsmenu)
211        return self._stdMenu
212
213    def saveSettings(self, qs, prefix):
214        qs.setValue(prefix+'/wrap', self.wrapMode())
215        qs.setValue(prefix+'/whitespace', self.whitespaceVisibility())
216
217    def loadSettings(self, qs, prefix):
218        self.setWrapMode(qs.value(prefix+'/wrap').toInt()[0])
219        self.setWhitespaceVisibility(qs.value(prefix+'/whitespace').toInt()[0])
220
221    @pyqtSlot(unicode, bool, bool, bool)
222    def find(self, exp, icase=True, wrap=False, forward=True):
223        """Find the next/prev occurence; returns True if found
224
225        This method tries to imitate the behavior of QTextEdit.find(),
226        unlike combo of QsciScintilla.findFirst() and findNext().
227        """
228        cond = (exp, True, not icase, False, wrap, forward)
229        if cond == self.__findcond:
230            return self.findNext()
231        else:
232            self.__findcond = cond
233            return self.findFirst(*cond)
234
235    @pyqtSlot()
236    def _resetfindcond(self):
237        self.__findcond = ()
238
239    @pyqtSlot(unicode, bool)
240    def highlightText(self, match, icase=False):
241        """Highlight text matching to the given regexp pattern [unicode]
242
243        The previous highlight is cleared automatically.
244        """
245        try:
246            flags = 0
247            if icase:
248                flags |= re.IGNORECASE
249            pat = re.compile(unicode(match).encode('utf-8'), flags)
250        except re.error:
251            return  # it could be partial pattern while user typing
252
253        self.clearHighlightText()
254        self.SendScintilla(self.SCI_SETINDICATORCURRENT,
255                           self._highlightIndicator)
256
257        # NOTE: pat and target text are *not* unicode because scintilla
258        # requires positions in byte. For accuracy, it should do pattern
259        # match in unicode, then calculating byte length of substring::
260        #
261        #     text = unicode(self.text())
262        #     for m in pat.finditer(text):
263        #         p = len(text[:m.start()].encode('utf-8'))
264        #         self.SendScintilla(self.SCI_INDICATORFILLRANGE,
265        #             p, len(m.group(0).encode('utf-8')))
266        #
267        # but it doesn't to avoid possible performance issue.
268        for m in pat.finditer(unicode(self.text()).encode('utf-8')):
269            self.SendScintilla(self.SCI_INDICATORFILLRANGE,
270                               m.start(), m.end() - m.start())
271
272    @pyqtSlot()
273    def clearHighlightText(self):
274        self.SendScintilla(self.SCI_SETINDICATORCURRENT,
275                           self._highlightIndicator)
276        self.SendScintilla(self.SCI_INDICATORCLEARRANGE, 0, self.length())
277
278    @util.propertycache
279    def _highlightIndicator(self):
280        """Return indicator number for highlight after initializing it"""
281        id = self._imsupport.PREEDIT_INDIC_ID - 1
282        self.SendScintilla(self.SCI_INDICSETSTYLE, id, self.INDIC_ROUNDBOX)
283        self.SendScintilla(self.SCI_INDICSETUNDER, id, True)
284        self.SendScintilla(self.SCI_INDICSETFORE, id, 0x00ffff) # 0xbbggrr
285        # document says alpha value is 0 to 255, but it looks 0 to 100
286        self.SendScintilla(self.SCI_INDICSETALPHA, id, 100)
287        return id
288
289class SearchToolBar(QToolBar):
290    conditionChanged = pyqtSignal(unicode, bool, bool)
291    """Emitted (pattern, icase, wrap) when search condition changed"""
292
293    searchRequested = pyqtSignal(unicode, bool, bool, bool)
294    """Emitted (pattern, icase, wrap, forward) when requested"""
295
296    def __init__(self, parent=None, hidable=False, settings=None):
297        super(SearchToolBar, self).__init__(_('Search'), parent,
298                                            objectName='search',
299                                            iconSize=QSize(16, 16))
300        if hidable:
301            self._close_button = QToolButton(icon=qtlib.geticon('close'),
302                                             shortcut=Qt.Key_Escape)
303            self._close_button.clicked.connect(self.hide)
304            self.addWidget(self._close_button)
305
306        self._lbl = QLabel(_('Regexp:'),
307                           toolTip=_('Regular expression search pattern'))
308        self.addWidget(self._lbl)
309        self._le = QLineEdit()
310        self._le.returnPressed.connect(self._emitSearchRequested)
311        self._lbl.setBuddy(self._le)
312        self.addWidget(self._le)
313        self._chk = QCheckBox(_('Ignore case'))
314        self.addWidget(self._chk)
315        self._wrapchk = QCheckBox(_('Wrap search'))
316        self.addWidget(self._wrapchk)
317        self._bt = QPushButton(_('Search'), enabled=False)
318        self._bt.clicked.connect(self._emitSearchRequested)
319        self._le.textChanged.connect(lambda s: self._bt.setEnabled(bool(s)))
320        self.addWidget(self._bt)
321
322        self.setFocusProxy(self._le)
323
324        def defaultsettings():
325            s = QSettings()
326            s.beginGroup('searchtoolbar')
327            return s
328        self._settings = settings or defaultsettings()
329        self.searchRequested.connect(self._writesettings)
330        self._readsettings()
331
332        self._le.textChanged.connect(self._emitConditionChanged)
333        self._chk.toggled.connect(self._emitConditionChanged)
334        self._wrapchk.toggled.connect(self._emitConditionChanged)
335
336    def keyPressEvent(self, event):
337        if event.matches(QKeySequence.FindNext):
338            self._emitSearchRequested(forward=True)
339            return
340        if event.matches(QKeySequence.FindPrevious):
341            self._emitSearchRequested(forward=False)
342            return
343        if event.key() in (Qt.Key_Enter, Qt.Key_Return):
344            return  # handled by returnPressed
345        super(SearchToolBar, self).keyPressEvent(event)
346
347    def wheelEvent(self, event):
348        if event.delta() > 0:
349            self._emitSearchRequested(forward=False)
350            return
351        if event.delta() < 0:
352            self._emitSearchRequested(forward=True)
353            return
354        super(SearchToolBar, self).wheelEvent(event)
355
356    def setVisible(self, visible=True):
357        super(SearchToolBar, self).setVisible(visible)
358        if visible:
359            self._le.setFocus()
360            self._le.selectAll()
361
362    def _readsettings(self):
363        self.setCaseInsensitive(self._settings.value('icase', False).toBool())
364        self.setWrapAround(self._settings.value('wrap', False).toBool())
365
366    @pyqtSlot()
367    def _writesettings(self):
368        self._settings.setValue('icase', self.caseInsensitive())
369        self._settings.setValue('wrap', self.wrapAround())
370
371    @pyqtSlot()
372    def _emitConditionChanged(self):
373        self.conditionChanged.emit(self.pattern(), self.caseInsensitive(),
374                                   self.wrapAround())
375
376    @pyqtSlot()
377    def _emitSearchRequested(self, forward=True):
378        self.searchRequested.emit(self.pattern(), self.caseInsensitive(),
379                                  self.wrapAround(), forward)
380
381    def pattern(self):
382        """Returns the current search pattern [unicode]"""
383        return self._le.text()
384
385    def setPattern(self, text):
386        """Set the search pattern [unicode]"""
387        self._le.setText(text)
388
389    def caseInsensitive(self):
390        """True if case-insensitive search is requested"""
391        return self._chk.isChecked()
392
393    def setCaseInsensitive(self, icase):
394        self._chk.setChecked(icase)
395
396    def wrapAround(self):
397        """True if wrap search is requested"""
398        return self._wrapchk.isChecked()
399
400    def setWrapAround(self, wrap):
401        self._wrapchk.setChecked(wrap)
402
403    @pyqtSlot(unicode)
404    def search(self, text):
405        """Request search with the given pattern"""
406        self.setPattern(text)
407        self._emitSearchRequested()
408
409class KeyPressInterceptor(QObject):
410    """Grab key press events important for dialogs
411
412    Usage::
413        sci = qscilib.Scintilla(self)
414        sci.installEventFilter(KeyPressInterceptor(self))
415    """
416
417    def __init__(self, parent=None, keys=None, keyseqs=None):
418        super(KeyPressInterceptor, self).__init__(parent)
419        self._keys = set((Qt.Key_Escape,))
420        self._keyseqs = set((QKeySequence.Refresh,))
421        if keys:
422            self._keys.update(keys)
423        if keyseqs:
424            self._keyseqs.update(keyseqs)
425
426    def eventFilter(self, watched, event):
427        if event.type() != QEvent.KeyPress:
428            return super(KeyPressInterceptor, self).eventFilter(
429                watched, event)
430        if self._isinterceptable(event):
431            event.ignore()
432            return True
433        return False
434
435    def _isinterceptable(self, event):
436        if event.key() in self._keys:
437            return True
438        if util.any(event.matches(e) for e in self._keyseqs):
439            return True
440        return False
441
442def fileEditor(filename, **opts):
443    'Open a simple modal file editing dialog'
444    dialog = QDialog()
445    dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowContextHelpButtonHint)
446    dialog.setWindowTitle(filename)
447    dialog.setLayout(QVBoxLayout())
448    editor = Scintilla()
449    editor.setBraceMatching(QsciScintilla.SloppyBraceMatch)
450    editor.installEventFilter(KeyPressInterceptor(dialog))
451    editor.setMarginLineNumbers(1, True)
452    editor.setMarginWidth(1, '000')
453    editor.setLexer(QsciLexerProperties())
454    if opts.get('foldable'):
455        editor.setFolding(QsciScintilla.BoxedTreeFoldStyle)
456    dialog.layout().addWidget(editor)
457
458    searchbar = SearchToolBar(dialog, hidable=True)
459    searchbar.searchRequested.connect(editor.find)
460    searchbar.conditionChanged.connect(editor.highlightText)
461    searchbar.hide()
462    def showsearchbar():
463        searchbar.show()
464        searchbar.setFocus(Qt.OtherFocusReason)
465    QShortcut(QKeySequence.Find, dialog, showsearchbar)
466    dialog.layout().addWidget(searchbar)
467
468    BB = QDialogButtonBox
469    bb = QDialogButtonBox(BB.Save|BB.Cancel)
470    bb.accepted.connect(dialog.accept)
471    bb.rejected.connect(dialog.reject)
472    dialog.layout().addWidget(bb)
473
474    s = QSettings()
475    geomname = 'editor-geom'
476    desktopgeom = qApp.desktop().availableGeometry()
477    dialog.resize(desktopgeom.size() * 0.5)
478    dialog.restoreGeometry(s.value(geomname).toByteArray())
479
480    ret = QDialog.Rejected
481    try:
482        f = QFile(filename)
483        f.open(QIODevice.ReadOnly)
484        editor.read(f)
485        editor.setModified(False)
486        ret = dialog.exec_()
487        if ret == QDialog.Accepted:
488            f = QFile(filename)
489            f.open(QIODevice.WriteOnly)
490            editor.write(f)
491        s.setValue(geomname, dialog.saveGeometry())
492    except EnvironmentError, e:
493        qtlib.WarningMsgBox(_('Unable to read/write config file'),
494                            hglib.tounicode(str(e)), parent=dialog)
495    return ret