widgets: consolidate context menu handling for text widgets
[git-cola.git] / cola / widgets / spellcheck.py
blob936099fb323a64efc90810338f196e5ef84d882c
1 from __future__ import absolute_import, division, print_function, unicode_literals
2 import re
4 from qtpy import QtCore
5 from qtpy import QtGui
6 from qtpy import QtWidgets
7 from qtpy.QtCore import Qt
9 from .. import qtutils
10 from .. import spellcheck
11 from ..i18n import N_
12 from .text import HintedTextEdit
15 # pylint: disable=too-many-ancestors
16 class SpellCheckTextEdit(HintedTextEdit):
17 def __init__(self, context, hint, check=None, parent=None):
18 HintedTextEdit.__init__(self, context, hint, parent)
20 # Default dictionary based on the current locale.
21 self.spellcheck = check or spellcheck.NorvigSpellCheck()
22 self.highlighter = Highlighter(self.document(), self.spellcheck)
24 def mousePressEvent(self, event):
25 if event.button() == Qt.RightButton:
26 # Rewrite the mouse event to a left button event so the cursor is
27 # moved to the location of the pointer.
28 event = QtGui.QMouseEvent(
29 QtCore.QEvent.MouseButtonPress,
30 event.pos(),
31 Qt.LeftButton,
32 Qt.LeftButton,
33 Qt.NoModifier,
35 HintedTextEdit.mousePressEvent(self, event)
37 def create_context_menu(self, event_pos):
38 popup_menu = super(SpellCheckTextEdit, self).create_context_menu(event_pos)
40 # Check if the selected word is misspelled and offer spelling
41 # suggestions if it is.
42 spell_menu = None
43 if self.textCursor().hasSelection():
44 text = self.textCursor().selectedText()
45 if not self.spellcheck.check(text):
46 title = N_('Spelling Suggestions')
47 spell_menu = qtutils.create_menu(title, self)
48 for word in self.spellcheck.suggest(text):
49 action = SpellAction(word, spell_menu)
50 action.result.connect(self.correct)
51 spell_menu.addAction(action)
52 # Only add the spelling suggests to the menu if there are
53 # suggestions.
54 if spell_menu.actions():
55 popup_menu.addSeparator()
56 popup_menu.addMenu(spell_menu)
58 return popup_menu
60 def contextMenuEvent(self, event):
61 """Select the current word and then show a context menu"""
62 # Select the word under the cursor before calling the default contextMenuEvent.
63 cursor = self.textCursor()
64 cursor.select(QtGui.QTextCursor.WordUnderCursor)
65 self.setTextCursor(cursor)
66 super(SpellCheckTextEdit, self).contextMenuEvent(event)
68 def correct(self, word):
69 """Replaces the selected text with word."""
70 cursor = self.textCursor()
71 cursor.beginEditBlock()
73 cursor.removeSelectedText()
74 cursor.insertText(word)
76 cursor.endEditBlock()
79 class SpellCheckLineEdit(SpellCheckTextEdit):
80 """A fake QLineEdit that provides spellcheck capabilities
82 This class emulates QLineEdit using our QPlainTextEdit base class
83 so that we can leverage the existing spellcheck feature.
85 """
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(SpellCheckLineEdit, self).__init__(
92 context, hint, check=check, parent=parent
94 self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
95 self.setLineWrapMode(QtWidgets.QTextEdit.NoWrap)
96 self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
97 self.setWordWrapMode(QtGui.QTextOption.NoWrap)
98 self.setTabChangesFocus(True)
99 self.textChanged.connect(self._trim_changed_text_lines, Qt.QueuedConnection)
101 def focusInEvent(self, event):
102 """Select text when entering with a tab to mimic QLineEdit"""
103 super(SpellCheckLineEdit, self).focusInEvent(event)
105 if event.reason() in (
106 Qt.BacktabFocusReason,
107 Qt.ShortcutFocusReason,
108 Qt.TabFocusReason,
110 self.selectAll()
112 def focusOutEvent(self, event):
113 """De-select text when exiting with tab to mimic QLineEdit"""
114 super(SpellCheckLineEdit, self).focusOutEvent(event)
116 if event.reason() in (
117 Qt.BacktabFocusReason,
118 Qt.MouseFocusReason,
119 Qt.ShortcutFocusReason,
120 Qt.TabFocusReason,
122 cur = self.textCursor()
123 cur.movePosition(QtGui.QTextCursor.End)
124 self.setTextCursor(cur)
126 def keyPressEvent(self, event):
127 """Handle the up/down arrow keys"""
128 event_key = event.key()
129 if event_key == Qt.Key_Up:
130 cursor = self.textCursor()
131 if cursor.position() == 0:
132 cursor.clearSelection()
133 else:
134 if event.modifiers() & Qt.ShiftModifier:
135 mode = QtGui.QTextCursor.KeepAnchor
136 else:
137 mode = QtGui.QTextCursor.MoveAnchor
138 cursor.setPosition(0, mode)
139 self.setTextCursor(cursor)
140 return
142 if event_key == Qt.Key_Down:
143 cursor = self.textCursor()
144 cur_position = cursor.position()
145 end_position = len(self.value())
146 if cur_position == end_position:
147 cursor.clearSelection()
148 self.setTextCursor(cursor)
149 self.down_pressed.emit()
150 else:
151 if event.modifiers() & Qt.ShiftModifier:
152 mode = QtGui.QTextCursor.KeepAnchor
153 else:
154 mode = QtGui.QTextCursor.MoveAnchor
155 cursor.setPosition(end_position, mode)
156 self.setTextCursor(cursor)
157 return
158 super(SpellCheckLineEdit, self).keyPressEvent(event)
160 def minimumSizeHint(self):
161 """Match QLineEdit's size behavior"""
162 block_fmt = self.document().firstBlock().blockFormat()
163 width = super(SpellCheckLineEdit, self).minimumSizeHint().width()
164 height = int(
165 QtGui.QFontMetricsF(self.font()).lineSpacing() +
166 block_fmt.topMargin() +
167 block_fmt.bottomMargin() +
168 self.document().documentMargin() +
169 2 * self.frameWidth()
172 style_opts = QtWidgets.QStyleOptionFrame()
173 style_opts.initFrom(self)
174 style_opts.lineWidth = self.frameWidth()
176 return self.style().sizeFromContents(
177 QtWidgets.QStyle.CT_LineEdit,
178 style_opts,
179 QtCore.QSize(width, height),
180 self
183 def sizeHint(self):
184 """Use the minimum size as the sizeHint()"""
185 return self.minimumSizeHint()
187 def _trim_changed_text_lines(self):
188 """Trim the document to a single line to enforce a maximum of line line"""
189 # self.setMaximumBlockCount(1) Undo/Redo.
190 if self.document().blockCount() > 1:
191 self.document().setPlainText(self.document().firstBlock().text())
194 class Highlighter(QtGui.QSyntaxHighlighter):
196 WORDS = r"(?iu)[\w']+"
198 def __init__(self, doc, spellcheck_widget):
199 QtGui.QSyntaxHighlighter.__init__(self, doc)
200 self.spellcheck = spellcheck_widget
201 self.enabled = False
203 def enable(self, enabled):
204 self.enabled = enabled
205 self.rehighlight()
207 def highlightBlock(self, text):
208 if not self.enabled:
209 return
210 fmt = QtGui.QTextCharFormat()
211 fmt.setUnderlineColor(Qt.red)
212 fmt.setUnderlineStyle(QtGui.QTextCharFormat.SpellCheckUnderline)
214 for word_object in re.finditer(self.WORDS, text):
215 if not self.spellcheck.check(word_object.group()):
216 self.setFormat(
217 word_object.start(), word_object.end() - word_object.start(), fmt
221 class SpellAction(QtWidgets.QAction):
222 """QAction that returns the text in a signal."""
224 result = QtCore.Signal(object)
226 def __init__(self, *args):
227 QtWidgets.QAction.__init__(self, *args)
228 # pylint: disable=no-member
229 self.triggered.connect(self.correct)
231 def correct(self):
232 self.result.emit(self.text())