widgets.commitmsg: Jump to the next line only when at EOL
[git-cola.git] / cola / widgets / commitmsg.py
blob3f32657981e00e2ba0f9f3d6224d3cb5ad920ca2
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 = CommitSummaryLineEdit()
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.focus_description)
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 focus_description(self):
199 self.description.setFocus(True)
201 def commit_summary(self):
202 """Return the commit summary as unicode"""
203 summary = unicode(self.summary.text()).strip()
204 if summary != self.summary_placeholder:
205 return summary
206 else:
207 return u''
209 def commit_description(self):
210 """Return the commit description as unicode"""
211 description = unicode(self.description.toPlainText()).strip()
212 if description != self.description_placeholder:
213 return description
214 else:
215 return u''
217 def commit_message(self):
218 """Return the commit message as a unicode string"""
219 summary = self.commit_summary()
220 description = self.commit_description()
221 if summary and description:
222 return summary + u'\n\n' + description
223 elif summary:
224 return summary
225 elif description:
226 return u'\n\n' + description
227 else:
228 return u''
230 def commit_message_changed(self, value=None):
231 """Update the model when values change"""
232 self.notifying = True
233 message = self.commit_message()
234 self.model.set_commitmsg(message)
235 self.update_placeholder_state()
236 self.notifying = False
237 self.update_actions()
239 def update_actions(self):
240 commit_enabled = bool(self.commit_summary())
241 self.commit_button.setEnabled(commit_enabled)
242 self.commit_action.setEnabled(commit_enabled)
244 def update_placeholder_state(self):
245 """Update the color palette for the placeholder text"""
246 self.set_placeholder_palette(self.summary,
247 self.is_summary_placeholder())
248 self.set_placeholder_palette(self.description,
249 self.is_description_placeholder())
251 def set_commit_message(self, message):
252 """Set the commit message to match the observed model"""
253 if self.notifying:
254 # Calling self.model.set_commitmsg(message) causes us to
255 # loop around so break the loop
256 return
258 # Parse the "summary" and "description" fields
259 umsg = unicode(message)
260 lines = umsg.splitlines()
262 num_lines = len(lines)
264 if num_lines == 0:
265 # Message is empty
266 summary = u''
267 description = u''
269 elif num_lines == 1:
270 # Message has a summary only
271 summary = lines[0]
272 description = u''
274 elif num_lines == 2:
275 # Message has two lines; this is not a common case
276 summary = lines[0]
277 description = lines[1]
279 else:
280 # Summary and several description lines
281 summary = lines[0]
282 if lines[1]:
283 # We usually skip this line but check just in case
284 description_lines = lines[1:]
285 else:
286 description_lines = lines[2:]
287 description = u'\n'.join(description_lines)
289 focus_summary = not summary
290 focus_description = not description
292 # Update summary
293 if not summary and not self.summary.hasFocus():
294 summary = self.summary_placeholder
296 blocksignals = self.summary.blockSignals(True)
297 self.summary.setText(summary)
298 self.summary.setCursorPosition(0)
299 self.summary.blockSignals(blocksignals)
301 # Update description
302 if not description and not self.description.hasFocus():
303 description = self.description_placeholder
305 blocksignals = self.description.blockSignals(True)
306 self.description.setPlainText(description)
307 self.description.blockSignals(blocksignals)
309 # Update text color
310 self.update_placeholder_state()
312 # Focus the empty summary or description
313 if focus_summary:
314 self.summary.setFocus(True)
315 self.emit_summary_position()
316 elif focus_description:
317 self.description.setFocus(True)
318 self.emit_cursor_position()
319 else:
320 self.emit_summary_position()
322 self.update_actions()
324 def setFont(self, font):
325 """Pass the setFont() calls down to the text widgets"""
326 self.summary.setFont(font)
327 self.description.setFont(font)
329 def set_mode(self, mode):
330 checked = (mode == self.model.mode_amend)
331 blocksignals = self.amend_action.blockSignals(True)
332 self.amend_action.setChecked(checked)
333 self.amend_action.blockSignals(blocksignals)
335 def emit_summary_position(self):
336 cols = self.summary.cursorPosition()
337 self.emit(SIGNAL('cursorPosition(int,int)'), 1, cols)
339 def emit_cursor_position(self):
340 """Update the UI with the current row and column."""
341 cursor = self.description.textCursor()
342 position = cursor.position()
343 txt = unicode(self.description.toPlainText())
344 rows = txt[:position].count('\n') + 1 + 2 # description starts at 2
345 cols = cursor.columnNumber()
346 self.emit(SIGNAL('cursorPosition(int,int)'), rows, cols)
348 def commit(self):
349 """Attempt to create a commit from the index and commit message."""
350 if not bool(self.commit_summary()):
351 # Describe a good commit message
352 error_msg = tr(''
353 'Please supply a commit message.\n\n'
354 'A good commit message has the following format:\n\n'
355 '- First line: Describe in one sentence what you did.\n'
356 '- Second line: Blank\n'
357 '- Remaining lines: Describe why this change is good.\n')
358 log(1, error_msg)
359 cola.notifier().broadcast(signals.information,
360 'Missing Commit Message',
361 error_msg)
362 return
364 msg = self.commit_message()
366 if not self.model.staged:
367 error_msg = tr(''
368 'No changes to commit.\n\n'
369 'You must stage at least 1 file before you can commit.')
370 if self.model.modified:
371 informative_text = tr('Would you like to stage and '
372 'commit all modified files?')
373 if not confirm('Stage and commit?',
374 error_msg,
375 informative_text,
376 'Stage and Commit',
377 default=False,
378 icon=save_icon()):
379 return
380 else:
381 cola.notifier().broadcast(signals.information,
382 'Nothing to commit',
383 error_msg)
384 return
385 cola.notifier().broadcast(signals.stage_modified)
387 # Warn that amending published commits is generally bad
388 amend = self.amend_action.isChecked()
389 if (amend and self.model.is_commit_published() and
390 not confirm('Rewrite Published Commit?',
391 'This commit has already been published.\n'
392 'This operation will rewrite published history.\n'
393 'You probably don\'t want to do this.',
394 'Amend the published commit?',
395 'Amend Commit',
396 default=False, icon=save_icon())):
397 return
398 # Perform the commit
399 cola.notifier().broadcast(signals.commit, amend, msg)
401 def build_prev_commits_menu(self):
402 dag = DAG('HEAD', 6)
403 commits = RepoReader(dag)
405 menu_commits = []
406 for idx, c in enumerate(commits):
407 menu_commits.insert(0, c)
408 if idx > 5:
409 continue
411 menu = self.prev_commits_menu
412 menu.clear()
413 for c in menu_commits:
414 menu.addAction(c.summary,
415 lambda c=c: self.load_previous_message(c.sha1))
417 if len(commits) == 6:
418 menu.addSeparator()
419 menu.addAction('More...', self.choose_commit)
421 def choose_commit(self):
422 revs, summaries = gitcmds.log_helper()
423 sha1s = select_commits('Select Commit Message', revs, summaries,
424 multiselect=False)
425 if not sha1s:
426 return
427 sha1 = sha1s[0]
428 self.load_previous_message(sha1)
430 def load_previous_message(self, sha1):
431 self.emit(SIGNAL(signals.load_previous_message), sha1)
434 class CommitSummaryLineEdit(QtGui.QLineEdit):
435 def __init__(self, parent=None):
436 super(CommitSummaryLineEdit, self).__init__(parent)
438 def keyPressEvent(self, event):
439 if event.key() == Qt.Key_Down:
440 position = self.cursorPosition()
441 curtext = unicode(self.text())
442 if position == len(curtext):
443 self.emit(SIGNAL('returnPressed()'))
444 event.ignore()
445 return
446 super(CommitSummaryLineEdit, self).keyPressEvent(event)
449 class CommitMessageTextEdit(QtGui.QTextEdit):
450 def __init__(self, parent=None):
451 QtGui.QTextEdit.__init__(self, parent)
452 self.setLineWrapMode(QtGui.QTextEdit.NoWrap)
453 self.setAcceptRichText(False)
454 self.setMinimumSize(QtCore.QSize(1, 1))
456 self.action_emit_shift_tab = add_action(self,
457 'Shift Tab', self.emit_shift_tab, 'Shift+tab')
459 self.installEventFilter(self)
461 def eventFilter(self, obj, event):
462 if event.type() == QtCore.QEvent.FocusIn:
463 height = QtGui.QFontMetrics(self.font()).height() * 3
464 height += defs.spacing * 4
465 self.setMinimumSize(QtCore.QSize(1, height))
467 elif event.type() == QtCore.QEvent.FocusOut:
468 self.setMinimumSize(QtCore.QSize(1, 1))
470 return False
472 def keyPressEvent(self, event):
473 if event.key() == Qt.Key_Up:
474 cursor = self.textCursor()
475 position = cursor.position()
476 if position == 0:
477 self.emit_shift_tab()
478 event.ignore()
479 return
480 text_before = unicode(self.toPlainText())[:position]
481 lines_before = text_before.count('\n')
482 if lines_before == 0:
483 cursor.setPosition(0)
484 self.setTextCursor(cursor)
485 event.ignore()
486 return
487 elif event.key() == Qt.Key_Down:
488 cursor = self.textCursor()
489 position = cursor.position()
490 all_text = unicode(self.toPlainText())
491 text_after = all_text[position:]
492 lines_after = text_after.count('\n')
493 if lines_after == 0:
494 cursor.setPosition(len(all_text))
495 self.setTextCursor(cursor)
496 event.ignore()
497 return
498 super(CommitMessageTextEdit, self).keyPressEvent(event)
500 def emit_shift_tab(self):
501 self.emit(SIGNAL('shiftTab()'))