widgets.commitmsg: Make Up/Down arrows more useful
[git-cola.git] / cola / widgets / commitmsg.py
blob6646e31004aec071451688dda46f9bd80defe7a8
1 from PyQt4 import QtGui
2 from PyQt4 import QtCore
3 from PyQt4.QtCore import Qt
4 from PyQt4.QtCore import SIGNAL
6 import cola
7 from cola import gitcmds
8 from cola import signals
9 from cola.qt import create_toolbutton
10 from cola.qtutils import add_action
11 from cola.qtutils import confirm
12 from cola.qtutils import connect_action
13 from cola.qtutils import connect_action_bool
14 from cola.qtutils import connect_button
15 from cola.qtutils import emit
16 from cola.qtutils import log
17 from cola.qtutils import relay_signal
18 from cola.qtutils import options_icon
19 from cola.qtutils import save_icon
20 from cola.qtutils import tr
21 from cola.widgets import defs
22 from cola.prefs import diff_font
23 from cola.dag.model import DAG
24 from cola.dag.model import RepoReader
25 from cola.widgets.selectcommits import select_commits
28 class CommitMessageEditor(QtGui.QWidget):
29 def __init__(self, model, parent):
30 QtGui.QWidget.__init__(self, parent)
32 self.model = model
33 self.notifying = False
34 self.summary_placeholder = u'Commit summary'
35 self.description_placeholder = u'Extended description...'
37 # Palette for normal text
38 self.default_palette = QtGui.QPalette(self.palette())
40 # Palette used for the placeholder text
41 self.placeholder_palette = pal = QtGui.QPalette(self.palette())
42 color = pal.text().color()
43 color.setAlpha(128)
44 pal.setColor(QtGui.QPalette.Active, QtGui.QPalette.Text, color)
45 pal.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Text, color)
47 self.summary = QtGui.QLineEdit()
48 self.description = CommitMessageTextEdit()
50 self.commit_button = create_toolbutton(text='Commit@@verb',
51 tooltip='Commit staged changes',
52 icon=save_icon())
54 self.actions_menu = QtGui.QMenu()
55 self.actions_button = create_toolbutton(icon=options_icon(),
56 tooltip='Actions...')
57 self.actions_button.setMenu(self.actions_menu)
58 self.actions_button.setPopupMode(QtGui.QToolButton.InstantPopup)
60 # Amend checkbox
61 self.signoff_action = self.actions_menu.addAction(tr('Sign Off'))
62 self.signoff_action.setToolTip('Sign off on this commit')
63 self.signoff_action.setShortcut('Ctrl+i')
65 self.commit_action = self.actions_menu.addAction(tr('Commit@@verb'))
66 self.commit_action.setToolTip(tr('Commit staged changes'))
67 self.commit_action.setShortcut('Ctrl+Return')
69 self.actions_menu.addSeparator()
70 self.amend_action = self.actions_menu.addAction(tr('Amend Last Commit'))
71 self.amend_action.setCheckable(True)
73 self.prev_commits_menu = self.actions_menu.addMenu(
74 'Load Previous Commit Message')
75 self.connect(self.prev_commits_menu, SIGNAL('aboutToShow()'),
76 self.build_prev_commits_menu)
78 self.toplayout = QtGui.QHBoxLayout()
79 self.toplayout.setMargin(0)
80 self.toplayout.setSpacing(defs.spacing)
81 self.toplayout.addWidget(self.actions_button)
82 self.toplayout.addWidget(self.summary)
83 self.toplayout.addWidget(self.commit_button)
85 self.mainlayout = QtGui.QVBoxLayout()
86 self.mainlayout.setMargin(defs.margin)
87 self.mainlayout.setSpacing(defs.spacing)
88 self.mainlayout.addLayout(self.toplayout)
89 self.mainlayout.addWidget(self.description)
90 self.setLayout(self.mainlayout)
92 relay_signal(self, self.description,
93 SIGNAL(signals.load_previous_message))
95 connect_button(self.commit_button, self.commit)
96 connect_action(self.commit_action, self.commit)
97 connect_action(self.signoff_action, emit(self, signals.signoff))
99 cola.notifier().connect(signals.amend, self.amend_action.setChecked)
101 # Broadcast the amend mode
102 connect_action_bool(self.amend_action, emit(self, signals.amend_mode))
104 self.model.add_observer(self.model.message_commit_message_changed,
105 self.set_commit_message)
107 self.connect(self.summary, SIGNAL('returnPressed()'),
108 self.summary_return_pressed)
110 self.connect(self.summary, SIGNAL('cursorPositionChanged(int,int)'),
111 lambda x, y: self.emit_summary_position())
113 # Keep model informed of changes
114 self.connect(self.summary, SIGNAL('textChanged(QString)'),
115 self.commit_message_changed)
117 self.connect(self.description, SIGNAL('textChanged()'),
118 self.commit_message_changed)
120 self.connect(self.description, SIGNAL('cursorPositionChanged()'),
121 self.emit_cursor_position)
123 self.connect(self.description, SIGNAL('shiftTab()'),
124 self.focus_summary)
126 self.setFont(diff_font())
128 self.summary.installEventFilter(self)
129 self.description.installEventFilter(self)
131 self.enable_placeholder_summary(True)
132 self.enable_placeholder_description(True)
134 self.commit_button.setEnabled(False)
135 self.commit_action.setEnabled(False)
137 self.setFocusProxy(self.summary)
139 def focus_summary(self):
140 self.summary.setFocus(True)
142 def is_summary_placeholder(self):
143 summary = unicode(self.summary.text()).strip()
144 return summary == self.summary_placeholder
146 def is_description_placeholder(self):
147 description = unicode(self.description.toPlainText()).strip()
148 return description == self.description_placeholder
150 def eventFilter(self, obj, event):
151 if obj == self.summary:
152 if event.type() == QtCore.QEvent.FocusIn:
153 self.emit_summary_position()
154 if self.is_summary_placeholder():
155 self.enable_placeholder_summary(False)
157 elif event.type() == QtCore.QEvent.FocusOut:
158 if not bool(self.commit_summary()):
159 self.enable_placeholder_summary(True)
161 elif obj == self.description:
162 if event.type() == QtCore.QEvent.FocusIn:
163 self.emit_cursor_position()
164 if self.is_description_placeholder():
165 self.enable_placeholder_description(False)
167 elif event.type() == QtCore.QEvent.FocusOut:
168 if not bool(self.commit_description()):
169 self.enable_placeholder_description(True)
171 return False
173 def enable_placeholder_summary(self, placeholder):
174 blocksignals = self.summary.blockSignals(True)
175 if placeholder:
176 self.summary.setText(self.summary_placeholder)
177 else:
178 self.summary.clear()
179 self.summary.setCursorPosition(0)
180 self.summary.blockSignals(blocksignals)
181 self.set_placeholder_palette(self.summary, placeholder)
183 def enable_placeholder_description(self, placeholder):
184 blocksignals = self.description.blockSignals(True)
185 if placeholder:
186 self.description.setPlainText(self.description_placeholder)
187 else:
188 self.description.clear()
189 self.description.blockSignals(blocksignals)
190 self.set_placeholder_palette(self.description, placeholder)
192 def set_placeholder_palette(self, widget, placeholder):
193 if placeholder:
194 widget.setPalette(self.placeholder_palette)
195 else:
196 widget.setPalette(self.default_palette)
198 def summary_return_pressed(self):
199 if bool(self.commit_summary()):
200 self.description.setFocus(True)
202 def commit_summary(self):
203 """Return the commit summary as unicode"""
204 summary = unicode(self.summary.text()).strip()
205 if summary != self.summary_placeholder:
206 return summary
207 else:
208 return u''
210 def commit_description(self):
211 """Return the commit description as unicode"""
212 description = unicode(self.description.toPlainText()).strip()
213 if description != self.description_placeholder:
214 return description
215 else:
216 return u''
218 def commit_message(self):
219 """Return the commit message as a unicode string"""
220 summary = self.commit_summary()
221 description = self.commit_description()
222 if summary and description:
223 return summary + u'\n\n' + description
224 elif summary:
225 return summary
226 elif description:
227 return u'\n\n' + description
228 else:
229 return u''
231 def commit_message_changed(self, value=None):
232 """Update the model when values change"""
233 self.notifying = True
234 message = self.commit_message()
235 self.model.set_commitmsg(message)
236 self.update_placeholder_state()
237 self.notifying = False
238 self.update_actions()
240 def update_actions(self):
241 commit_enabled = bool(self.commit_summary())
242 self.commit_button.setEnabled(commit_enabled)
243 self.commit_action.setEnabled(commit_enabled)
245 def update_placeholder_state(self):
246 """Update the color palette for the placeholder text"""
247 self.set_placeholder_palette(self.summary,
248 self.is_summary_placeholder())
249 self.set_placeholder_palette(self.description,
250 self.is_description_placeholder())
252 def set_commit_message(self, message):
253 """Set the commit message to match the observed model"""
254 if self.notifying:
255 # Calling self.model.set_commitmsg(message) causes us to
256 # loop around so break the loop
257 return
259 # Parse the "summary" and "description" fields
260 umsg = unicode(message)
261 lines = umsg.splitlines()
263 num_lines = len(lines)
265 if num_lines == 0:
266 # Message is empty
267 summary = u''
268 description = u''
270 elif num_lines == 1:
271 # Message has a summary only
272 summary = lines[0]
273 description = u''
275 elif num_lines == 2:
276 # Message has two lines; this is not a common case
277 summary = lines[0]
278 description = lines[1]
280 else:
281 # Summary and several description lines
282 summary = lines[0]
283 if lines[1]:
284 # We usually skip this line but check just in case
285 description_lines = lines[1:]
286 else:
287 description_lines = lines[2:]
288 description = u'\n'.join(description_lines)
290 focus_summary = not summary
291 focus_description = not description
293 # Update summary
294 if not summary and not self.summary.hasFocus():
295 summary = self.summary_placeholder
297 blocksignals = self.summary.blockSignals(True)
298 self.summary.setText(summary)
299 self.summary.setCursorPosition(0)
300 self.summary.blockSignals(blocksignals)
302 # Update description
303 if not description and not self.description.hasFocus():
304 description = self.description_placeholder
306 blocksignals = self.description.blockSignals(True)
307 self.description.setPlainText(description)
308 self.description.blockSignals(blocksignals)
310 # Update text color
311 self.update_placeholder_state()
313 # Focus the empty summary or description
314 if focus_summary:
315 self.summary.setFocus(True)
316 self.emit_summary_position()
317 elif focus_description:
318 self.description.setFocus(True)
319 self.emit_cursor_position()
320 else:
321 self.emit_summary_position()
323 self.update_actions()
325 def setFont(self, font):
326 """Pass the setFont() calls down to the text widgets"""
327 self.summary.setFont(font)
328 self.description.setFont(font)
330 def set_mode(self, mode):
331 checked = (mode == self.model.mode_amend)
332 blocksignals = self.amend_action.blockSignals(True)
333 self.amend_action.setChecked(checked)
334 self.amend_action.blockSignals(blocksignals)
336 def emit_summary_position(self):
337 cols = self.summary.cursorPosition()
338 self.emit(SIGNAL('cursorPosition(int,int)'), 1, cols)
340 def emit_cursor_position(self):
341 """Update the UI with the current row and column."""
342 cursor = self.description.textCursor()
343 position = cursor.position()
344 txt = unicode(self.description.toPlainText())
345 rows = txt[:position].count('\n') + 1 + 2 # description starts at 2
346 cols = cursor.columnNumber()
347 self.emit(SIGNAL('cursorPosition(int,int)'), rows, cols)
349 def commit(self):
350 """Attempt to create a commit from the index and commit message."""
351 if not bool(self.commit_summary()):
352 # Describe a good commit message
353 error_msg = tr(''
354 'Please supply a commit message.\n\n'
355 'A good commit message has the following format:\n\n'
356 '- First line: Describe in one sentence what you did.\n'
357 '- Second line: Blank\n'
358 '- Remaining lines: Describe why this change is good.\n')
359 log(1, error_msg)
360 cola.notifier().broadcast(signals.information,
361 'Missing Commit Message',
362 error_msg)
363 return
365 msg = self.commit_message()
367 if not self.model.staged:
368 error_msg = tr(''
369 'No changes to commit.\n\n'
370 'You must stage at least 1 file before you can commit.')
371 if self.model.modified:
372 informative_text = tr('Would you like to stage and '
373 'commit all modified files?')
374 if not confirm('Stage and commit?',
375 error_msg,
376 informative_text,
377 'Stage and Commit',
378 default=False,
379 icon=save_icon()):
380 return
381 else:
382 cola.notifier().broadcast(signals.information,
383 'Nothing to commit',
384 error_msg)
385 return
386 cola.notifier().broadcast(signals.stage_modified)
388 # Warn that amending published commits is generally bad
389 amend = self.amend_action.isChecked()
390 if (amend and self.model.is_commit_published() and
391 not confirm('Rewrite Published Commit?',
392 'This commit has already been published.\n'
393 'This operation will rewrite published history.\n'
394 'You probably don\'t want to do this.',
395 'Amend the published commit?',
396 'Amend Commit',
397 default=False, icon=save_icon())):
398 return
399 # Perform the commit
400 cola.notifier().broadcast(signals.commit, amend, msg)
402 def build_prev_commits_menu(self):
403 dag = DAG('HEAD', 6)
404 commits = RepoReader(dag)
406 menu_commits = []
407 for idx, c in enumerate(commits):
408 menu_commits.insert(0, c)
409 if idx > 5:
410 continue
412 menu = self.prev_commits_menu
413 menu.clear()
414 for c in menu_commits:
415 menu.addAction(c.summary,
416 lambda c=c: self.load_previous_message(c.sha1))
418 if len(commits) == 6:
419 menu.addSeparator()
420 menu.addAction('More...', self.choose_commit)
422 def choose_commit(self):
423 revs, summaries = gitcmds.log_helper()
424 sha1s = select_commits('Select Commit Message', revs, summaries,
425 multiselect=False)
426 if not sha1s:
427 return
428 sha1 = sha1s[0]
429 self.load_previous_message(sha1)
431 def load_previous_message(self, sha1):
432 self.emit(SIGNAL(signals.load_previous_message), sha1)
435 class CommitMessageTextEdit(QtGui.QTextEdit):
436 def __init__(self, parent=None):
437 QtGui.QTextEdit.__init__(self, parent)
438 self.setLineWrapMode(QtGui.QTextEdit.NoWrap)
439 self.setAcceptRichText(False)
440 self.setMinimumSize(QtCore.QSize(1, 1))
442 self.action_emit_shift_tab = add_action(self,
443 'Shift Tab', self.shift_tab, 'Shift+tab')
445 self.installEventFilter(self)
447 def eventFilter(self, obj, event):
448 if event.type() == QtCore.QEvent.FocusIn:
449 height = QtGui.QFontMetrics(self.font()).height() * 3
450 height += defs.spacing * 4
451 self.setMinimumSize(QtCore.QSize(1, height))
453 elif event.type() == QtCore.QEvent.FocusOut:
454 self.setMinimumSize(QtCore.QSize(1, 1))
456 return False
458 def keyPressEvent(self, event):
459 if event.key() == Qt.Key_Up:
460 cursor = self.textCursor()
461 position = cursor.position()
462 text_before = unicode(self.toPlainText())[:position]
463 lines_before = text_before.count('\n')
464 if lines_before == 0:
465 cursor.setPosition(0)
466 self.setTextCursor(cursor)
467 event.ignore()
468 return
469 elif event.key() == Qt.Key_Down:
470 cursor = self.textCursor()
471 position = cursor.position()
472 all_text = unicode(self.toPlainText())
473 text_after = all_text[position:]
474 lines_after = text_after.count('\n')
475 if lines_after == 0:
476 cursor.setPosition(len(all_text))
477 self.setTextCursor(cursor)
478 event.ignore()
479 return
480 super(CommitMessageTextEdit, self).keyPressEvent(event)
482 def shift_tab(self):
483 self.emit(SIGNAL('shiftTab()'))