guicmds: Replace repobrowser with the simpler browse dialog
[git-cola.git] / cola / qt.py
blob1b49a8cbdcae4672035bb8e85f42b6accb90e345
1 import re
2 import subprocess
4 from PyQt4 import QtGui
5 from PyQt4 import QtCore
6 from PyQt4.QtCore import Qt
7 from PyQt4.QtCore import QVariant
8 from PyQt4.QtCore import SIGNAL
9 from PyQt4.QtGui import QFont
10 from PyQt4.QtGui import QSyntaxHighlighter
11 from PyQt4.QtGui import QTextCharFormat
12 from PyQt4.QtGui import QColor
13 try:
14 from PyQt4.QtCore import pyqtProperty
15 except ImportError:
16 pyqtProperty = None
18 import cola
19 from cola import resources
20 from cola.compat import set
21 from cola import utils
22 from cola import qtutils
23 from cola.qtutils import tr
26 def create_button(text, layout=None, tooltip=None, icon=None):
27 """Create a button, set its title, and add it to the parent."""
28 button = QtGui.QPushButton()
29 button.setText(tr(text))
30 if icon:
31 button.setIcon(icon)
32 if layout is not None:
33 layout.addWidget(button)
34 return button
37 def create_dock(title, parent):
38 """Create a dock widget and set it up accordingly."""
39 dock = QtGui.QDockWidget(parent)
40 dock.setWindowTitle(tr(title))
41 dock.setObjectName(title)
42 return dock
45 def create_menu(title, parent):
46 """Create a menu and set its title."""
47 qmenu = QtGui.QMenu(parent)
48 qmenu.setTitle(tr(title))
49 return qmenu
52 def create_toolbutton(parent, text=None, layout=None, tooltip=None, icon=None):
53 button = QtGui.QToolButton(parent)
54 button.setAutoRaise(True)
55 button.setAutoFillBackground(True)
56 if icon:
57 button.setIcon(icon)
58 if text:
59 button.setText(tr(text))
60 button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
61 if tooltip:
62 button.setToolTip(tr(tooltip))
63 if layout is not None:
64 layout.addWidget(button)
65 return button
68 class QFlowLayoutWidget(QtGui.QWidget):
70 _horizontal = QtGui.QBoxLayout.LeftToRight
71 _vertical = QtGui.QBoxLayout.TopToBottom
73 def __init__(self, parent=None):
74 QtGui.QWidget.__init__(self, parent)
75 self._direction = self._vertical
76 self._layout = layout = QtGui.QBoxLayout(self._direction)
77 layout.setSpacing(2)
78 layout.setMargin(2)
79 self.setLayout(layout)
80 self.setContentsMargins(2, 2, 2, 2)
81 policy = QtGui.QSizePolicy(QtGui.QSizePolicy.Minimum,
82 QtGui.QSizePolicy.Minimum)
83 self.setSizePolicy(policy)
84 self.setMinimumSize(QtCore.QSize(1, 1))
86 def resizeEvent(self, event):
87 size = event.size()
88 if size.width() * 0.8 < size.height():
89 dxn = self._vertical
90 else:
91 dxn = self._horizontal
93 if dxn != self._direction:
94 self._direction = dxn
95 self.layout().setDirection(dxn)
98 class QCollapsibleGroupBox(QtGui.QGroupBox):
99 def __init__(self, parent=None):
100 QtGui.QGroupBox.__init__(self, parent)
101 self.setFlat(True)
102 self.collapsed = False
103 self.click_pos = None
104 self.collapse_icon_size = 16
106 def set_collapsed(self, collapsed):
107 self.collapsed = collapsed
108 for widget in self.findChildren(QtGui.QWidget):
109 widget.setHidden(collapsed)
110 self.emit(SIGNAL('toggled(bool)'), collapsed)
112 def mousePressEvent(self, event):
113 if event.button() == QtCore.Qt.LeftButton:
114 option = QtGui.QStyleOptionGroupBox()
115 self.initStyleOption(option)
116 icon_size = self.collapse_icon_size
117 button_area = QtCore.QRect(0, 0, icon_size, icon_size)
118 top_left = option.rect.adjusted(0, 0, -10, 0).topLeft()
119 button_area.moveTopLeft(QtCore.QPoint(top_left))
120 self.click_pos = event.pos()
121 QtGui.QGroupBox.mousePressEvent(self, event)
123 def mouseReleaseEvent(self, event):
124 if (event.button() == QtCore.Qt.LeftButton and
125 self.click_pos == event.pos()):
126 self.set_collapsed(not self.collapsed)
127 QtGui.QGroupBox.mouseReleaseEvent(self, event)
129 def paintEvent(self, event):
130 painter = QtGui.QStylePainter(self)
131 option = QtGui.QStyleOptionGroupBox()
132 self.initStyleOption(option)
133 painter.save()
134 painter.translate(self.collapse_icon_size + 4, 0)
135 painter.drawComplexControl(QtGui.QStyle.CC_GroupBox, option)
136 painter.restore()
138 style = QtGui.QStyle
139 point = option.rect.adjusted(0, 0, -10, 0).topLeft()
140 icon_size = self.collapse_icon_size
141 option.rect = QtCore.QRect(point.x(), point.y(), icon_size, icon_size)
142 if self.collapsed:
143 painter.drawPrimitive(style.PE_IndicatorArrowRight, option)
144 else:
145 painter.drawPrimitive(style.PE_IndicatorArrowDown, option)
148 class GitRefCompleter(QtGui.QCompleter):
149 """Provides completion for branches and tags"""
150 def __init__(self, parent):
151 QtGui.QCompleter.__init__(self, parent)
152 self._model = GitRefModel(parent)
153 self.setModel(self._model)
154 self.setCompletionMode(self.UnfilteredPopupCompletion)
155 self.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
157 def __del__(self):
158 self.dispose()
160 def dispose(self):
161 self._model.dispose()
164 class GitRefLineEdit(QtGui.QLineEdit):
165 def __init__(self, parent=None):
166 QtGui.QLineEdit.__init__(self, parent)
167 self.refcompleter = GitRefCompleter(self)
168 self.setCompleter(self.refcompleter)
171 class GitRefModel(QtGui.QStandardItemModel):
172 def __init__(self, parent):
173 QtGui.QStandardItemModel.__init__(self, parent)
174 self.cmodel = cola.model()
175 msg = self.cmodel.message_updated
176 self.cmodel.add_message_observer(msg, self.update_matches)
177 self.update_matches()
179 def dispose(self):
180 self.cmodel.remove_observer(self.update_matches)
182 def update_matches(self):
183 model = self.cmodel
184 matches = model.local_branches + model.remote_branches + model.tags
185 QStandardItem = QtGui.QStandardItem
186 self.clear()
187 for match in matches:
188 item = QStandardItem()
189 item.setIcon(QtGui.QIcon(resources.icon('git.svg')))
190 item.setText(match)
191 self.appendRow(item)
194 class GitLogCompletionModel(QtGui.QStandardItemModel):
195 def __init__(self, parent):
196 self.matched_text = None
197 QtGui.QStandardItemModel.__init__(self, parent)
198 self.cmodel = cola.model()
200 def lower_cmp(self, a, b):
201 return cmp(a.replace('.','').lower(), b.replace('.','').lower())
203 def update_matches(self, case_sensitive):
204 QStandardItem = QtGui.QStandardItem
205 file_list = self.cmodel.everything()
206 files = set(file_list)
207 files_and_dirs = utils.add_parents(set(files))
208 dirs = files_and_dirs.difference(files)
210 file_icon = qtutils.file_icon()
211 dir_icon = qtutils.dir_icon()
212 git_icon = QtGui.QIcon(resources.icon('git.svg'))
214 model = self.cmodel
215 refs = model.local_branches + model.remote_branches + model.tags
216 matched_text = self.matched_text
218 if matched_text:
219 if case_sensitive:
220 matched_refs = [r for r in refs if matched_text in r]
221 else:
222 matched_refs = [r for r in refs
223 if matched_text.lower() in r.lower()]
224 else:
225 matched_refs = refs
227 matched_refs.sort(cmp=self.lower_cmp)
229 if matched_text:
230 if case_sensitive:
231 matched_paths = [f for f in files_and_dirs
232 if matched_text in f]
233 else:
234 matched_paths = [f for f in files_and_dirs
235 if matched_text.lower() in f.lower()]
236 else:
237 matched_paths = list(files_and_dirs)
239 matched_paths.sort(cmp=self.lower_cmp)
241 items = []
243 for ref in matched_refs:
244 item = QStandardItem()
245 item.setText(ref)
246 item.setIcon(git_icon)
247 items.append(item)
249 if matched_paths and (not matched_text or matched_text in '--'):
250 item = QStandardItem()
251 item.setText('--')
252 item.setIcon(file_icon)
253 items.append(item)
255 for match in matched_paths:
256 item = QStandardItem()
257 item.setText(match)
258 if match in dirs:
259 item.setIcon(dir_icon)
260 else:
261 item.setIcon(file_icon)
262 items.append(item)
264 self.clear()
265 for item in items:
266 self.appendRow(item)
268 def set_match_text(self, text, case_sensitive):
269 self.matched_text = text
270 self.update_matches(case_sensitive)
273 class GitLogLineEdit(QtGui.QLineEdit):
274 def __init__(self, parent=None):
275 QtGui.QLineEdit.__init__(self, parent)
277 self._model = GitLogCompletionModel(self)
278 self._delegate = HighlightCompletionDelegate(self)
280 self._completer = QtGui.QCompleter(self)
281 self._completer.setWidget(self)
282 self._completer.setModel(self._model)
283 self._completer.setCompletionMode(
284 QtGui.QCompleter.UnfilteredPopupCompletion)
285 self._completer.popup().setItemDelegate(self._delegate)
287 self.connect(self._completer, SIGNAL('activated(QString)'),
288 self._complete)
289 self.connect(self, SIGNAL('textChanged(QString)'), self._text_changed)
290 self._keys_to_ignore = set([QtCore.Qt.Key_Enter,
291 QtCore.Qt.Key_Return,
292 QtCore.Qt.Key_Escape])
294 def _text_changed(self, text):
295 text = self.last_word()
296 case_sensitive = bool([char for char in text if char.isupper()])
297 if case_sensitive:
298 self._completer.setCaseSensitivity(QtCore.Qt.CaseSensitive)
299 else:
300 self._completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
301 self._delegate.set_highlight_text(text, case_sensitive)
302 self._model.set_match_text(text, case_sensitive)
304 def _complete(self, completion):
306 This is the event handler for the QCompleter.activated(QString) signal,
307 it is called when the user selects an item in the completer popup.
309 if not completion:
310 return
311 words = self.words()
312 if words:
313 words.pop()
314 words.append(unicode(completion))
315 self.setText(subprocess.list2cmdline(words))
316 self.emit(SIGNAL('ref_changed'))
318 def words(self):
319 return utils.shell_usplit(unicode(self.text()))
321 def last_word(self):
322 words = self.words()
323 if not words:
324 return self.text()
325 if not words[-1]:
326 return u''
327 return words[-1]
329 def event(self, event):
330 if event.type() == QtCore.QEvent.KeyPress:
331 if (event.key() == QtCore.Qt.Key_Tab and
332 self._completer.popup().isVisible()):
333 event.ignore()
334 return True
335 return QtGui.QLineEdit.event(self, event)
337 def keyPressEvent(self, event):
338 if self._completer.popup().isVisible():
339 if event.key() in self._keys_to_ignore:
340 event.ignore()
341 self._complete(self.last_word())
342 return
344 elif (event.key() == QtCore.Qt.Key_Down and
345 self._completer.completionCount() > 0):
346 event.accept()
347 self._completer.popup().setCurrentIndex(
348 self._completer.completionModel().index(0,0))
349 self._completer.complete()
350 return
352 QtGui.QLineEdit.keyPressEvent(self, event)
354 prefix = self.last_word()
355 if prefix != self._completer.completionPrefix():
356 self._update_popup_items(prefix)
357 if len(event.text()) > 0 and len(prefix) > 0:
358 self._completer.complete()
359 if len(prefix) == 0:
360 self._completer.popup().hide()
362 def _update_popup_items(self, prefix):
364 Filters the completer's popup items to only show items
365 with the given prefix.
367 self._completer.setCompletionPrefix(prefix)
368 self._completer.popup().setCurrentIndex(
369 self._completer.completionModel().index(0,0))
372 class HighlightCompletionDelegate(QtGui.QStyledItemDelegate):
373 """A delegate used for auto-completion to give formatted completion"""
374 def __init__(self, parent=None): # model, parent=None):
375 QtGui.QStyledItemDelegate.__init__(self, parent)
376 self.highlight_text = ''
377 self.case_sensitive = False
379 self.doc = QtGui.QTextDocument()
380 self.doc.setDocumentMargin(0)
382 def set_highlight_text(self, text, case_sensitive):
383 """Sets the text that will be made bold in the term name when displayed"""
384 self.highlight_text = text
385 self.case_sensitive = case_sensitive
387 def paint(self, painter, option, index):
388 """Overloaded Qt method for custom painting of a model index"""
389 if not self.highlight_text:
390 return QtGui.QStyledItemDelegate.paint(self, painter, option, index)
392 text = unicode(index.data().toPyObject())
393 if self.case_sensitive:
394 html = text.replace(self.highlight_text,
395 '<strong>%s</strong>' % self.highlight_text)
396 else:
397 match = re.match('(.*)(' + self.highlight_text + ')(.*)',
398 text, re.IGNORECASE)
399 if match:
400 start = match.group(1) or ''
401 middle = match.group(2) or ''
402 end = match.group(3) or ''
403 html = (start + ('<strong>%s</strong>' % middle) + end)
404 else:
405 html = text
406 self.doc.setHtml(html)
408 # Painting item without text, Text Document will paint the text
409 optionV4 = QtGui.QStyleOptionViewItemV4(option)
410 self.initStyleOption(optionV4, index)
411 optionV4.text = QtCore.QString()
413 style = QtGui.QApplication.style()
414 style.drawControl(QtGui.QStyle.CE_ItemViewItem, optionV4, painter)
415 ctx = QtGui.QAbstractTextDocumentLayout.PaintContext()
417 # Highlighting text if item is selected
418 if (optionV4.state & QtGui.QStyle.State_Selected):
419 ctx.palette.setColor(QtGui.QPalette.Text, optionV4.palette.color(QtGui.QPalette.Active, QtGui.QPalette.HighlightedText))
421 # translate the painter to where the text is drawn
422 textRect = style.subElementRect(QtGui.QStyle.SE_ItemViewItemText, optionV4)
423 painter.save()
425 start = textRect.topLeft() + QtCore.QPoint(3, 0)
426 painter.translate(start)
427 painter.setClipRect(textRect.translated(-start))
429 # tell the text document to draw the html for us
430 self.doc.documentLayout().draw(painter, ctx)
431 painter.restore()
433 # Syntax highlighting
435 def TERMINAL(pattern):
437 Denotes that a pattern is the final pattern that should
438 be matched. If this pattern matches no other formats
439 will be applied, even if they would have matched.
441 return '__TERMINAL__:%s' % pattern
443 # Cache the results of re.compile so that we don't keep
444 # rebuilding the same regexes whenever stylesheets change
445 _RGX_CACHE = {}
447 default_colors = {}
448 def _install_default_colors():
449 def color(c, a=255):
450 qc = QColor(c)
451 qc.setAlpha(a)
452 return qc
453 default_colors.update({
454 'color_add': color(Qt.green, 128),
455 'color_remove': color(Qt.red, 128),
456 'color_begin': color(Qt.darkCyan),
457 'color_header': color(Qt.darkYellow),
458 'color_stat_add': color(QColor(32, 255, 32)),
459 'color_stat_info': color(QColor(32, 32, 255)),
460 'color_stat_remove': color(QColor(255, 32, 32)),
461 'color_emphasis': color(Qt.black),
462 'color_info': color(Qt.blue),
463 'color_date': color(Qt.darkCyan),
465 _install_default_colors()
468 class GenericSyntaxHighligher(QSyntaxHighlighter):
469 def __init__(self, doc, *args, **kwargs):
470 QSyntaxHighlighter.__init__(self, doc)
471 for attr, val in default_colors.items():
472 setattr(self, attr, val)
473 self._rules = []
474 self.generate_rules()
475 self.reset()
477 def reset(self):
478 self._rules = []
479 self.generate_rules()
481 def generate_rules(self):
482 pass
484 def create_rules(self, *rules):
485 if len(rules) % 2:
486 raise Exception('create_rules requires an even '
487 'number of arguments.')
488 for idx, rule in enumerate(rules):
489 if idx % 2:
490 continue
491 formats = rules[idx+1]
492 terminal = rule.startswith(TERMINAL(''))
493 if terminal:
494 rule = rule[len(TERMINAL('')):]
495 try:
496 regex = _RGX_CACHE[rule]
497 except KeyError:
498 regex = _RGX_CACHE[rule] = re.compile(rule)
499 self._rules.append((regex, formats, terminal,))
501 def formats(self, line):
502 matched = []
503 for regex, fmts, terminal in self._rules:
504 match = regex.match(line)
505 if not match:
506 continue
507 matched.append([match, fmts])
508 if terminal:
509 return matched
510 return matched
512 def mkformat(self, fg=None, bg=None, bold=False):
513 fmt = QTextCharFormat()
514 if fg:
515 fmt.setForeground(fg)
516 if bg:
517 fmt.setBackground(bg)
518 if bold:
519 fmt.setFontWeight(QFont.Bold)
520 return fmt
522 def highlightBlock(self, qstr):
523 ascii = unicode(qstr)
524 if not ascii:
525 return
526 formats = self.formats(ascii)
527 if not formats:
528 return
529 for match, fmts in formats:
530 start = match.start()
531 end = match.end()
532 groups = match.groups()
534 # No groups in the regex, assume this is a single rule
535 # that spans the entire line
536 if not groups:
537 self.setFormat(0, len(ascii), fmts)
538 continue
540 # Groups exist, rule is a tuple corresponding to group
541 for grpidx, group in enumerate(groups):
542 # allow empty matches
543 if not group:
544 continue
545 # allow None as a no-op format
546 length = len(group)
547 if fmts[grpidx]:
548 self.setFormat(start, start+length,
549 fmts[grpidx])
550 start += length
552 def set_colors(self, colordict):
553 for attr, val in colordict.items():
554 setattr(self, attr, val)
557 class DiffSyntaxHighlighter(GenericSyntaxHighligher):
558 """Implements the diff syntax highlighting
560 This class is used by widgets that display diffs.
563 def __init__(self, doc, whitespace=True):
564 self.whitespace = whitespace
565 GenericSyntaxHighligher.__init__(self, doc)
567 def generate_rules(self):
568 diff_begin = self.mkformat(self.color_begin, bold=True)
569 diff_head = self.mkformat(self.color_header)
570 diff_add = self.mkformat(bg=self.color_add)
571 diff_remove = self.mkformat(bg=self.color_remove)
573 diffstat_info = self.mkformat(self.color_stat_info, bold=True)
574 diffstat_add = self.mkformat(self.color_stat_add, bold=True)
575 diffstat_remove = self.mkformat(self.color_stat_remove, bold=True)
577 if self.whitespace:
578 bad_ws = self.mkformat(Qt.black, Qt.red)
580 # We specify the whitespace rule last so that it is
581 # applied after the diff addition/removal rules.
582 # The rules for the header
583 diff_bgn_rgx = TERMINAL('^@@|^\+\+\+|^---')
584 diff_hd1_rgx = TERMINAL('^diff --git')
585 diff_hd2_rgx = TERMINAL('^index \S+\.\.\S+')
586 diff_hd3_rgx = TERMINAL('^new file mode')
587 diff_add_rgx = TERMINAL('^\+')
588 diff_rmv_rgx = TERMINAL('^-')
589 diff_sts_rgx = ('(.+\|.+?)(\d+)(.+?)([\+]*?)([-]*?)$')
590 diff_sum_rgx = ('(\s+\d+ files changed[^\d]*)'
591 '(:?\d+ insertions[^\d]*)'
592 '(:?\d+ deletions.*)$')
594 self.create_rules(diff_bgn_rgx, diff_begin,
595 diff_hd1_rgx, diff_head,
596 diff_hd2_rgx, diff_head,
597 diff_hd3_rgx, diff_head,
598 diff_add_rgx, diff_add,
599 diff_rmv_rgx, diff_remove,
600 diff_sts_rgx, (None, diffstat_info,
601 None, diffstat_add,
602 diffstat_remove),
603 diff_sum_rgx, (diffstat_info,
604 diffstat_add,
605 diffstat_remove))
606 if self.whitespace:
607 self.create_rules('(..*?)(\s+)$', (None, bad_ws))
610 # This is used as a mixin to generate property callbacks
611 def accessors(attr):
612 private_attr = '_'+attr
614 def getter(self):
615 return self.__dict__.get(private_attr, None)
617 def setter(self, value):
618 self.__dict__[private_attr] = value
619 self.reset_syntax()
620 return (getter, setter)
622 def install_style_properties(cls):
623 # Diff GUI colors -- this is controllable via the style sheet
624 if pyqtProperty is None:
625 return
626 for name in default_colors:
627 setattr(cls, name, pyqtProperty('QColor', *accessors(name)))
629 def set_theme_properties(widget):
630 for name, color in default_colors.items():
631 widget.setProperty(name, QVariant(color))
634 if __name__ == '__main__':
635 import sys
636 class SyntaxTestDialog(QtGui.QDialog):
637 def __init__(self, parent):
638 QtGui.QDialog.__init__(self, parent)
639 self.resize(720, 512)
640 self.vboxlayout = QtGui.QVBoxLayout(self)
641 self.vboxlayout.setObjectName('vboxlayout')
642 self.output_text = QtGui.QTextEdit(self)
643 font = QtGui.QFont()
644 if utils.is_darwin():
645 family = 'Monaco'
646 else:
647 family = 'Monospace'
648 font.setFamily(family)
649 font.setPointSize(13)
650 self.output_text.setFont(font)
651 self.output_text.setAcceptDrops(False)
652 self.vboxlayout.addWidget(self.output_text)
653 self.syntax = DiffSyntaxHighlighter(self.output_text.document())
655 app = QtGui.QApplication(sys.argv)
656 dialog = SyntaxTestDialog(app.activeWindow())
657 dialog.show()
658 dialog.raise_()
659 app.exec_()