widgets: pylint tweaks, docstrings and use queued connections
[git-cola.git] / cola / widgets / commitmsg.py
blobb8bf34820dc8a211a0d5db521708fc71c68380ec
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 SpellCheckLineEdit, SpellCheckTextEdit
29 from .text import anchor_mode
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 self.signoff_action,
73 self.commit_action,
74 None,
75 self.launch_editor,
76 self.launch_difftool,
77 None,
78 self.move_up,
79 self.move_down,
80 None,
83 # Widgets
84 self.summary = CommitSummaryLineEdit(context, check=self.spellcheck)
85 self.summary.setMinimumHeight(defs.tool_button_height)
86 self.summary.menu_actions.extend(menu_actions)
88 self.description = CommitMessageTextEdit(
89 context, check=self.spellcheck, parent=self
91 self.description.menu_actions.extend(menu_actions)
93 commit_button_tooltip = N_('Commit staged changes\nShortcut: Ctrl+Enter')
94 self.commit_button = qtutils.create_button(
95 text=N_('Commit@@verb'), tooltip=commit_button_tooltip, icon=icons.commit()
97 self.commit_group = Group(self.commit_action, self.commit_button)
99 self.actions_menu = qtutils.create_menu(N_('Actions'), self)
100 self.actions_button = qtutils.create_toolbutton(
101 icon=icons.configure(), tooltip=N_('Actions...')
103 self.actions_button.setMenu(self.actions_menu)
105 self.actions_menu.addAction(self.signoff_action)
106 self.actions_menu.addAction(self.commit_action)
107 self.actions_menu.addSeparator()
109 # Amend checkbox
110 self.amend_action = self.actions_menu.addAction(N_('Amend Last Commit'))
111 self.amend_action.setIcon(icons.edit())
112 self.amend_action.setCheckable(True)
113 self.amend_action.setShortcut(hotkeys.AMEND)
114 self.amend_action.setShortcutContext(Qt.ApplicationShortcut)
116 # Bypass hooks
117 self.bypass_commit_hooks_action = self.actions_menu.addAction(
118 N_('Bypass Commit Hooks')
120 self.bypass_commit_hooks_action.setCheckable(True)
121 self.bypass_commit_hooks_action.setChecked(False)
123 # Sign commits
124 self.sign_action = self.actions_menu.addAction(N_('Create Signed Commit'))
125 self.sign_action.setCheckable(True)
126 signcommits = cfg.get('cola.signcommits', default=False)
127 self.sign_action.setChecked(signcommits)
129 # Spell checker
130 self.check_spelling_action = self.actions_menu.addAction(N_('Check Spelling'))
131 self.check_spelling_action.setCheckable(True)
132 spell_check = prefs.spellcheck(context)
133 self.check_spelling_action.setChecked(spell_check)
134 self.toggle_check_spelling(spell_check)
136 # Line wrapping
137 self.autowrap_action = self.actions_menu.addAction(N_('Auto-Wrap Lines'))
138 self.autowrap_action.setCheckable(True)
139 self.autowrap_action.setChecked(prefs.linebreak(context))
141 # Commit message
142 self.actions_menu.addSeparator()
143 self.load_commitmsg_menu = self.actions_menu.addMenu(
144 N_('Load Previous Commit Message')
146 self.load_commitmsg_menu.aboutToShow.connect(self.build_commitmsg_menu)
148 self.fixup_commit_menu = self.actions_menu.addMenu(N_('Fixup Previous Commit'))
149 self.fixup_commit_menu.aboutToShow.connect(self.build_fixup_menu)
151 self.toplayout = qtutils.hbox(
152 defs.no_margin,
153 defs.spacing,
154 self.actions_button,
155 self.summary,
156 self.commit_button,
158 self.toplayout.setContentsMargins(
159 defs.margin, defs.no_margin, defs.no_margin, defs.no_margin
162 self.mainlayout = qtutils.vbox(
163 defs.no_margin, defs.spacing, self.toplayout, self.description
165 self.setLayout(self.mainlayout)
167 qtutils.connect_button(self.commit_button, self.commit)
169 # Broadcast the amend mode
170 qtutils.connect_action_bool(
171 self.amend_action, partial(cmds.run(cmds.AmendMode), context)
173 qtutils.connect_action_bool(
174 self.check_spelling_action, self.toggle_check_spelling
177 # Handle the one-off autowrapping
178 qtutils.connect_action_bool(self.autowrap_action, self.set_linebreak)
180 self.summary.accepted.connect(self.focus_description)
181 self.summary.down_pressed.connect(self.summary_cursor_down)
183 self.model.commit_message_changed.connect(
184 self.set_commit_message, type=Qt.QueuedConnection
187 self.summary.cursor_changed.connect(self.cursor_changed.emit)
188 self.description.cursor_changed.connect(
189 # description starts at line 2
190 lambda row, col: self.cursor_changed.emit(row + 2, col)
193 # pylint: disable=no-member
194 self.summary.textChanged.connect(
195 self.commit_summary_changed, Qt.QueuedConnection
197 self.description.textChanged.connect(
198 self._commit_message_changed, Qt.QueuedConnection
200 self.description.leave.connect(self.focus_summary, Qt.QueuedConnection)
202 self.commit_group.setEnabled(False)
204 self.set_expandtab(prefs.expandtab(context))
205 self.set_tabwidth(prefs.tabwidth(context))
206 self.set_textwidth(prefs.textwidth(context))
207 self.set_linebreak(prefs.linebreak(context))
209 # Loading message
210 commit_msg = ''
211 commit_msg_path = commit_message_path(context)
212 if commit_msg_path:
213 commit_msg = core.read(commit_msg_path)
214 model.set_commitmsg(commit_msg)
216 # Allow tab to jump from the summary to the description
217 self.setTabOrder(self.summary, self.description)
218 self.setFont(qtutils.diff_font(context))
219 self.setFocusProxy(self.summary)
221 cfg.user_config_changed.connect(self.config_changed)
223 def config_changed(self, key, value):
224 if key != prefs.SPELL_CHECK:
225 return
226 if get(self.check_spelling_action) == value:
227 return
228 self.check_spelling_action.setChecked(value)
229 self.toggle_check_spelling(value)
231 def set_initial_size(self):
232 self.setMaximumHeight(133)
233 QtCore.QTimer.singleShot(1, self.restore_size)
235 def restore_size(self):
236 self.setMaximumHeight(2**13)
238 def focus_summary(self):
239 self.summary.setFocus()
241 def focus_description(self):
242 self.description.setFocus()
244 def summary_cursor_down(self):
245 """Handle the down key in the summary field
247 If the cursor is at the end of the line then focus the description.
248 Otherwise, move the cursor to the end of the line so that a
249 subsequence "down" press moves to the end of the line.
252 self.focus_description()
254 def commit_message(self, raw=True):
255 """Return the commit message as a unicode string"""
256 summary = get(self.summary)
257 if raw:
258 description = get(self.description)
259 else:
260 description = self.formatted_description()
261 if summary and description:
262 return summary + '\n\n' + description
263 if summary:
264 return summary
265 if description:
266 return '\n\n' + description
267 return ''
269 def formatted_description(self):
270 text = get(self.description)
271 if not self._linebreak:
272 return text
273 return textwrap.word_wrap(text, self._tabwidth, self._textwidth)
275 def commit_summary_changed(self):
276 """Respond to changes to the `summary` field
278 Newlines can enter the `summary` field when pasting, which is
279 undesirable. Break the pasted value apart into the separate
280 (summary, description) values and move the description over to the
281 "extended description" field.
284 value = self.summary.value()
285 if '\n' in value:
286 summary, description = value.split('\n', 1)
287 description = description.lstrip('\n')
288 cur_description = get(self.description)
289 if cur_description:
290 description = description + '\n' + cur_description
291 # this callback is triggered by changing `summary`
292 # so disable signals for `summary` only.
293 self.summary.set_value(summary, block=True)
294 self.description.set_value(description)
295 self._commit_message_changed()
297 def _commit_message_changed(self, _value=None):
298 """Update the model when values change"""
299 message = self.commit_message()
300 self.model.set_commitmsg(message, notify=False)
301 self.refresh_palettes()
302 self.update_actions()
304 def clear(self):
305 if not Interaction.confirm(
306 N_('Clear commit message?'),
307 N_('The commit message will be cleared.'),
308 N_('This cannot be undone. Clear commit message?'),
309 N_('Clear commit message'),
310 default=True,
311 icon=icons.discard(),
313 return
314 self.model.set_commitmsg('')
316 def update_actions(self):
317 commit_enabled = bool(get(self.summary))
318 self.commit_group.setEnabled(commit_enabled)
320 def refresh_palettes(self):
321 """Update the color palette for the hint text"""
322 self.summary.hint.refresh()
323 self.description.hint.refresh()
325 def set_commit_message(self, message):
326 """Set the commit message to match the observed model"""
327 # Parse the "summary" and "description" fields
328 lines = message.splitlines()
330 num_lines = len(lines)
332 if num_lines == 0:
333 # Message is empty
334 summary = ''
335 description = ''
337 elif num_lines == 1:
338 # Message has a summary only
339 summary = lines[0]
340 description = ''
342 elif num_lines == 2:
343 # Message has two lines; this is not a common case
344 summary = lines[0]
345 description = lines[1]
347 else:
348 # Summary and several description lines
349 summary = lines[0]
350 if lines[1]:
351 # We usually skip this line but check just in case
352 description_lines = lines[1:]
353 else:
354 description_lines = lines[2:]
355 description = '\n'.join(description_lines)
357 focus_summary = not summary
358 focus_description = not description
360 # Update summary
361 self.summary.set_value(summary, block=True)
363 # Update description
364 self.description.set_value(description, block=True)
366 # Update text color
367 self.refresh_palettes()
369 # Focus the empty summary or description
370 if focus_summary:
371 self.summary.setFocus()
372 elif focus_description:
373 self.description.setFocus()
374 else:
375 self.summary.cursor_position.emit()
377 self.update_actions()
379 def set_expandtab(self, value):
380 self.description.set_expandtab(value)
382 def set_tabwidth(self, width):
383 self._tabwidth = width
384 self.description.set_tabwidth(width)
386 def set_textwidth(self, width):
387 self._textwidth = width
388 self.description.set_textwidth(width)
390 def set_linebreak(self, brk):
391 self._linebreak = brk
392 self.description.set_linebreak(brk)
393 with qtutils.BlockSignals(self.autowrap_action):
394 self.autowrap_action.setChecked(brk)
396 def setFont(self, font):
397 """Pass the setFont() calls down to the text widgets"""
398 self.summary.setFont(font)
399 self.description.setFont(font)
401 def set_mode(self, mode):
402 can_amend = not self.model.is_merging
403 checked = mode == self.model.mode_amend
404 with qtutils.BlockSignals(self.amend_action):
405 self.amend_action.setEnabled(can_amend)
406 self.amend_action.setChecked(checked)
408 def commit(self):
409 """Attempt to create a commit from the index and commit message."""
410 context = self.context
411 if not bool(get(self.summary)):
412 # Describe a good commit message
413 error_msg = N_(
414 'Please supply a commit message.\n\n'
415 'A good commit message has the following format:\n\n'
416 '- First line: Describe in one sentence what you did.\n'
417 '- Second line: Blank\n'
418 '- Remaining lines: Describe why this change is good.\n'
420 Interaction.log(error_msg)
421 Interaction.information(N_('Missing Commit Message'), error_msg)
422 return
424 msg = self.commit_message(raw=False)
426 # We either need to have something staged, or be merging.
427 # If there was a merge conflict resolved, there may not be anything
428 # to stage, but we still need to commit to complete the merge.
429 if not (self.model.staged or self.model.is_merging):
430 error_msg = N_(
431 'No changes to commit.\n\n'
432 'You must stage at least 1 file before you can commit.'
434 if self.model.modified:
435 informative_text = N_(
436 'Would you like to stage and commit all modified files?'
438 if not Interaction.confirm(
439 N_('Stage and commit?'),
440 error_msg,
441 informative_text,
442 N_('Stage and Commit'),
443 default=True,
444 icon=icons.save(),
446 return
447 else:
448 Interaction.information(N_('Nothing to commit'), error_msg)
449 return
450 cmds.do(cmds.StageModified, context)
452 # Warn that amending published commits is generally bad
453 amend = get(self.amend_action)
454 check_published = prefs.check_published_commits(context)
455 if (
456 amend
457 and check_published
458 and self.model.is_commit_published()
459 and not Interaction.confirm(
460 N_('Rewrite Published Commit?'),
462 'This commit has already been published.\n'
463 'This operation will rewrite published history.\n'
464 'You probably don\'t want to do this.'
466 N_('Amend the published commit?'),
467 N_('Amend Commit'),
468 default=False,
469 icon=icons.save(),
472 return
473 no_verify = get(self.bypass_commit_hooks_action)
474 sign = get(self.sign_action)
475 cmds.do(cmds.Commit, context, amend, msg, sign, no_verify=no_verify)
476 self.bypass_commit_hooks_action.setChecked(False)
478 def build_fixup_menu(self):
479 self.build_commits_menu(
480 cmds.LoadFixupMessage,
481 self.fixup_commit_menu,
482 self.choose_fixup_commit,
483 prefix='fixup! ',
486 def build_commitmsg_menu(self):
487 self.build_commits_menu(
488 cmds.LoadCommitMessageFromOID,
489 self.load_commitmsg_menu,
490 self.choose_commit_message,
493 def build_commits_menu(self, cmd, menu, chooser, prefix=''):
494 context = self.context
495 params = dag.DAG('HEAD', 6)
496 commits = dag.RepoReader(context, params)
498 menu_commits = []
499 for idx, c in enumerate(commits.get()):
500 menu_commits.insert(0, c)
501 if idx > 5:
502 continue
504 menu.clear()
505 for c in menu_commits:
506 menu.addAction(prefix + c.summary, cmds.run(cmd, context, c.oid))
508 if len(commits) == 6:
509 menu.addSeparator()
510 menu.addAction(N_('More...'), chooser)
512 def choose_commit(self, cmd):
513 context = self.context
514 revs, summaries = gitcmds.log_helper(context)
515 oids = select_commits(
516 context, N_('Select Commit'), revs, summaries, multiselect=False
518 if not oids:
519 return
520 oid = oids[0]
521 cmds.do(cmd, context, oid)
523 def choose_commit_message(self):
524 self.choose_commit(cmds.LoadCommitMessageFromOID)
526 def choose_fixup_commit(self):
527 self.choose_commit(cmds.LoadFixupMessage)
529 def toggle_check_spelling(self, enabled):
530 spell_check = self.spellcheck
531 cfg = self.context.cfg
533 if prefs.spellcheck(self.context) != enabled:
534 cfg.set_user(prefs.SPELL_CHECK, enabled)
535 if enabled and not self.spellcheck_initialized:
536 # Add our name to the dictionary
537 self.spellcheck_initialized = True
538 user_name = cfg.get('user.name')
539 if user_name:
540 for part in user_name.split():
541 spell_check.add_word(part)
543 # Add our email address to the dictionary
544 user_email = cfg.get('user.email')
545 if user_email:
546 for part in user_email.split('@'):
547 for elt in part.split('.'):
548 spell_check.add_word(elt)
550 # git jargon
551 spell_check.add_word('Acked')
552 spell_check.add_word('Signed')
553 spell_check.add_word('Closes')
554 spell_check.add_word('Fixes')
556 self.summary.highlighter.enable(enabled)
557 self.description.highlighter.enable(enabled)
560 # pylint: disable=too-many-ancestors
561 class CommitSummaryLineEdit(SpellCheckLineEdit):
562 """Text input field for the commit summary"""
564 down_pressed = Signal()
565 accepted = Signal()
567 def __init__(self, context, check=None, parent=None):
568 hint = N_('Commit summary')
569 SpellCheckLineEdit.__init__(self, context, hint, check=check, parent=parent)
570 self._comment_char = None
571 self._refresh_config()
573 self.textChanged.connect(self._update_summary_text, Qt.QueuedConnection)
574 context.cfg.updated.connect(self._refresh_config, type=Qt.QueuedConnection)
576 def _refresh_config(self):
577 """Update comment char in response to config changes"""
578 self._comment_char = prefs.comment_char(self.context)
580 def _update_summary_text(self):
581 """Prevent commit messages from starting with comment characters"""
582 value = self.value()
583 if self._comment_char and value.startswith(self._comment_char):
584 cursor = self.textCursor()
585 position = cursor.position()
587 value = value.lstrip()
588 if self._comment_char:
589 value = value.lstrip(self._comment_char).lstrip()
591 self.set_value(value, block=True)
593 value = self.value()
594 if position > 1:
595 position = max(0, min(position - 1, len(value) - 1))
596 cursor.setPosition(position)
597 self.setTextCursor(cursor)
599 def keyPressEvent(self, event):
600 """Allow "Enter" to focus into the extended description field"""
601 event_key = event.key()
602 if event_key in (
603 Qt.Key_Enter,
604 Qt.Key_Return,
606 self.accepted.emit()
607 return
608 SpellCheckLineEdit.keyPressEvent(self, event)
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)
619 self.action_emit_leave = qtutils.add_action(
620 self, 'Shift Tab', self.leave.emit, hotkeys.LEAVE
623 def keyPressEvent(self, event):
624 if event.key() == Qt.Key_Up:
625 cursor = self.textCursor()
626 position = cursor.position()
627 if position == 0:
628 # The cursor is at the beginning of the line.
629 # If we have selection then simply reset the cursor.
630 # Otherwise, emit a signal so that the parent can
631 # change focus.
632 if cursor.hasSelection():
633 self.set_cursor_position(0)
634 else:
635 self.leave.emit()
636 event.accept()
637 return
638 text_before = self.toPlainText()[:position]
639 lines_before = text_before.count('\n')
640 if lines_before == 0:
641 # If we're on the first line, but not at the
642 # beginning, then move the cursor to the beginning
643 # of the line.
644 if event.modifiers() & Qt.ShiftModifier:
645 mode = QtGui.QTextCursor.KeepAnchor
646 else:
647 mode = QtGui.QTextCursor.MoveAnchor
648 cursor.setPosition(0, mode)
649 self.setTextCursor(cursor)
650 event.accept()
651 return
652 elif event.key() == Qt.Key_Down:
653 cursor = self.textCursor()
654 position = cursor.position()
655 all_text = self.toPlainText()
656 text_after = all_text[position:]
657 lines_after = text_after.count('\n')
658 if lines_after == 0:
659 select = event.modifiers() & Qt.ShiftModifier
660 mode = anchor_mode(select)
661 cursor.setPosition(len(all_text), mode)
662 self.setTextCursor(cursor)
663 event.accept()
664 return
665 SpellCheckTextEdit.keyPressEvent(self, event)
667 def setFont(self, font):
668 SpellCheckTextEdit.setFont(self, font)
669 fm = self.fontMetrics()
670 self.setMinimumSize(QtCore.QSize(fm.width('MMMM'), fm.height() * 2))