qtutils: add text_width() and text_size() helper functions
[git-cola.git] / cola / widgets / commitmsg.py
blob373e7872829d3a27722394f2e97ad27855ca1230
1 from functools import partial
3 from qtpy import QtCore
4 from qtpy import QtGui
5 from qtpy import QtWidgets
6 from qtpy.QtCore import Qt
7 from qtpy.QtCore import Signal
9 from .. import actions
10 from .. import cmds
11 from .. import core
12 from .. import gitcmds
13 from .. import hotkeys
14 from .. import icons
15 from .. import textwrap
16 from .. import qtutils
17 from .. import spellcheck
18 from ..interaction import Interaction
19 from ..gitcmds import commit_message_path
20 from ..i18n import N_
21 from ..models import dag
22 from ..models import prefs
23 from ..qtutils import get
24 from ..utils import Group
25 from . import defs
26 from . import standard
27 from .selectcommits import select_commits
28 from .spellcheck import SpellCheckLineEdit, SpellCheckTextEdit
29 from .text import anchor_mode
32 class CommitMessageEditor(QtWidgets.QFrame):
33 commit_finished = Signal(object)
34 cursor_changed = Signal(int, int)
35 down = Signal()
36 up = Signal()
38 def __init__(self, context, parent):
39 QtWidgets.QFrame.__init__(self, parent)
40 cfg = context.cfg
41 self.context = context
42 self.model = model = context.model
43 self.spellcheck_initialized = False
44 self.spellcheck = spellcheck.NorvigSpellCheck()
45 self.spellcheck.set_dictionary(cfg.get('cola.dictionary', None))
47 self._linebreak = None
48 self._textwidth = None
49 self._tabwidth = None
51 # Actions
52 self.signoff_action = qtutils.add_action(
53 self, cmds.SignOff.name(), cmds.run(cmds.SignOff, context), hotkeys.SIGNOFF
55 self.signoff_action.setIcon(icons.style_dialog_apply())
56 self.signoff_action.setToolTip(N_('Sign off on this commit'))
58 self.commit_action = qtutils.add_action(
59 self, N_('Commit@@verb'), self.commit, hotkeys.APPLY
61 self.commit_action.setIcon(icons.commit())
62 self.commit_action.setToolTip(N_('Commit staged changes'))
63 self.clear_action = qtutils.add_action(self, N_('Clear...'), self.clear)
65 self.launch_editor = actions.launch_editor_at_line(context, self)
66 self.launch_difftool = actions.launch_difftool(context, self)
68 self.move_up = actions.move_up(self)
69 self.move_down = actions.move_down(self)
71 # Menu acctions
72 self.menu_actions = menu_actions = [
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,
81 None,
84 # Widgets
85 self.summary = CommitSummaryLineEdit(context, check=self.spellcheck)
86 self.summary.setMinimumHeight(defs.tool_button_height)
87 self.summary.menu_actions.extend(menu_actions)
89 self.description = CommitMessageTextEdit(
90 context, check=self.spellcheck, parent=self
92 self.description.menu_actions.extend(menu_actions)
94 commit_button_tooltip = N_('Commit staged changes\nShortcut: Ctrl+Enter')
95 self.commit_button = qtutils.create_button(
96 text=N_('Commit@@verb'), tooltip=commit_button_tooltip, icon=icons.commit()
98 self.commit_group = Group(self.commit_action, self.commit_button)
99 self.commit_progress_bar = standard.progress_bar(
100 self,
101 hide=(self.commit_button,),
102 disable=(self.commit_button, self.summary, self.description),
105 self.actions_menu = qtutils.create_menu(N_('Actions'), self)
106 self.actions_button = qtutils.create_toolbutton(
107 icon=icons.configure(), tooltip=N_('Actions...')
109 self.actions_button.setMenu(self.actions_menu)
111 self.actions_menu.addAction(self.signoff_action)
112 self.actions_menu.addAction(self.commit_action)
113 self.actions_menu.addSeparator()
115 # Amend checkbox
116 self.amend_action = self.actions_menu.addAction(N_('Amend Last Commit'))
117 self.amend_action.setIcon(icons.edit())
118 self.amend_action.setCheckable(True)
119 self.amend_action.setShortcut(hotkeys.AMEND)
120 self.amend_action.setShortcutContext(Qt.ApplicationShortcut)
122 # Bypass hooks
123 self.bypass_commit_hooks_action = self.actions_menu.addAction(
124 N_('Bypass Commit Hooks')
126 self.bypass_commit_hooks_action.setCheckable(True)
127 self.bypass_commit_hooks_action.setChecked(False)
129 # Sign commits
130 self.sign_action = self.actions_menu.addAction(N_('Create Signed Commit'))
131 self.sign_action.setCheckable(True)
132 signcommits = cfg.get('cola.signcommits', default=False)
133 self.sign_action.setChecked(signcommits)
135 # Spell checker
136 self.check_spelling_action = self.actions_menu.addAction(N_('Check Spelling'))
137 self.check_spelling_action.setCheckable(True)
138 spell_check = prefs.spellcheck(context)
139 self.check_spelling_action.setChecked(spell_check)
140 self.toggle_check_spelling(spell_check)
142 # Line wrapping
143 self.autowrap_action = self.actions_menu.addAction(N_('Auto-Wrap Lines'))
144 self.autowrap_action.setCheckable(True)
145 self.autowrap_action.setChecked(prefs.linebreak(context))
147 # Commit message
148 self.actions_menu.addSeparator()
149 self.load_commitmsg_menu = self.actions_menu.addMenu(
150 N_('Load Previous Commit Message')
152 self.load_commitmsg_menu.aboutToShow.connect(self.build_commitmsg_menu)
154 self.fixup_commit_menu = self.actions_menu.addMenu(N_('Fixup Previous Commit'))
155 self.fixup_commit_menu.aboutToShow.connect(self.build_fixup_menu)
157 self.toplayout = qtutils.hbox(
158 defs.no_margin,
159 defs.spacing,
160 self.actions_button,
161 self.summary,
162 self.commit_button,
163 self.commit_progress_bar,
165 self.toplayout.setContentsMargins(
166 defs.margin, defs.no_margin, defs.no_margin, defs.no_margin
169 self.mainlayout = qtutils.vbox(
170 defs.no_margin, defs.spacing, self.toplayout, self.description
172 self.setLayout(self.mainlayout)
174 qtutils.connect_button(self.commit_button, self.commit)
176 # Broadcast the amend mode
177 qtutils.connect_action_bool(
178 self.amend_action, partial(cmds.run(cmds.AmendMode), context)
180 qtutils.connect_action_bool(
181 self.check_spelling_action, self.toggle_check_spelling
184 # Handle the one-off autowrapping
185 qtutils.connect_action_bool(self.autowrap_action, self.set_linebreak)
187 self.summary.accepted.connect(self.focus_description)
188 self.summary.down_pressed.connect(self.summary_cursor_down)
190 self.model.commit_message_changed.connect(
191 self.set_commit_message, type=Qt.QueuedConnection
193 self.commit_finished.connect(self._commit_finished, 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 self.focus_description()
258 def commit_message(self, raw=True):
259 """Return the commit message as a unicode string"""
260 summary = get(self.summary)
261 if raw:
262 description = get(self.description)
263 else:
264 description = self.formatted_description()
265 if summary and description:
266 return summary + '\n\n' + description
267 if summary:
268 return summary
269 if description:
270 return '\n\n' + description
271 return ''
273 def formatted_description(self):
274 text = get(self.description)
275 if not self._linebreak:
276 return text
277 return textwrap.word_wrap(text, self._tabwidth, self._textwidth)
279 def commit_summary_changed(self):
280 """Respond to changes to the `summary` field
282 Newlines can enter the `summary` field when pasting, which is
283 undesirable. Break the pasted value apart into the separate
284 (summary, description) values and move the description over to the
285 "extended description" field.
288 value = self.summary.value()
289 if '\n' in value:
290 summary, description = value.split('\n', 1)
291 description = description.lstrip('\n')
292 cur_description = get(self.description)
293 if cur_description:
294 description = description + '\n' + cur_description
295 # this callback is triggered by changing `summary`
296 # so disable signals for `summary` only.
297 self.summary.set_value(summary, block=True)
298 self.description.set_value(description)
299 self._commit_message_changed()
301 def _commit_message_changed(self, _value=None):
302 """Update the model when values change"""
303 message = self.commit_message()
304 self.model.set_commitmsg(message, notify=False)
305 self.refresh_palettes()
306 self.update_actions()
308 def clear(self):
309 if not Interaction.confirm(
310 N_('Clear commit message?'),
311 N_('The commit message will be cleared.'),
312 N_('This cannot be undone. Clear commit message?'),
313 N_('Clear commit message'),
314 default=True,
315 icon=icons.discard(),
317 return
318 self.model.set_commitmsg('')
320 def update_actions(self):
321 commit_enabled = bool(get(self.summary))
322 self.commit_group.setEnabled(commit_enabled)
324 def refresh_palettes(self):
325 """Update the color palette for the hint text"""
326 self.summary.hint.refresh()
327 self.description.hint.refresh()
329 def set_commit_message(self, message):
330 """Set the commit message to match the observed model"""
331 # Parse the "summary" and "description" fields
332 lines = message.splitlines()
334 num_lines = len(lines)
336 if num_lines == 0:
337 # Message is empty
338 summary = ''
339 description = ''
341 elif num_lines == 1:
342 # Message has a summary only
343 summary = lines[0]
344 description = ''
346 elif num_lines == 2:
347 # Message has two lines; this is not a common case
348 summary = lines[0]
349 description = lines[1]
351 else:
352 # Summary and several description lines
353 summary = lines[0]
354 if lines[1]:
355 # We usually skip this line but check just in case
356 description_lines = lines[1:]
357 else:
358 description_lines = lines[2:]
359 description = '\n'.join(description_lines)
361 focus_summary = not summary
362 focus_description = not description
364 # Update summary
365 self.summary.set_value(summary, block=True)
367 # Update description
368 self.description.set_value(description, block=True)
370 # Update text color
371 self.refresh_palettes()
373 # Focus the empty summary or description
374 if focus_summary:
375 self.summary.setFocus()
376 elif focus_description:
377 self.description.setFocus()
378 else:
379 self.summary.cursor_position.emit()
381 self.update_actions()
383 def set_expandtab(self, value):
384 self.description.set_expandtab(value)
386 def set_tabwidth(self, width):
387 self._tabwidth = width
388 self.description.set_tabwidth(width)
390 def set_textwidth(self, width):
391 self._textwidth = width
392 self.description.set_textwidth(width)
394 def set_linebreak(self, brk):
395 self._linebreak = brk
396 self.description.set_linebreak(brk)
397 with qtutils.BlockSignals(self.autowrap_action):
398 self.autowrap_action.setChecked(brk)
400 def setFont(self, font):
401 """Pass the setFont() calls down to the text widgets"""
402 self.summary.setFont(font)
403 self.description.setFont(font)
405 def set_mode(self, mode):
406 can_amend = not self.model.is_merging
407 checked = mode == self.model.mode_amend
408 with qtutils.BlockSignals(self.amend_action):
409 self.amend_action.setEnabled(can_amend)
410 self.amend_action.setChecked(checked)
412 def commit(self):
413 """Attempt to create a commit from the index and commit message."""
414 context = self.context
415 if not bool(get(self.summary)):
416 # Describe a good commit message
417 error_msg = N_(
418 'Please supply a commit message.\n\n'
419 'A good commit message has the following format:\n\n'
420 '- First line: Describe in one sentence what you did.\n'
421 '- Second line: Blank\n'
422 '- Remaining lines: Describe why this change is good.\n'
424 Interaction.log(error_msg)
425 Interaction.information(N_('Missing Commit Message'), error_msg)
426 return
428 msg = self.commit_message(raw=False)
430 # We either need to have something staged, or be merging.
431 # If there was a merge conflict resolved, there may not be anything
432 # to stage, but we still need to commit to complete the merge.
433 if not (self.model.staged or self.model.is_merging):
434 error_msg = N_(
435 'No changes to commit.\n\n'
436 'You must stage at least 1 file before you can commit.'
438 if self.model.modified:
439 informative_text = N_(
440 'Would you like to stage and commit all modified files?'
442 if not Interaction.confirm(
443 N_('Stage and commit?'),
444 error_msg,
445 informative_text,
446 N_('Stage and Commit'),
447 default=True,
448 icon=icons.save(),
450 return
451 else:
452 Interaction.information(N_('Nothing to commit'), error_msg)
453 return
454 cmds.do(cmds.StageModified, context)
456 # Warn that amending published commits is generally bad
457 amend = get(self.amend_action)
458 check_published = prefs.check_published_commits(context)
459 if (
460 amend
461 and check_published
462 and self.model.is_commit_published()
463 and not Interaction.confirm(
464 N_('Rewrite Published Commit?'),
466 'This commit has already been published.\n'
467 'This operation will rewrite published history.\n'
468 "You probably don't want to do this."
470 N_('Amend the published commit?'),
471 N_('Amend Commit'),
472 default=False,
473 icon=icons.save(),
476 return
477 no_verify = get(self.bypass_commit_hooks_action)
478 sign = get(self.sign_action)
479 self.bypass_commit_hooks_action.setChecked(False)
481 self.commit_progress_bar.setMaximumWidth(self.commit_button.width())
482 self.commit_progress_bar.setMinimumHeight(self.commit_button.height() - 2)
484 task = qtutils.SimpleTask(
485 cmds.run(cmds.Commit, context, amend, msg, sign, no_verify=no_verify)
487 self.context.runtask.start(
488 task,
489 finish=self.commit_finished.emit,
490 progress=self.commit_progress_bar,
493 def _commit_finished(self, task):
494 """Reset widget state on completion of the commit task"""
495 title = N_('Commit failed')
496 status, out, err = task.result
497 Interaction.command(title, 'git commit', status, out, err)
498 self.setFocus()
500 def build_fixup_menu(self):
501 self.build_commits_menu(
502 cmds.LoadFixupMessage,
503 self.fixup_commit_menu,
504 self.choose_fixup_commit,
505 prefix='fixup! ',
508 def build_commitmsg_menu(self):
509 self.build_commits_menu(
510 cmds.LoadCommitMessageFromOID,
511 self.load_commitmsg_menu,
512 self.choose_commit_message,
515 def build_commits_menu(self, cmd, menu, chooser, prefix=''):
516 context = self.context
517 params = dag.DAG('HEAD', 6)
518 commits = dag.RepoReader(context, params)
520 menu_commits = []
521 for idx, commit in enumerate(commits.get()):
522 menu_commits.insert(0, commit)
523 if idx > 5:
524 continue
526 menu.clear()
527 for commit in menu_commits:
528 menu.addAction(prefix + commit.summary, cmds.run(cmd, context, commit.oid))
530 if len(commits) == 6:
531 menu.addSeparator()
532 menu.addAction(N_('More...'), chooser)
534 def choose_commit(self, cmd):
535 context = self.context
536 revs, summaries = gitcmds.log_helper(context)
537 oids = select_commits(
538 context, N_('Select Commit'), revs, summaries, multiselect=False
540 if not oids:
541 return
542 oid = oids[0]
543 cmds.do(cmd, context, oid)
545 def choose_commit_message(self):
546 self.choose_commit(cmds.LoadCommitMessageFromOID)
548 def choose_fixup_commit(self):
549 self.choose_commit(cmds.LoadFixupMessage)
551 def toggle_check_spelling(self, enabled):
552 spell_check = self.spellcheck
553 cfg = self.context.cfg
555 if prefs.spellcheck(self.context) != enabled:
556 cfg.set_user(prefs.SPELL_CHECK, enabled)
557 if enabled and not self.spellcheck_initialized:
558 # Add our name to the dictionary
559 self.spellcheck_initialized = True
560 user_name = cfg.get('user.name')
561 if user_name:
562 for part in user_name.split():
563 spell_check.add_word(part)
565 # Add our email address to the dictionary
566 user_email = cfg.get('user.email')
567 if user_email:
568 for part in user_email.split('@'):
569 for elt in part.split('.'):
570 spell_check.add_word(elt)
572 # git jargon
573 spell_check.add_word('Acked')
574 spell_check.add_word('Signed')
575 spell_check.add_word('Closes')
576 spell_check.add_word('Fixes')
578 self.summary.highlighter.enable(enabled)
579 self.description.highlighter.enable(enabled)
582 # pylint: disable=too-many-ancestors
583 class CommitSummaryLineEdit(SpellCheckLineEdit):
584 """Text input field for the commit summary"""
586 down_pressed = Signal()
587 accepted = Signal()
589 def __init__(self, context, check=None, parent=None):
590 hint = N_('Commit summary')
591 SpellCheckLineEdit.__init__(self, context, hint, check=check, parent=parent)
592 self._comment_char = None
593 self._refresh_config()
595 self.textChanged.connect(self._update_summary_text, Qt.QueuedConnection)
596 context.cfg.updated.connect(self._refresh_config, type=Qt.QueuedConnection)
598 def _refresh_config(self):
599 """Update comment char in response to config changes"""
600 self._comment_char = prefs.comment_char(self.context)
602 def _update_summary_text(self):
603 """Prevent commit messages from starting with comment characters"""
604 value = self.value()
605 if self._comment_char and value.startswith(self._comment_char):
606 cursor = self.textCursor()
607 position = cursor.position()
609 value = value.lstrip()
610 if self._comment_char:
611 value = value.lstrip(self._comment_char).lstrip()
613 self.set_value(value, block=True)
615 value = self.value()
616 if position > 1:
617 position = max(0, min(position - 1, len(value) - 1))
618 cursor.setPosition(position)
619 self.setTextCursor(cursor)
621 def keyPressEvent(self, event):
622 """Allow "Enter" to focus into the extended description field"""
623 event_key = event.key()
624 if event_key in (
625 Qt.Key_Enter,
626 Qt.Key_Return,
628 self.accepted.emit()
629 return
630 SpellCheckLineEdit.keyPressEvent(self, event)
633 # pylint: disable=too-many-ancestors
634 class CommitMessageTextEdit(SpellCheckTextEdit):
635 leave = Signal()
637 def __init__(self, context, check=None, parent=None):
638 hint = N_('Extended description...')
639 SpellCheckTextEdit.__init__(self, context, hint, check=check, parent=parent)
641 self.action_emit_leave = qtutils.add_action(
642 self, 'Shift Tab', self.leave.emit, hotkeys.LEAVE
645 def keyPressEvent(self, event):
646 if event.key() == Qt.Key_Up:
647 cursor = self.textCursor()
648 position = cursor.position()
649 if position == 0:
650 # The cursor is at the beginning of the line.
651 # If we have selection then simply reset the cursor.
652 # Otherwise, emit a signal so that the parent can
653 # change focus.
654 if cursor.hasSelection():
655 self.set_cursor_position(0)
656 else:
657 self.leave.emit()
658 event.accept()
659 return
660 text_before = self.toPlainText()[:position]
661 lines_before = text_before.count('\n')
662 if lines_before == 0:
663 # If we're on the first line, but not at the
664 # beginning, then move the cursor to the beginning
665 # of the line.
666 if event.modifiers() & Qt.ShiftModifier:
667 mode = QtGui.QTextCursor.KeepAnchor
668 else:
669 mode = QtGui.QTextCursor.MoveAnchor
670 cursor.setPosition(0, mode)
671 self.setTextCursor(cursor)
672 event.accept()
673 return
674 elif event.key() == Qt.Key_Down:
675 cursor = self.textCursor()
676 position = cursor.position()
677 all_text = self.toPlainText()
678 text_after = all_text[position:]
679 lines_after = text_after.count('\n')
680 if lines_after == 0:
681 select = event.modifiers() & Qt.ShiftModifier
682 mode = anchor_mode(select)
683 cursor.setPosition(len(all_text), mode)
684 self.setTextCursor(cursor)
685 event.accept()
686 return
687 SpellCheckTextEdit.keyPressEvent(self, event)
689 def setFont(self, font):
690 SpellCheckTextEdit.setFont(self, font)
691 width, height = qtutils.text_size(font, 'MMMM')
692 self.setMinimumSize(QtCore.QSize(width, height * 2))