/tortoisehg/hgqt/qscilib.py

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