spellcheck: allow the spellcheck object to be externally provided
[git-cola.git] / cola / widgets / commitmsg.py
blob62e9e5c0a5ceee21b24dfb439a4a2ac04ed797cd
1 from __future__ import absolute_import, division, print_function, unicode_literals
2 from functools import partial
4 from qtpy import QtCore
5 from qtpy import QtGui
6 from qtpy import QtWidgets
7 from qtpy.QtCore import Qt
8 from qtpy.QtCore import Signal
10 from .. import actions
11 from .. import cmds
12 from .. import core
13 from .. import gitcmds
14 from .. import hotkeys
15 from .. import icons
16 from .. import textwrap
17 from .. import qtutils
18 from .. import spellcheck
19 from ..interaction import Interaction
20 from ..gitcmds import commit_message_path
21 from ..i18n import N_
22 from ..models import dag
23 from ..models import prefs
24 from ..qtutils import get
25 from ..utils import Group
26 from . import defs
27 from .selectcommits import select_commits
28 from .spellcheck import SpellCheckTextEdit
29 from .text import HintedLineEdit
32 class CommitMessageEditor(QtWidgets.QFrame):
33 cursor_changed = Signal(int, int)
34 down = Signal()
35 up = Signal()
37 def __init__(self, context, parent):
38 QtWidgets.QFrame.__init__(self, parent)
39 cfg = context.cfg
40 self.context = context
41 self.model = model = context.model
42 self.spellcheck_initialized = False
43 self.spellcheck = spellcheck.NorvigSpellCheck()
44 self.spellcheck.set_dictionary(cfg.get('cola.dictionary', None))
46 self._linebreak = None
47 self._textwidth = None
48 self._tabwidth = None
50 # Actions
51 self.signoff_action = qtutils.add_action(
52 self, cmds.SignOff.name(), cmds.run(cmds.SignOff, context), hotkeys.SIGNOFF
54 self.signoff_action.setIcon(icons.style_dialog_apply())
55 self.signoff_action.setToolTip(N_('Sign off on this commit'))
57 self.commit_action = qtutils.add_action(
58 self, N_('Commit@@verb'), self.commit, hotkeys.APPLY
60 self.commit_action.setIcon(icons.commit())
61 self.commit_action.setToolTip(N_('Commit staged changes'))
62 self.clear_action = qtutils.add_action(self, N_('Clear...'), self.clear)
64 self.launch_editor = actions.launch_editor_at_line(context, self)
65 self.launch_difftool = actions.launch_difftool(context, self)
67 self.move_up = actions.move_up(self)
68 self.move_down = actions.move_down(self)
70 # Menu acctions
71 self.menu_actions = menu_actions = [
72 None,
73 self.signoff_action,
74 self.commit_action,
75 None,
76 self.launch_editor,
77 self.launch_difftool,
78 None,
79 self.move_up,
80 self.move_down,
83 # Widgets
84 self.summary = CommitSummaryLineEdit(context)
85 self.summary.setMinimumHeight(defs.tool_button_height)
86 self.summary.menu_actions.extend(menu_actions)
88 self.summary_validator = MessageValidator(context, parent=self.summary)
89 self.summary.setValidator(self.summary_validator)
91 self.description = CommitMessageTextEdit(
92 context, check=self.spellcheck, parent=self
94 self.description.menu_actions.extend(menu_actions)
96 commit_button_tooltip = N_('Commit staged changes\nShortcut: Ctrl+Enter')
97 self.commit_button = qtutils.create_button(
98 text=N_('Commit@@verb'), tooltip=commit_button_tooltip, icon=icons.commit()
100 self.commit_group = Group(self.commit_action, self.commit_button)
102 self.actions_menu = qtutils.create_menu(N_('Actions'), self)
103 self.actions_button = qtutils.create_toolbutton(
104 icon=icons.configure(), tooltip=N_('Actions...')
106 self.actions_button.setMenu(self.actions_menu)
108 self.actions_menu.addAction(self.signoff_action)
109 self.actions_menu.addAction(self.commit_action)
110 self.actions_menu.addSeparator()
112 # Amend checkbox
113 self.amend_action = self.actions_menu.addAction(N_('Amend Last Commit'))
114 self.amend_action.setIcon(icons.edit())
115 self.amend_action.setCheckable(True)
116 self.amend_action.setShortcut(hotkeys.AMEND)
117 self.amend_action.setShortcutContext(Qt.ApplicationShortcut)
119 # Bypass hooks
120 self.bypass_commit_hooks_action = self.actions_menu.addAction(
121 N_('Bypass Commit Hooks')
123 self.bypass_commit_hooks_action.setCheckable(True)
124 self.bypass_commit_hooks_action.setChecked(False)
126 # Sign commits
127 self.sign_action = self.actions_menu.addAction(N_('Create Signed Commit'))
128 self.sign_action.setCheckable(True)
129 signcommits = cfg.get('cola.signcommits', default=False)
130 self.sign_action.setChecked(signcommits)
132 # Spell checker
133 self.check_spelling_action = self.actions_menu.addAction(N_('Check Spelling'))
134 self.check_spelling_action.setCheckable(True)
135 spell_check = prefs.spellcheck(context)
136 self.check_spelling_action.setChecked(spell_check)
137 self.toggle_check_spelling(spell_check)
139 # Line wrapping
140 self.autowrap_action = self.actions_menu.addAction(N_('Auto-Wrap Lines'))
141 self.autowrap_action.setCheckable(True)
142 self.autowrap_action.setChecked(prefs.linebreak(context))
144 # Commit message
145 self.actions_menu.addSeparator()
146 self.load_commitmsg_menu = self.actions_menu.addMenu(
147 N_('Load Previous Commit Message')
149 self.load_commitmsg_menu.aboutToShow.connect(self.build_commitmsg_menu)
151 self.fixup_commit_menu = self.actions_menu.addMenu(N_('Fixup Previous Commit'))
152 self.fixup_commit_menu.aboutToShow.connect(self.build_fixup_menu)
154 self.toplayout = qtutils.hbox(
155 defs.no_margin,
156 defs.spacing,
157 self.actions_button,
158 self.summary,
159 self.commit_button,
161 self.toplayout.setContentsMargins(
162 defs.margin, defs.no_margin, defs.no_margin, defs.no_margin
165 self.mainlayout = qtutils.vbox(
166 defs.no_margin, defs.spacing, self.toplayout, self.description
168 self.setLayout(self.mainlayout)
170 qtutils.connect_button(self.commit_button, self.commit)
172 # Broadcast the amend mode
173 qtutils.connect_action_bool(
174 self.amend_action, partial(cmds.run(cmds.AmendMode), context)
176 qtutils.connect_action_bool(
177 self.check_spelling_action, self.toggle_check_spelling
180 # Handle the one-off autowrapping
181 qtutils.connect_action_bool(self.autowrap_action, self.set_linebreak)
183 qtutils.add_action(
184 self.summary, N_('Move Down'), self.focus_description, *hotkeys.ACCEPT
187 qtutils.add_action(
188 self.summary, N_('Move Down'), self.summary_cursor_down, hotkeys.DOWN
191 self.model.commit_message_changed.connect(
192 self.set_commit_message, type=Qt.QueuedConnection
195 self.summary.cursor_changed.connect(self.cursor_changed.emit)
196 self.description.cursor_changed.connect(
197 # description starts at line 2
198 lambda row, col: self.cursor_changed.emit(row + 2, col)
201 # pylint: disable=no-member
202 self.summary.textChanged.connect(self.commit_summary_changed)
203 self.description.textChanged.connect(self._commit_message_changed)
204 self.description.leave.connect(self.focus_summary)
206 self.commit_group.setEnabled(False)
208 self.set_expandtab(prefs.expandtab(context))
209 self.set_tabwidth(prefs.tabwidth(context))
210 self.set_textwidth(prefs.textwidth(context))
211 self.set_linebreak(prefs.linebreak(context))
213 # Loading message
214 commit_msg = ''
215 commit_msg_path = commit_message_path(context)
216 if commit_msg_path:
217 commit_msg = core.read(commit_msg_path)
218 model.set_commitmsg(commit_msg)
220 # Allow tab to jump from the summary to the description
221 self.setTabOrder(self.summary, self.description)
222 self.setFont(qtutils.diff_font(context))
223 self.setFocusProxy(self.summary)
225 cfg.user_config_changed.connect(self.config_changed)
227 def config_changed(self, key, value):
228 if key != prefs.SPELL_CHECK:
229 return
230 if get(self.check_spelling_action) == value:
231 return
232 self.check_spelling_action.setChecked(value)
233 self.toggle_check_spelling(value)
235 def set_initial_size(self):
236 self.setMaximumHeight(133)
237 QtCore.QTimer.singleShot(1, self.restore_size)
239 def restore_size(self):
240 self.setMaximumHeight(2**13)
242 def focus_summary(self):
243 self.summary.setFocus()
245 def focus_description(self):
246 self.description.setFocus()
248 def summary_cursor_down(self):
249 """Handle the down key in the summary field
251 If the cursor is at the end of the line then focus the description.
252 Otherwise, move the cursor to the end of the line so that a
253 subsequence "down" press moves to the end of the line.
256 cur_position = self.summary.cursorPosition()
257 end_position = len(get(self.summary))
258 if cur_position == end_position:
259 self.focus_description()
260 else:
261 self.summary.setCursorPosition(end_position)
263 def commit_message(self, raw=True):
264 """Return the commit message as a unicode string"""
265 summary = get(self.summary)
266 if raw:
267 description = get(self.description)
268 else:
269 description = self.formatted_description()
270 if summary and description:
271 return summary + '\n\n' + description
272 if summary:
273 return summary
274 if description:
275 return '\n\n' + description
276 return ''
278 def formatted_description(self):
279 text = get(self.description)
280 if not self._linebreak:
281 return text
282 return textwrap.word_wrap(text, self._tabwidth, self._textwidth)
284 def commit_summary_changed(self, value):
285 """Respond to changes to the `summary` field
287 Newlines can enter the `summary` field when pasting, which is
288 undesirable. Break the pasted value apart into the separate
289 (summary, description) values and move the description over to the
290 "extended description" field.
293 if '\n' in value:
294 summary, description = value.split('\n', 1)
295 description = description.lstrip('\n')
296 cur_description = get(self.description)
297 if cur_description:
298 description = description + '\n' + cur_description
299 # this callback is triggered by changing `summary`
300 # so disable signals for `summary` only.
301 self.summary.set_value(summary, block=True)
302 self.description.set_value(description)
303 self._commit_message_changed()
305 def _commit_message_changed(self, _value=None):
306 """Update the model when values change"""
307 message = self.commit_message()
308 self.model.set_commitmsg(message, notify=False)
309 self.refresh_palettes()
310 self.update_actions()
312 def clear(self):
313 if not Interaction.confirm(
314 N_('Clear commit message?'),
315 N_('The commit message will be cleared.'),
316 N_('This cannot be undone. Clear commit message?'),
317 N_('Clear commit message'),
318 default=True,
319 icon=icons.discard(),
321 return
322 self.model.set_commitmsg('')
324 def update_actions(self):
325 commit_enabled = bool(get(self.summary))
326 self.commit_group.setEnabled(commit_enabled)
328 def refresh_palettes(self):
329 """Update the color palette for the hint text"""
330 self.summary.hint.refresh()
331 self.description.hint.refresh()
333 def set_commit_message(self, message):
334 """Set the commit message to match the observed model"""
335 # Parse the "summary" and "description" fields
336 lines = message.splitlines()
338 num_lines = len(lines)
340 if num_lines == 0:
341 # Message is empty
342 summary = ''
343 description = ''
345 elif num_lines == 1:
346 # Message has a summary only
347 summary = lines[0]
348 description = ''
350 elif num_lines == 2:
351 # Message has two lines; this is not a common case
352 summary = lines[0]
353 description = lines[1]
355 else:
356 # Summary and several description lines
357 summary = lines[0]
358 if lines[1]:
359 # We usually skip this line but check just in case
360 description_lines = lines[1:]
361 else:
362 description_lines = lines[2:]
363 description = '\n'.join(description_lines)
365 focus_summary = not summary
366 focus_description = not description
368 # Update summary
369 self.summary.set_value(summary, block=True)
371 # Update description
372 self.description.set_value(description, block=True)
374 # Update text color
375 self.refresh_palettes()
377 # Focus the empty summary or description
378 if focus_summary:
379 self.summary.setFocus()
380 elif focus_description:
381 self.description.setFocus()
382 else:
383 self.summary.cursor_position.emit()
385 self.update_actions()
387 def set_expandtab(self, value):
388 self.description.set_expandtab(value)
390 def set_tabwidth(self, width):
391 self._tabwidth = width
392 self.description.set_tabwidth(width)
394 def set_textwidth(self, width):
395 self._textwidth = width
396 self.description.set_textwidth(width)
398 def set_linebreak(self, brk):
399 self._linebreak = brk
400 self.description.set_linebreak(brk)
401 with qtutils.BlockSignals(self.autowrap_action):
402 self.autowrap_action.setChecked(brk)
404 def setFont(self, font):
405 """Pass the setFont() calls down to the text widgets"""
406 self.summary.setFont(font)
407 self.description.setFont(font)
409 def set_mode(self, mode):
410 can_amend = not self.model.is_merging
411 checked = mode == self.model.mode_amend
412 with qtutils.BlockSignals(self.amend_action):
413 self.amend_action.setEnabled(can_amend)
414 self.amend_action.setChecked(checked)
416 def commit(self):
417 """Attempt to create a commit from the index and commit message."""
418 context = self.context
419 if not bool(get(self.summary)):
420 # Describe a good commit message
421 error_msg = N_(
422 'Please supply a commit message.\n\n'
423 'A good commit message has the following format:\n\n'
424 '- First line: Describe in one sentence what you did.\n'
425 '- Second line: Blank\n'
426 '- Remaining lines: Describe why this change is good.\n'
428 Interaction.log(error_msg)
429 Interaction.information(N_('Missing Commit Message'), error_msg)
430 return
432 msg = self.commit_message(raw=False)
434 # We either need to have something staged, or be merging.
435 # If there was a merge conflict resolved, there may not be anything
436 # to stage, but we still need to commit to complete the merge.
437 if not (self.model.staged or self.model.is_merging):
438 error_msg = N_(
439 'No changes to commit.\n\n'
440 'You must stage at least 1 file before you can commit.'
442 if self.model.modified:
443 informative_text = N_(
444 'Would you like to stage and commit all modified files?'
446 if not Interaction.confirm(
447 N_('Stage and commit?'),
448 error_msg,
449 informative_text,
450 N_('Stage and Commit'),
451 default=True,
452 icon=icons.save(),
454 return
455 else:
456 Interaction.information(N_('Nothing to commit'), error_msg)
457 return
458 cmds.do(cmds.StageModified, context)
460 # Warn that amending published commits is generally bad
461 amend = get(self.amend_action)
462 check_published = prefs.check_published_commits(context)
463 if (
464 amend
465 and check_published
466 and self.model.is_commit_published()
467 and not Interaction.confirm(
468 N_('Rewrite Published Commit?'),
470 'This commit has already been published.\n'
471 'This operation will rewrite published history.\n'
472 'You probably don\'t want to do this.'
474 N_('Amend the published commit?'),
475 N_('Amend Commit'),
476 default=False,
477 icon=icons.save(),
480 return
481 no_verify = get(self.bypass_commit_hooks_action)
482 sign = get(self.sign_action)
483 cmds.do(cmds.Commit, context, amend, msg, sign, no_verify=no_verify)
484 self.bypass_commit_hooks_action.setChecked(False)
486 def build_fixup_menu(self):
487 self.build_commits_menu(
488 cmds.LoadFixupMessage,
489 self.fixup_commit_menu,
490 self.choose_fixup_commit,
491 prefix='fixup! ',
494 def build_commitmsg_menu(self):
495 self.build_commits_menu(
496 cmds.LoadCommitMessageFromOID,
497 self.load_commitmsg_menu,
498 self.choose_commit_message,
501 def build_commits_menu(self, cmd, menu, chooser, prefix=''):
502 context = self.context
503 params = dag.DAG('HEAD', 6)
504 commits = dag.RepoReader(context, params)
506 menu_commits = []
507 for idx, c in enumerate(commits.get()):
508 menu_commits.insert(0, c)
509 if idx > 5:
510 continue
512 menu.clear()
513 for c in menu_commits:
514 menu.addAction(prefix + c.summary, cmds.run(cmd, context, c.oid))
516 if len(commits) == 6:
517 menu.addSeparator()
518 menu.addAction(N_('More...'), chooser)
520 def choose_commit(self, cmd):
521 context = self.context
522 revs, summaries = gitcmds.log_helper(context)
523 oids = select_commits(
524 context, N_('Select Commit'), revs, summaries, multiselect=False
526 if not oids:
527 return
528 oid = oids[0]
529 cmds.do(cmd, context, oid)
531 def choose_commit_message(self):
532 self.choose_commit(cmds.LoadCommitMessageFromOID)
534 def choose_fixup_commit(self):
535 self.choose_commit(cmds.LoadFixupMessage)
537 def toggle_check_spelling(self, enabled):
538 spell_check = self.spellcheck
539 cfg = self.context.cfg
541 if prefs.spellcheck(self.context) != enabled:
542 cfg.set_user(prefs.SPELL_CHECK, enabled)
543 if enabled and not self.spellcheck_initialized:
544 # Add our name to the dictionary
545 self.spellcheck_initialized = True
546 user_name = cfg.get('user.name')
547 if user_name:
548 for part in user_name.split():
549 spell_check.add_word(part)
551 # Add our email address to the dictionary
552 user_email = cfg.get('user.email')
553 if user_email:
554 for part in user_email.split('@'):
555 for elt in part.split('.'):
556 spell_check.add_word(elt)
558 # git jargon
559 spell_check.add_word('Acked')
560 spell_check.add_word('Signed')
561 spell_check.add_word('Closes')
562 spell_check.add_word('Fixes')
564 self.description.highlighter.enable(enabled)
565 self.summary.highlighter.enable(enabled)
568 class MessageValidator(QtGui.QValidator):
569 """Prevent invalid commit messages"""
571 def __init__(self, context, parent=None):
572 super(MessageValidator, self).__init__(parent)
573 self.context = context
574 self._comment_char = None
575 self.refresh()
576 context.cfg.updated.connect(self.refresh, type=Qt.QueuedConnection)
578 def refresh(self):
579 """Update comment char in response to config changes"""
580 self._comment_char = prefs.comment_char(self.context)
582 def validate(self, string, idx):
583 """Scrub whitespace and validate the commit message"""
584 string = string.lstrip()
585 if string.startswith(self._comment_char):
586 state = self.Invalid
587 else:
588 state = self.Acceptable
589 return (state, string, idx)
592 class CommitSummaryLineEdit(HintedLineEdit):
594 cursor = Signal(int, int)
596 def __init__(self, context, parent=None):
597 hint = N_('Commit summary')
598 HintedLineEdit.__init__(self, context, hint, parent=parent)
599 self.menu_actions = []
601 def build_menu(self):
602 menu = self.createStandardContextMenu()
603 add_menu_actions(menu, self.menu_actions)
604 return menu
606 def contextMenuEvent(self, event):
607 menu = self.build_menu()
608 menu.exec_(self.mapToGlobal(event.pos()))
611 # pylint: disable=too-many-ancestors
612 class CommitMessageTextEdit(SpellCheckTextEdit):
613 leave = Signal()
615 def __init__(self, context, check=None, parent=None):
616 hint = N_('Extended description...')
617 SpellCheckTextEdit.__init__(self, context, hint, check=check, parent=parent)
618 self.menu_actions = []
620 self.action_emit_leave = qtutils.add_action(
621 self, 'Shift Tab', self.leave.emit, hotkeys.LEAVE
624 def build_menu(self):
625 menu, _ = self.context_menu()
626 add_menu_actions(menu, self.menu_actions)
627 return menu
629 def contextMenuEvent(self, event):
630 menu = self.build_menu()
631 menu.exec_(self.mapToGlobal(event.pos()))
633 def keyPressEvent(self, event):
634 if event.key() == Qt.Key_Up:
635 cursor = self.textCursor()
636 position = cursor.position()
637 if position == 0:
638 # The cursor is at the beginning of the line.
639 # If we have selection then simply reset the cursor.
640 # Otherwise, emit a signal so that the parent can
641 # change focus.
642 if cursor.hasSelection():
643 cursor.setPosition(0)
644 self.setTextCursor(cursor)
645 else:
646 self.leave.emit()
647 event.accept()
648 return
649 text_before = self.toPlainText()[:position]
650 lines_before = text_before.count('\n')
651 if lines_before == 0:
652 # If we're on the first line, but not at the
653 # beginning, then move the cursor to the beginning
654 # of the line.
655 if event.modifiers() & Qt.ShiftModifier:
656 mode = QtGui.QTextCursor.KeepAnchor
657 else:
658 mode = QtGui.QTextCursor.MoveAnchor
659 cursor.setPosition(0, mode)
660 self.setTextCursor(cursor)
661 event.accept()
662 return
663 elif event.key() == Qt.Key_Down:
664 cursor = self.textCursor()
665 position = cursor.position()
666 all_text = self.toPlainText()
667 text_after = all_text[position:]
668 lines_after = text_after.count('\n')
669 if lines_after == 0:
670 if event.modifiers() & Qt.ShiftModifier:
671 mode = QtGui.QTextCursor.KeepAnchor
672 else:
673 mode = QtGui.QTextCursor.MoveAnchor
674 cursor.setPosition(len(all_text), mode)
675 self.setTextCursor(cursor)
676 event.accept()
677 return
678 SpellCheckTextEdit.keyPressEvent(self, event)
680 def setFont(self, font):
681 SpellCheckTextEdit.setFont(self, font)
682 fm = self.fontMetrics()
683 self.setMinimumSize(QtCore.QSize(fm.width('MMMM'), fm.height() * 2))
686 def add_menu_actions(menu, menu_actions):
687 """Add actions to a menu, treating None as a separator"""
688 for action in menu_actions:
689 if action is None:
690 menu.addSeparator()
691 else:
692 menu.addAction(action)