status: use Italics instead of Bold-with-background for headers
[git-cola.git] / cola / widgets / standard.py
blobd6fac9dad599e857de8f71e266ce5d46de484ceb
1 from __future__ import division, absolute_import, unicode_literals
3 import time
6 from PyQt4 import QtGui
7 from PyQt4 import QtCore
8 from PyQt4.QtCore import Qt
9 from PyQt4.QtCore import SIGNAL
10 from PyQt4.QtGui import QDockWidget
12 from cola import core
13 from cola import gitcfg
14 from cola import qtcompat
15 from cola import qtutils
16 from cola.settings import Settings
19 class WidgetMixin(object):
20 """Mix-in for common utilities and serialization of widget state"""
22 def __init__(self, QtClass):
23 self.QtClass = QtClass
24 self._apply_state_applied = False
26 def show(self):
27 """Automatically centers dialogs"""
28 if not self._apply_state_applied and self.parent() is not None:
29 left = self.parent().x()
30 width = self.parent().width()
31 center_x = left + width//2
33 x = center_x - self.width()//2
34 y = self.parent().y()
36 self.move(x, y)
37 # Call the base Qt show()
38 return self.QtClass.show(self)
40 def name(self):
41 """Returns the name of the view class"""
42 return self.__class__.__name__.lower()
44 def save_state(self, settings=None):
45 if settings is None:
46 settings = Settings()
47 settings.load()
48 if gitcfg.current().get('cola.savewindowsettings', True):
49 settings.save_gui_state(self)
51 def restore_state(self, settings=None):
52 if settings is None:
53 settings = Settings()
54 settings.load()
55 state = settings.get_gui_state(self)
56 return bool(state) and self.apply_state(state)
58 def apply_state(self, state):
59 """Imports data for view save/restore"""
60 result = True
61 try:
62 self.resize(state['width'], state['height'])
63 except:
64 result = False
65 try:
66 self.move(state['x'], state['y'])
67 except:
68 result = False
69 try:
70 if state['maximized']:
71 self.showMaximized()
72 except:
73 result = False
74 self._apply_state_applied = result
75 return result
77 def export_state(self):
78 """Exports data for view save/restore"""
79 state = self.windowState()
80 maximized = bool(state & Qt.WindowMaximized)
81 return {
82 'x': self.x(),
83 'y': self.y(),
84 'width': self.width(),
85 'height': self.height(),
86 'maximized': maximized,
89 def save_settings(self):
90 settings = Settings()
91 settings.load()
92 settings.add_recent(core.getcwd())
93 return self.save_state(settings=settings)
95 def closeEvent(self, event):
96 self.save_settings()
97 self.QtClass.closeEvent(self, event)
100 class MainWindowMixin(WidgetMixin):
102 def __init__(self, QtClass):
103 WidgetMixin.__init__(self, QtClass)
104 # Dockwidget options
105 self.dockwidgets = []
106 self.lock_layout = False
107 self.widget_version = 0
108 qtcompat.set_common_dock_options(self)
110 def export_state(self):
111 """Exports data for save/restore"""
112 state = WidgetMixin.export_state(self)
113 windowstate = self.saveState(self.widget_version)
114 state['lock_layout'] = self.lock_layout
115 state['windowstate'] = windowstate.toBase64().data().decode('ascii')
116 return state
118 def apply_state(self, state):
119 result = WidgetMixin.apply_state(self, state)
120 windowstate = state.get('windowstate', None)
121 if windowstate is None:
122 result = False
123 else:
124 result = self.restoreState(QtCore.QByteArray.fromBase64(str(windowstate)),
125 self.widget_version) and result
126 self.lock_layout = state.get('lock_layout', self.lock_layout)
127 self.update_dockwidget_lock_state()
128 self.update_dockwidget_tooltips()
129 return result
131 def set_lock_layout(self, lock_layout):
132 self.lock_layout = lock_layout
133 self.update_dockwidget_lock_state()
135 def update_dockwidget_lock_state(self):
136 if self.lock_layout:
137 features = (QDockWidget.DockWidgetClosable |
138 QDockWidget.DockWidgetFloatable)
139 else:
140 features = (QDockWidget.DockWidgetClosable |
141 QDockWidget.DockWidgetFloatable |
142 QDockWidget.DockWidgetMovable)
143 for widget in self.dockwidgets:
144 widget.titleBarWidget().update_tooltips()
145 widget.setFeatures(features)
147 def update_dockwidget_tooltips(self):
148 for widget in self.dockwidgets:
149 widget.titleBarWidget().update_tooltips()
151 def closeEvent(self, event):
152 qtutils.persist_clipboard()
153 WidgetMixin.closeEvent(self, event)
156 class TreeMixin(object):
158 def __init__(self, QtClass):
159 self.QtClass = QtClass
160 self.setAlternatingRowColors(True)
161 self.setUniformRowHeights(True)
162 self.setAllColumnsShowFocus(True)
163 self.setAnimated(True)
164 self.setRootIsDecorated(False)
166 def keyPressEvent(self, event):
168 Make LeftArrow to work on non-directories.
170 When LeftArrow is pressed on a file entry or an unexpanded
171 directory, then move the current index to the parent directory.
173 This simplifies navigation using the keyboard.
174 For power-users, we support Vim keybindings ;-P
177 # Check whether the item is expanded before calling the base class
178 # keyPressEvent otherwise we end up collapsing and changing the
179 # current index in one shot, which we don't want to do.
180 index = self.currentIndex()
181 was_expanded = self.isExpanded(index)
182 was_collapsed = not was_expanded
184 # Vim keybindings...
185 # Rewrite the event before marshalling to QTreeView.event()
186 key = event.key()
188 # Remap 'H' to 'Left'
189 if key == Qt.Key_H:
190 event = QtGui.QKeyEvent(event.type(),
191 Qt.Key_Left,
192 event.modifiers())
193 # Remap 'J' to 'Down'
194 elif key == Qt.Key_J:
195 event = QtGui.QKeyEvent(event.type(),
196 Qt.Key_Down,
197 event.modifiers())
198 # Remap 'K' to 'Up'
199 elif key == Qt.Key_K:
200 event = QtGui.QKeyEvent(event.type(),
201 Qt.Key_Up,
202 event.modifiers())
203 # Remap 'L' to 'Right'
204 elif key == Qt.Key_L:
205 event = QtGui.QKeyEvent(event.type(),
206 Qt.Key_Right,
207 event.modifiers())
209 # Re-read the event key to take the remappings into account
210 key = event.key()
211 if key == Qt.Key_Up:
212 idxs = self.selectedIndexes()
213 rows = [idx.row() for idx in idxs]
214 if len(rows) == 1 and rows[0] == 0:
215 # The cursor is at the beginning of the line.
216 # If we have selection then simply reset the cursor.
217 # Otherwise, emit a signal so that the parent can
218 # change focus.
219 self.emit(SIGNAL('up()'))
221 elif key == Qt.Key_Space:
222 self.emit(SIGNAL('space()'))
224 result = self.QtClass.keyPressEvent(self, event)
226 # Let others hook in here before we change the indexes
227 self.emit(SIGNAL('indexAboutToChange()'))
229 # Automatically select the first entry when expanding a directory
230 if (key == Qt.Key_Right and was_collapsed and
231 self.isExpanded(index)):
232 index = self.moveCursor(self.MoveDown, event.modifiers())
233 self.setCurrentIndex(index)
235 # Process non-root entries with valid parents only.
236 elif key == Qt.Key_Left and index.parent().isValid():
238 # File entries have rowCount() == 0
239 if self.model().itemFromIndex(index).rowCount() == 0:
240 self.setCurrentIndex(index.parent())
242 # Otherwise, do this for collapsed directories only
243 elif was_collapsed:
244 self.setCurrentIndex(index.parent())
246 # If it's a movement key ensure we have a selection
247 elif key in (Qt.Key_Left, Qt.Key_Up, Qt.Key_Right, Qt.Key_Down):
248 # Try to select the first item if the model index is invalid
249 item = self.selected_item()
250 if item is None or not index.isValid():
251 index = self.model().index(0, 0, QtCore.QModelIndex())
252 if index.isValid():
253 self.setCurrentIndex(index)
255 return result
257 def items(self):
258 root = self.invisibleRootItem()
259 child = root.child
260 count = root.childCount()
261 return [child(i) for i in range(count)]
263 def selected_items(self):
264 """Return all selected items"""
265 if hasattr(self, 'selectedItems'):
266 return self.selectedItems()
267 else:
268 item_from_index = self.model().itemFromIndex
269 return [item_from_index(i) for i in self.selectedIndexes()]
271 def selected_item(self):
272 """Return the first selected item"""
273 selected_items = self.selected_items()
274 if not selected_items:
275 return None
276 return selected_items[0]
278 def current_item(self):
279 if hasattr(self, 'currentItem'):
280 item = self.currentItem()
281 else:
282 index = self.currentIndex()
283 if index.isValid():
284 item = self.model().itemFromIndex(index)
285 else:
286 item = None
287 return item
290 class DraggableTreeMixin(TreeMixin):
291 """A tree widget with internal drag+drop reordering of rows"""
293 ITEMS_MOVED_SIGNAL = 'items_moved'
295 def __init__(self, QtClass):
296 super(DraggableTreeMixin, self).__init__(QtClass)
297 self.setAcceptDrops(True)
298 self.setSelectionMode(self.SingleSelection)
299 self.setDragEnabled(True)
300 self.setDropIndicatorShown(True)
301 self.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
302 self.setSortingEnabled(False)
303 self._inner_drag = False
305 def dragEnterEvent(self, event):
306 """Accept internal drags only"""
307 self.QtClass.dragEnterEvent(self, event)
308 self._inner_drag = event.source() == self
309 if self._inner_drag:
310 event.acceptProposedAction()
311 else:
312 event.ignore()
314 def dragLeaveEvent(self, event):
315 self.QtClass.dragLeaveEvent(self, event)
316 if self._inner_drag:
317 event.accept()
318 else:
319 event.ignore()
320 self._inner_drag = False
322 def dropEvent(self, event):
323 """Re-select selected items after an internal move"""
324 if not self._inner_drag:
325 event.ignore()
326 return
327 clicked_items = self.selected_items()
328 event.setDropAction(Qt.MoveAction)
329 self.QtClass.dropEvent(self, event)
331 if clicked_items:
332 self.clearSelection()
333 for item in clicked_items:
334 self.setItemSelected(item, True)
335 self.emit(SIGNAL(self.ITEMS_MOVED_SIGNAL), clicked_items)
336 self._inner_drag = False
337 event.accept() # must be called after dropEvent()
339 def mousePressEvent(self, event):
340 """Clear the selection when a mouse click hits no item"""
341 clicked_item = self.itemAt(event.pos())
342 if clicked_item is None:
343 self.clearSelection()
344 return self.QtClass.mousePressEvent(self, event)
347 class Widget(WidgetMixin, QtGui.QWidget):
349 def __init__(self, parent=None):
350 QtGui.QWidget.__init__(self, parent)
351 WidgetMixin.__init__(self, QtGui.QWidget)
354 class Dialog(WidgetMixin, QtGui.QDialog):
356 def __init__(self, parent=None, save_settings=False):
357 QtGui.QDialog.__init__(self, parent)
358 WidgetMixin.__init__(self, QtGui.QDialog)
359 self._save_settings = save_settings
361 def close(self):
362 if self._save_settings:
363 self.save_settings()
364 return self.QtClass.close(self)
366 def reject(self):
367 if self._save_settings:
368 self.save_settings()
369 return self.QtClass.reject(self)
372 class MainWindow(MainWindowMixin, QtGui.QMainWindow):
374 def __init__(self, parent=None):
375 QtGui.QMainWindow.__init__(self, parent)
376 MainWindowMixin.__init__(self, QtGui.QMainWindow)
379 class TreeView(TreeMixin, QtGui.QTreeView):
381 def __init__(self, parent=None):
382 QtGui.QTreeView.__init__(self, parent)
383 TreeMixin.__init__(self, QtGui.QTreeView)
386 class TreeWidget(TreeMixin, QtGui.QTreeWidget):
388 def __init__(self, parent=None):
389 QtGui.QTreeWidget.__init__(self, parent)
390 TreeMixin.__init__(self, QtGui.QTreeWidget)
393 class DraggableTreeWidget(DraggableTreeMixin, QtGui.QTreeWidget):
395 def __init__(self, parent=None):
396 QtGui.QTreeWidget.__init__(self, parent)
397 DraggableTreeMixin.__init__(self, QtGui.QTreeWidget)
400 class ProgressDialog(QtGui.QProgressDialog):
401 """Custom progress dialog
403 This dialog ignores the ESC key so that it is not
404 prematurely closed.
406 An thread is spawned to animate the progress label text.
409 def __init__(self, title, label, parent):
410 QtGui.QProgressDialog.__init__(self, parent)
411 self.setFont(qtutils.diff_font())
412 self.setRange(0, 0)
413 self.setCancelButton(None)
414 if parent is not None:
415 self.setWindowModality(Qt.WindowModal)
416 self.progress_thread = ProgressAnimationThread(label, self)
417 self.connect(self.progress_thread,
418 SIGNAL('update_progress(PyQt_PyObject)'),
419 self.update_progress, Qt.QueuedConnection)
421 self.set_details(title, label)
423 def set_details(self, title, label):
424 self.setWindowTitle(title)
425 self.setLabelText(label + ' ')
426 self.progress_thread.set_text(label)
428 def update_progress(self, txt):
429 self.setLabelText(txt)
431 def keyPressEvent(self, event):
432 if event.key() != Qt.Key_Escape:
433 QtGui.QProgressDialog.keyPressEvent(self, event)
435 def show(self):
436 QtGui.QApplication.setOverrideCursor(Qt.WaitCursor)
437 self.progress_thread.start()
438 QtGui.QProgressDialog.show(self)
440 def hide(self):
441 QtGui.QApplication.restoreOverrideCursor()
442 self.progress_thread.stop()
443 self.progress_thread.wait()
444 QtGui.QProgressDialog.hide(self)
447 class ProgressAnimationThread(QtCore.QThread):
448 """Emits a pseudo-animated text stream for progress bars"""
450 def __init__(self, txt, parent, timeout=0.1):
451 QtCore.QThread.__init__(self, parent)
452 self.running = False
453 self.txt = txt
454 self.timeout = timeout
455 self.symbols = [
456 '. ..',
457 '.. .',
458 '... ',
459 ' ... ',
460 ' ...',
462 self.idx = -1
464 def set_text(self, txt):
465 self.txt = txt
467 def next(self):
468 self.idx = (self.idx + 1) % len(self.symbols)
469 return self.txt + self.symbols[self.idx]
471 def stop(self):
472 self.running = False
474 def run(self):
475 self.running = True
476 while self.running:
477 self.emit(SIGNAL('update_progress(PyQt_PyObject)'), self.next())
478 time.sleep(self.timeout)
481 class SpinBox(QtGui.QSpinBox):
482 def __init__(self, parent=None):
483 QtGui.QSpinBox.__init__(self, parent)
484 self.setMinimum(1)
485 self.setMaximum(99999)
486 self.setPrefix('')
487 self.setSuffix('')