win32/install.iss: Support spaces in git-cola path
[git-cola.git] / cola / qt.py
blob98cb28b11cc2d0394ce64584ae59bd3134b6038d
1 import re
3 from PyQt4 import QtGui
4 from PyQt4 import QtCore
5 from PyQt4.QtCore import Qt
6 from PyQt4.QtCore import SIGNAL
7 from PyQt4.QtGui import QFont
8 from PyQt4.QtGui import QSyntaxHighlighter
9 from PyQt4.QtGui import QTextCharFormat
10 from PyQt4.QtGui import QColor
12 from cola import utils
13 from cola import qtutils
14 from cola.qtutils import tr
15 from cola.widgets import completion
16 from cola.widgets import defs
19 def create_button(text='', layout=None, tooltip=None, icon=None):
20 """Create a button, set its title, and add it to the parent."""
21 button = QtGui.QPushButton()
22 if text:
23 button.setText(tr(text))
24 if icon:
25 button.setIcon(icon)
26 if tooltip is not None:
27 button.setToolTip(tooltip)
28 if layout is not None:
29 layout.addWidget(button)
30 return button
33 class DockTitleBarWidget(QtGui.QWidget):
34 def __init__(self, parent, title):
35 QtGui.QWidget.__init__(self, parent)
36 self.label = label = QtGui.QLabel()
37 font = label.font()
38 font.setCapitalization(QtGui.QFont.SmallCaps)
39 label.setFont(font)
40 label.setText(title)
42 self.close_button = QtGui.QPushButton()
43 self.close_button.setFlat(True)
44 self.close_button.setFixedSize(QtCore.QSize(16, 16))
45 self.close_button.setIcon(qtutils.titlebar_close_icon())
47 self.toggle_button = QtGui.QPushButton()
48 self.toggle_button.setFlat(True)
49 self.toggle_button.setFixedSize(QtCore.QSize(16, 16))
50 self.toggle_button.setIcon(qtutils.titlebar_normal_icon())
52 self.corner_layout = QtGui.QHBoxLayout()
53 self.corner_layout.setMargin(0)
54 self.corner_layout.setSpacing(defs.spacing)
56 layout = QtGui.QHBoxLayout()
57 layout.setMargin(2)
58 layout.setSpacing(defs.spacing)
59 layout.addWidget(label)
60 layout.addStretch()
61 layout.addLayout(self.corner_layout)
62 layout.addWidget(self.toggle_button)
63 layout.addWidget(self.close_button)
64 self.setLayout(layout)
66 self.connect(self.toggle_button, SIGNAL('clicked()'),
67 self.toggle_floating)
69 self.connect(self.close_button, SIGNAL('clicked()'),
70 self.parent().toggleViewAction().trigger)
72 def toggle_floating(self):
73 self.parent().setFloating(not self.parent().isFloating())
75 def set_title(self, title):
76 self.label.setText(title)
78 def add_corner_widget(self, widget):
79 self.corner_layout.addWidget(widget)
82 def create_dock(title, parent):
83 """Create a dock widget and set it up accordingly."""
84 dock = QtGui.QDockWidget(parent)
85 dock.setWindowTitle(tr(title))
86 dock.setObjectName(title)
87 titlebar = DockTitleBarWidget(dock, title)
88 dock.setTitleBarWidget(titlebar)
89 return dock
92 def create_menu(title, parent):
93 """Create a menu and set its title."""
94 qmenu = QtGui.QMenu(parent)
95 qmenu.setTitle(tr(title))
96 return qmenu
99 def create_toolbutton(text=None, layout=None, tooltip=None, icon=None):
100 button = QtGui.QToolButton()
101 button.setAutoRaise(True)
102 button.setAutoFillBackground(True)
103 if icon:
104 button.setIcon(icon)
105 if text:
106 button.setText(tr(text))
107 button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
108 if tooltip:
109 button.setToolTip(tr(tooltip))
110 if layout is not None:
111 layout.addWidget(button)
112 return button
115 class QFlowLayoutWidget(QtGui.QWidget):
117 _horizontal = QtGui.QBoxLayout.LeftToRight
118 _vertical = QtGui.QBoxLayout.TopToBottom
120 def __init__(self, parent):
121 QtGui.QWidget.__init__(self, parent)
122 self._direction = self._vertical
123 self._layout = layout = QtGui.QBoxLayout(self._direction)
124 layout.setSpacing(defs.spacing)
125 layout.setMargin(defs.margin)
126 self.setLayout(layout)
127 policy = QtGui.QSizePolicy(QtGui.QSizePolicy.Minimum,
128 QtGui.QSizePolicy.Minimum)
129 self.setSizePolicy(policy)
130 self.setMinimumSize(QtCore.QSize(1, 1))
132 def resizeEvent(self, event):
133 size = event.size()
134 if size.width() * 0.8 < size.height():
135 dxn = self._vertical
136 else:
137 dxn = self._horizontal
139 if dxn != self._direction:
140 self._direction = dxn
141 self.layout().setDirection(dxn)
144 class ExpandableGroupBox(QtGui.QGroupBox):
145 def __init__(self, parent=None):
146 QtGui.QGroupBox.__init__(self, parent)
147 self.setFlat(True)
148 self.expanded = True
149 self.click_pos = None
150 self.arrow_icon_size = 16
152 def set_expanded(self, expanded):
153 if expanded == self.expanded:
154 self.emit(SIGNAL('expanded(bool)'), expanded)
155 return
156 self.expanded = expanded
157 for widget in self.findChildren(QtGui.QWidget):
158 widget.setHidden(not expanded)
159 self.emit(SIGNAL('expanded(bool)'), expanded)
161 def mousePressEvent(self, event):
162 if event.button() == Qt.LeftButton:
163 option = QtGui.QStyleOptionGroupBox()
164 self.initStyleOption(option)
165 icon_size = self.arrow_icon_size
166 button_area = QtCore.QRect(0, 0, icon_size, icon_size)
167 offset = self.arrow_icon_size + defs.spacing
168 adjusted = option.rect.adjusted(0, 0, -offset, 0)
169 top_left = adjusted.topLeft()
170 button_area.moveTopLeft(QtCore.QPoint(top_left))
171 self.click_pos = event.pos()
172 QtGui.QGroupBox.mousePressEvent(self, event)
174 def mouseReleaseEvent(self, event):
175 if (event.button() == Qt.LeftButton and
176 self.click_pos == event.pos()):
177 self.set_expanded(not self.expanded)
178 QtGui.QGroupBox.mouseReleaseEvent(self, event)
180 def paintEvent(self, event):
181 painter = QtGui.QStylePainter(self)
182 option = QtGui.QStyleOptionGroupBox()
183 self.initStyleOption(option)
184 painter.save()
185 painter.translate(self.arrow_icon_size + defs.spacing, 0)
186 painter.drawText(option.rect, Qt.AlignLeft, self.title())
187 painter.restore()
189 style = QtGui.QStyle
190 point = option.rect.adjusted(0, -4, 0, 0).topLeft()
191 icon_size = self.arrow_icon_size
192 option.rect = QtCore.QRect(point.x(), point.y(), icon_size, icon_size)
193 if self.expanded:
194 painter.drawPrimitive(style.PE_IndicatorArrowDown, option)
195 else:
196 painter.drawPrimitive(style.PE_IndicatorArrowRight, option)
199 class GitRefDialog(QtGui.QDialog):
200 def __init__(self, title, button_text, parent):
201 super(GitRefDialog, self).__init__(parent)
202 self.setWindowTitle(title)
204 self.label = QtGui.QLabel()
205 self.label.setText(title)
207 self.lineedit = completion.GitRefLineEdit(self)
208 self.setFocusProxy(self.lineedit)
210 self.ok_button = QtGui.QPushButton()
211 self.ok_button.setText(self.tr(button_text))
212 self.ok_button.setIcon(qtutils.apply_icon())
214 self.close_button = QtGui.QPushButton()
215 self.close_button.setText(self.tr('Close'))
217 self.button_layout = QtGui.QHBoxLayout()
218 self.button_layout.setMargin(0)
219 self.button_layout.setSpacing(defs.button_spacing)
220 self.button_layout.addStretch()
221 self.button_layout.addWidget(self.ok_button)
222 self.button_layout.addWidget(self.close_button)
224 self.main_layout = QtGui.QVBoxLayout()
225 self.main_layout.setMargin(defs.margin)
226 self.main_layout.setSpacing(defs.spacing)
228 self.main_layout.addWidget(self.label)
229 self.main_layout.addWidget(self.lineedit)
230 self.main_layout.addLayout(self.button_layout)
231 self.setLayout(self.main_layout)
233 qtutils.connect_button(self.ok_button, self.accept)
234 qtutils.connect_button(self.close_button, self.reject)
236 self.connect(self.lineedit, SIGNAL('textChanged(QString)'),
237 self.text_changed)
239 self.setWindowModality(Qt.WindowModal)
240 self.ok_button.setEnabled(False)
242 def text(self):
243 return unicode(self.lineedit.text())
245 def text_changed(self, txt):
246 self.ok_button.setEnabled(bool(self.text()))
248 def set_text(self, ref):
249 self.lineedit.setText(ref)
251 @staticmethod
252 def ref(title, button_text, parent, default=None):
253 dlg = GitRefDialog(title, button_text, parent)
254 if default:
255 dlg.set_text(default)
256 dlg.show()
257 dlg.raise_()
258 dlg.setFocus()
259 if dlg.exec_() == GitRefDialog.Accepted:
260 return dlg.text()
261 else:
262 return None
264 # Syntax highlighting
266 def TERMINAL(pattern):
268 Denotes that a pattern is the final pattern that should
269 be matched. If this pattern matches no other formats
270 will be applied, even if they would have matched.
272 return '__TERMINAL__:%s' % pattern
274 # Cache the results of re.compile so that we don't keep
275 # rebuilding the same regexes whenever stylesheets change
276 _RGX_CACHE = {}
278 def rgba(r, g, b, a=255):
279 c = QColor()
280 c.setRgb(r, g, b)
281 c.setAlpha(a)
282 return c
284 default_colors = {
285 'color_text': rgba(0x00, 0x00, 0x00),
286 'color_add': rgba(0xcd, 0xff, 0xe0),
287 'color_remove': rgba(0xff, 0xd0, 0xd0),
288 'color_header': rgba(0xbb, 0xbb, 0xbb),
292 class GenericSyntaxHighligher(QSyntaxHighlighter):
293 def __init__(self, doc, *args, **kwargs):
294 QSyntaxHighlighter.__init__(self, doc)
295 for attr, val in default_colors.items():
296 setattr(self, attr, val)
297 self._rules = []
298 self.generate_rules()
300 def generate_rules(self):
301 pass
303 def create_rules(self, *rules):
304 if len(rules) % 2:
305 raise Exception('create_rules requires an even '
306 'number of arguments.')
307 for idx, rule in enumerate(rules):
308 if idx % 2:
309 continue
310 formats = rules[idx+1]
311 terminal = rule.startswith(TERMINAL(''))
312 if terminal:
313 rule = rule[len(TERMINAL('')):]
314 try:
315 regex = _RGX_CACHE[rule]
316 except KeyError:
317 regex = _RGX_CACHE[rule] = re.compile(rule)
318 self._rules.append((regex, formats, terminal,))
320 def formats(self, line):
321 matched = []
322 for regex, fmts, terminal in self._rules:
323 match = regex.match(line)
324 if not match:
325 continue
326 matched.append([match, fmts])
327 if terminal:
328 return matched
329 return matched
331 def mkformat(self, fg=None, bg=None, bold=False):
332 fmt = QTextCharFormat()
333 if fg:
334 fmt.setForeground(fg)
335 if bg:
336 fmt.setBackground(bg)
337 if bold:
338 fmt.setFontWeight(QFont.Bold)
339 return fmt
341 def highlightBlock(self, qstr):
342 ascii = unicode(qstr)
343 if not ascii:
344 return
345 formats = self.formats(ascii)
346 if not formats:
347 return
348 for match, fmts in formats:
349 start = match.start()
350 groups = match.groups()
352 # No groups in the regex, assume this is a single rule
353 # that spans the entire line
354 if not groups:
355 self.setFormat(0, len(ascii), fmts)
356 continue
358 # Groups exist, rule is a tuple corresponding to group
359 for grpidx, group in enumerate(groups):
360 # allow empty matches
361 if not group:
362 continue
363 # allow None as a no-op format
364 length = len(group)
365 if fmts[grpidx]:
366 self.setFormat(start, start+length,
367 fmts[grpidx])
368 start += length
370 def set_colors(self, colordict):
371 for attr, val in colordict.items():
372 setattr(self, attr, val)
375 class DiffSyntaxHighlighter(GenericSyntaxHighligher):
376 """Implements the diff syntax highlighting
378 This class is used by widgets that display diffs.
381 def __init__(self, doc, whitespace=True):
382 self.whitespace = whitespace
383 GenericSyntaxHighligher.__init__(self, doc)
385 def generate_rules(self):
386 diff_head = self.mkformat(fg=self.color_header)
387 diff_head_bold = self.mkformat(fg=self.color_header, bold=True)
389 diff_add = self.mkformat(fg=self.color_text, bg=self.color_add)
390 diff_remove = self.mkformat(fg=self.color_text, bg=self.color_remove)
392 if self.whitespace:
393 bad_ws = self.mkformat(fg=Qt.black, bg=Qt.red)
395 # We specify the whitespace rule last so that it is
396 # applied after the diff addition/removal rules.
397 # The rules for the header
398 diff_old_rgx = TERMINAL(r'^--- ')
399 diff_new_rgx = TERMINAL(r'^\+\+\+ ')
400 diff_ctx_rgx = TERMINAL(r'^@@ ')
402 diff_hd1_rgx = TERMINAL(r'^diff --git a/.*b/.*')
403 diff_hd2_rgx = TERMINAL(r'^index \S+\.\.\S+')
404 diff_hd3_rgx = TERMINAL(r'^new file mode')
405 diff_hd4_rgx = TERMINAL(r'^deleted file mode')
406 diff_add_rgx = TERMINAL(r'^\+')
407 diff_rmv_rgx = TERMINAL(r'^-')
408 diff_bar_rgx = TERMINAL(r'^([ ]+.*)(\|[ ]+\d+[ ]+[+-]+)$')
409 diff_sts_rgx = (r'(.+\|.+?)(\d+)(.+?)([\+]*?)([-]*?)$')
410 diff_sum_rgx = (r'(\s+\d+ files changed[^\d]*)'
411 r'(:?\d+ insertions[^\d]*)'
412 r'(:?\d+ deletions.*)$')
414 self.create_rules(diff_old_rgx, diff_head,
415 diff_new_rgx, diff_head,
416 diff_ctx_rgx, diff_head_bold,
417 diff_bar_rgx, (diff_head_bold, diff_head),
418 diff_hd1_rgx, diff_head,
419 diff_hd2_rgx, diff_head,
420 diff_hd3_rgx, diff_head,
421 diff_hd4_rgx, diff_head,
422 diff_add_rgx, diff_add,
423 diff_rmv_rgx, diff_remove,
424 diff_sts_rgx, (None, diff_head,
425 None, diff_head,
426 diff_head),
427 diff_sum_rgx, (diff_head,
428 diff_head,
429 diff_head))
430 if self.whitespace:
431 self.create_rules('(..*?)(\s+)$', (None, bad_ws))
434 if __name__ == '__main__':
435 import sys
436 class SyntaxTestDialog(QtGui.QDialog):
437 def __init__(self, parent):
438 QtGui.QDialog.__init__(self, parent)
439 self.resize(720, 512)
440 self.vboxlayout = QtGui.QVBoxLayout(self)
441 self.vboxlayout.setObjectName('vboxlayout')
442 self.output_text = QtGui.QTextEdit(self)
443 font = QtGui.QFont()
444 if utils.is_darwin():
445 family = 'Monaco'
446 else:
447 family = 'Monospace'
448 font.setFamily(family)
449 font.setPointSize(12)
450 self.output_text.setFont(font)
451 self.output_text.setAcceptDrops(False)
452 self.vboxlayout.addWidget(self.output_text)
453 self.syntax = DiffSyntaxHighlighter(self.output_text.document())
455 app = QtGui.QApplication(sys.argv)
456 dialog = SyntaxTestDialog(qtutils.active_window())
457 dialog.show()
458 dialog.raise_()
459 app.exec_()