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
14 from PyQt4
.QtCore
import pyqtProperty
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
))
32 if layout
is not None:
33 layout
.addWidget(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
)
45 def create_menu(title
, parent
):
46 """Create a menu and set its title."""
47 qmenu
= QtGui
.QMenu(parent
)
48 qmenu
.setTitle(tr(title
))
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)
59 button
.setText(tr(text
))
60 button
.setToolButtonStyle(Qt
.ToolButtonTextBesideIcon
)
62 button
.setToolTip(tr(tooltip
))
63 if layout
is not None:
64 layout
.addWidget(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
)
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
):
88 if size
.width() * 0.8 < size
.height():
91 dxn
= self
._horizontal
93 if dxn
!= self
._direction
:
95 self
.layout().setDirection(dxn
)
98 class QCollapsibleGroupBox(QtGui
.QGroupBox
):
99 def __init__(self
, parent
=None):
100 QtGui
.QGroupBox
.__init
__(self
, parent
)
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
)
134 painter
.translate(self
.collapse_icon_size
+ 4, 0)
135 painter
.drawComplexControl(QtGui
.QStyle
.CC_GroupBox
, option
)
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
)
143 painter
.drawPrimitive(style
.PE_IndicatorArrowRight
, option
)
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
)
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()
180 self
.cmodel
.remove_observer(self
.update_matches
)
182 def update_matches(self
):
184 matches
= model
.local_branches
+ model
.remote_branches
+ model
.tags
185 QStandardItem
= QtGui
.QStandardItem
187 for match
in matches
:
188 item
= QStandardItem()
189 item
.setIcon(QtGui
.QIcon(resources
.icon('git.svg')))
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'))
215 refs
= model
.local_branches
+ model
.remote_branches
+ model
.tags
216 matched_text
= self
.matched_text
220 matched_refs
= [r
for r
in refs
if matched_text
in r
]
222 matched_refs
= [r
for r
in refs
223 if matched_text
.lower() in r
.lower()]
227 matched_refs
.sort(cmp=self
.lower_cmp
)
231 matched_paths
= [f
for f
in files_and_dirs
232 if matched_text
in f
]
234 matched_paths
= [f
for f
in files_and_dirs
235 if matched_text
.lower() in f
.lower()]
237 matched_paths
= list(files_and_dirs
)
239 matched_paths
.sort(cmp=self
.lower_cmp
)
243 for ref
in matched_refs
:
244 item
= QStandardItem()
246 item
.setIcon(git_icon
)
249 if matched_paths
and (not matched_text
or matched_text
in '--'):
250 item
= QStandardItem()
252 item
.setIcon(file_icon
)
255 for match
in matched_paths
:
256 item
= QStandardItem()
259 item
.setIcon(dir_icon
)
261 item
.setIcon(file_icon
)
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)'),
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()])
298 self
._completer
.setCaseSensitivity(QtCore
.Qt
.CaseSensitive
)
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.
314 words
.append(unicode(completion
))
315 self
.setText(subprocess
.list2cmdline(words
))
316 self
.emit(SIGNAL('ref_changed'))
319 return utils
.shell_usplit(unicode(self
.text()))
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()):
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
:
341 self
._complete
(self
.last_word())
344 elif (event
.key() == QtCore
.Qt
.Key_Down
and
345 self
._completer
.completionCount() > 0):
347 self
._completer
.popup().setCurrentIndex(
348 self
._completer
.completionModel().index(0,0))
349 self
._completer
.complete()
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()
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
)
397 match
= re
.match('(.*)(' + self
.highlight_text
+ ')(.*)',
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
)
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
)
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
)
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
448 def _install_default_colors():
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
)
474 self
.generate_rules()
479 self
.generate_rules()
481 def generate_rules(self
):
484 def create_rules(self
, *rules
):
486 raise Exception('create_rules requires an even '
487 'number of arguments.')
488 for idx
, rule
in enumerate(rules
):
491 formats
= rules
[idx
+1]
492 terminal
= rule
.startswith(TERMINAL(''))
494 rule
= rule
[len(TERMINAL('')):]
496 regex
= _RGX_CACHE
[rule
]
498 regex
= _RGX_CACHE
[rule
] = re
.compile(rule
)
499 self
._rules
.append((regex
, formats
, terminal
,))
501 def formats(self
, line
):
503 for regex
, fmts
, terminal
in self
._rules
:
504 match
= regex
.match(line
)
507 matched
.append([match
, fmts
])
512 def mkformat(self
, fg
=None, bg
=None, bold
=False):
513 fmt
= QTextCharFormat()
515 fmt
.setForeground(fg
)
517 fmt
.setBackground(bg
)
519 fmt
.setFontWeight(QFont
.Bold
)
522 def highlightBlock(self
, qstr
):
523 ascii
= unicode(qstr
)
526 formats
= self
.formats(ascii
)
529 for match
, fmts
in formats
:
530 start
= match
.start()
532 groups
= match
.groups()
534 # No groups in the regex, assume this is a single rule
535 # that spans the entire line
537 self
.setFormat(0, len(ascii
), fmts
)
540 # Groups exist, rule is a tuple corresponding to group
541 for grpidx
, group
in enumerate(groups
):
542 # allow empty matches
545 # allow None as a no-op format
548 self
.setFormat(start
, 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)
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
,
603 diff_sum_rgx
, (diffstat_info
,
607 self
.create_rules('(..*?)(\s+)$', (None, bad_ws
))
610 # This is used as a mixin to generate property callbacks
612 private_attr
= '_'+attr
615 return self
.__dict
__.get(private_attr
, None)
617 def setter(self
, value
):
618 self
.__dict
__[private_attr
] = value
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:
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__':
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
)
644 if utils
.is_darwin():
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())