CHANGES: document the vendored qtpy update
[git-cola.git] / cola / widgets / spellcheck.py
blobdaf21a876a6b01809a91cc1073ecd7250755f720
1 import re
3 from qtpy import QtCore
4 from qtpy import QtGui
5 from qtpy import QtWidgets
6 from qtpy.QtCore import Qt
8 from .. import qtutils
9 from .. import spellcheck
10 from ..i18n import N_
11 from .text import HintedTextEdit
14 # pylint: disable=too-many-ancestors
15 class SpellCheckTextEdit(HintedTextEdit):
16 def __init__(self, context, hint, check=None, parent=None):
17 HintedTextEdit.__init__(self, context, hint, parent)
19 # Default dictionary based on the current locale.
20 self.spellcheck = check or spellcheck.NorvigSpellCheck()
21 self.highlighter = Highlighter(self.document(), self.spellcheck)
23 def mousePressEvent(self, event):
24 if event.button() == Qt.RightButton:
25 # Rewrite the mouse event to a left button event so the cursor is
26 # moved to the location of the pointer.
27 event = QtGui.QMouseEvent(
28 QtCore.QEvent.MouseButtonPress,
29 event.pos(),
30 Qt.LeftButton,
31 Qt.LeftButton,
32 Qt.NoModifier,
34 HintedTextEdit.mousePressEvent(self, event)
36 def create_context_menu(self, event_pos):
37 popup_menu = super().create_context_menu(event_pos)
39 # Check if the selected word is misspelled and offer spelling
40 # suggestions if it is.
41 spell_menu = None
42 if self.textCursor().hasSelection():
43 text = self.textCursor().selectedText()
44 if not self.spellcheck.check(text):
45 title = N_('Spelling Suggestions')
46 spell_menu = qtutils.create_menu(title, self)
47 for word in self.spellcheck.suggest(text):
48 action = SpellAction(word, spell_menu)
49 action.result.connect(self.correct)
50 spell_menu.addAction(action)
51 # Only add the spelling suggests to the menu if there are
52 # suggestions.
53 if spell_menu.actions():
54 popup_menu.addSeparator()
55 popup_menu.addMenu(spell_menu)
57 return popup_menu
59 def contextMenuEvent(self, event):
60 """Select the current word and then show a context menu"""
61 # Select the word under the cursor before calling the default contextMenuEvent.
62 cursor = self.textCursor()
63 cursor.select(QtGui.QTextCursor.WordUnderCursor)
64 self.setTextCursor(cursor)
65 super().contextMenuEvent(event)
67 def correct(self, word):
68 """Replaces the selected text with word."""
69 cursor = self.textCursor()
70 cursor.beginEditBlock()
72 cursor.removeSelectedText()
73 cursor.insertText(word)
75 cursor.endEditBlock()
78 class SpellCheckLineEdit(SpellCheckTextEdit):
79 """A fake QLineEdit that provides spellcheck capabilities
81 This class emulates QLineEdit using our QPlainTextEdit base class
82 so that we can leverage the existing spellcheck feature.
84 """
86 down_pressed = QtCore.Signal()
88 # This widget is a single-line QTextEdit as described in
89 # http://blog.ssokolow.com/archives/2022/07/22/a-qlineedit-replacement-with-spell-checking/
90 def __init__(self, context, hint, check=None, parent=None):
91 super().__init__(context, hint, check=check, parent=parent)
92 self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
93 self.setLineWrapMode(QtWidgets.QTextEdit.NoWrap)
94 self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
95 self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
96 self.setWordWrapMode(QtGui.QTextOption.NoWrap)
97 self.setTabChangesFocus(True)
98 self.textChanged.connect(self._trim_changed_text_lines)
100 def focusInEvent(self, event):
101 """Select text when entering with a tab to mimic QLineEdit"""
102 super().focusInEvent(event)
104 if event.reason() in (
105 Qt.BacktabFocusReason,
106 Qt.ShortcutFocusReason,
107 Qt.TabFocusReason,
109 self.selectAll()
111 def focusOutEvent(self, event):
112 """De-select text when exiting with tab to mimic QLineEdit"""
113 super().focusOutEvent(event)
115 if event.reason() in (
116 Qt.BacktabFocusReason,
117 Qt.MouseFocusReason,
118 Qt.ShortcutFocusReason,
119 Qt.TabFocusReason,
121 cur = self.textCursor()
122 cur.movePosition(QtGui.QTextCursor.End)
123 self.setTextCursor(cur)
125 def keyPressEvent(self, event):
126 """Handle the up/down arrow keys"""
127 event_key = event.key()
128 if event_key == Qt.Key_Up:
129 cursor = self.textCursor()
130 if cursor.position() == 0:
131 cursor.clearSelection()
132 else:
133 if event.modifiers() & Qt.ShiftModifier:
134 mode = QtGui.QTextCursor.KeepAnchor
135 else:
136 mode = QtGui.QTextCursor.MoveAnchor
137 cursor.setPosition(0, mode)
138 self.setTextCursor(cursor)
139 return
141 if event_key == Qt.Key_Down:
142 cursor = self.textCursor()
143 cur_position = cursor.position()
144 end_position = len(self.value())
145 if cur_position == end_position:
146 cursor.clearSelection()
147 self.setTextCursor(cursor)
148 self.down_pressed.emit()
149 else:
150 if event.modifiers() & Qt.ShiftModifier:
151 mode = QtGui.QTextCursor.KeepAnchor
152 else:
153 mode = QtGui.QTextCursor.MoveAnchor
154 cursor.setPosition(end_position, mode)
155 self.setTextCursor(cursor)
156 return
157 super().keyPressEvent(event)
159 def minimumSizeHint(self):
160 """Match QLineEdit's size behavior"""
161 block_fmt = self.document().firstBlock().blockFormat()
162 width = super().minimumSizeHint().width()
163 height = int(
164 QtGui.QFontMetricsF(self.font()).lineSpacing()
165 + block_fmt.topMargin()
166 + block_fmt.bottomMargin()
167 + self.document().documentMargin()
168 + 2 * self.frameWidth()
171 style_opts = QtWidgets.QStyleOptionFrame()
172 style_opts.initFrom(self)
173 style_opts.lineWidth = self.frameWidth()
175 return self.style().sizeFromContents(
176 QtWidgets.QStyle.CT_LineEdit, style_opts, QtCore.QSize(width, height), self
179 def sizeHint(self):
180 """Use the minimum size as the sizeHint()"""
181 return self.minimumSizeHint()
183 def _trim_changed_text_lines(self):
184 """Trim the document to a single line to enforce a maximum of one line"""
185 # self.setMaximumBlockCount(1) Undo/Redo.
186 if self.document().blockCount() > 1:
187 self.document().setPlainText(self.document().firstBlock().text())
190 class Highlighter(QtGui.QSyntaxHighlighter):
191 WORDS = r"(?iu)[\w']+"
193 def __init__(self, doc, spellcheck_widget):
194 QtGui.QSyntaxHighlighter.__init__(self, doc)
195 self.spellcheck = spellcheck_widget
196 self.enabled = False
198 def enable(self, enabled):
199 self.enabled = enabled
200 self.rehighlight()
202 def highlightBlock(self, text):
203 if not self.enabled:
204 return
205 fmt = QtGui.QTextCharFormat()
206 fmt.setUnderlineColor(Qt.red)
207 fmt.setUnderlineStyle(QtGui.QTextCharFormat.SpellCheckUnderline)
209 for word_object in re.finditer(self.WORDS, text):
210 if not self.spellcheck.check(word_object.group()):
211 self.setFormat(
212 word_object.start(), word_object.end() - word_object.start(), fmt
216 class SpellAction(QtWidgets.QAction):
217 """QAction that returns the text in a signal."""
219 result = QtCore.Signal(object)
221 def __init__(self, *args):
222 QtWidgets.QAction.__init__(self, *args)
223 # pylint: disable=no-member
224 self.triggered.connect(self.correct)
226 def correct(self):
227 self.result.emit(self.text())