widgets: reset() the progress dialog on construction
[git-cola.git] / cola / widgets / standard.py
blob3b9f8b6739e55bd846cc4b81bb9c122c6af03291
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
17 from cola.widgets import defs
20 class WidgetMixin(object):
21 """Mix-in for common utilities and serialization of widget state"""
23 def __init__(self, QtClass):
24 self.QtClass = QtClass
25 self._apply_state_applied = False
27 def show(self):
28 """Automatically centers dialogs"""
29 if not self._apply_state_applied and self.parent() is not None:
30 left = self.parent().x()
31 width = self.parent().width()
32 center_x = left + width//2
34 x = center_x - self.width()//2
35 y = self.parent().y()
37 self.move(x, y)
38 # Call the base Qt show()
39 return self.QtClass.show(self)
41 def name(self):
42 """Returns the name of the view class"""
43 return self.__class__.__name__.lower()
45 def save_state(self, settings=None):
46 if settings is None:
47 settings = Settings()
48 settings.load()
49 if gitcfg.current().get('cola.savewindowsettings', True):
50 settings.save_gui_state(self)
52 def restore_state(self, settings=None):
53 if settings is None:
54 settings = Settings()
55 settings.load()
56 state = settings.get_gui_state(self)
57 return bool(state) and self.apply_state(state)
59 def apply_state(self, state):
60 """Imports data for view save/restore"""
61 result = True
62 try:
63 self.resize(state['width'], state['height'])
64 except:
65 result = False
66 try:
67 self.move(state['x'], state['y'])
68 except:
69 result = False
70 try:
71 if state['maximized']:
72 self.showMaximized()
73 except:
74 result = False
75 self._apply_state_applied = result
76 return result
78 def export_state(self):
79 """Exports data for view save/restore"""
80 state = self.windowState()
81 maximized = bool(state & Qt.WindowMaximized)
82 return {
83 'x': self.x(),
84 'y': self.y(),
85 'width': self.width(),
86 'height': self.height(),
87 'maximized': maximized,
90 def save_settings(self):
91 settings = Settings()
92 settings.load()
93 settings.add_recent(core.getcwd())
94 return self.save_state(settings=settings)
96 def closeEvent(self, event):
97 self.save_settings()
98 self.QtClass.closeEvent(self, event)
101 class MainWindowMixin(WidgetMixin):
103 def __init__(self, QtClass):
104 WidgetMixin.__init__(self, QtClass)
105 # Dockwidget options
106 self.dockwidgets = []
107 self.lock_layout = False
108 self.widget_version = 0
109 qtcompat.set_common_dock_options(self)
111 def export_state(self):
112 """Exports data for save/restore"""
113 state = WidgetMixin.export_state(self)
114 windowstate = self.saveState(self.widget_version)
115 state['lock_layout'] = self.lock_layout
116 state['windowstate'] = windowstate.toBase64().data().decode('ascii')
117 return state
119 def apply_state(self, state):
120 result = WidgetMixin.apply_state(self, state)
121 windowstate = state.get('windowstate', None)
122 if windowstate is None:
123 result = False
124 else:
125 from_base64 = QtCore.QByteArray.fromBase64
126 result = self.restoreState(
127 from_base64(str(windowstate)),
128 self.widget_version) and result
129 self.lock_layout = state.get('lock_layout', self.lock_layout)
130 self.update_dockwidget_lock_state()
131 self.update_dockwidget_tooltips()
132 return result
134 def set_lock_layout(self, lock_layout):
135 self.lock_layout = lock_layout
136 self.update_dockwidget_lock_state()
138 def update_dockwidget_lock_state(self):
139 if self.lock_layout:
140 features = (QDockWidget.DockWidgetClosable |
141 QDockWidget.DockWidgetFloatable)
142 else:
143 features = (QDockWidget.DockWidgetClosable |
144 QDockWidget.DockWidgetFloatable |
145 QDockWidget.DockWidgetMovable)
146 for widget in self.dockwidgets:
147 widget.titleBarWidget().update_tooltips()
148 widget.setFeatures(features)
150 def update_dockwidget_tooltips(self):
151 for widget in self.dockwidgets:
152 widget.titleBarWidget().update_tooltips()
155 class TreeMixin(object):
157 def __init__(self, QtClass):
158 self.QtClass = QtClass
159 self.setAlternatingRowColors(True)
160 self.setUniformRowHeights(True)
161 self.setAllColumnsShowFocus(True)
162 self.setAnimated(True)
163 self.setRootIsDecorated(False)
165 def keyPressEvent(self, event):
167 Make LeftArrow to work on non-directories.
169 When LeftArrow is pressed on a file entry or an unexpanded
170 directory, then move the current index to the parent directory.
172 This simplifies navigation using the keyboard.
173 For power-users, we support Vim keybindings ;-P
176 # Check whether the item is expanded before calling the base class
177 # keyPressEvent otherwise we end up collapsing and changing the
178 # current index in one shot, which we don't want to do.
179 index = self.currentIndex()
180 was_expanded = self.isExpanded(index)
181 was_collapsed = not was_expanded
183 # Vim keybindings...
184 # Rewrite the event before marshalling to QTreeView.event()
185 key = event.key()
187 # Remap 'H' to 'Left'
188 if key == Qt.Key_H:
189 event = QtGui.QKeyEvent(event.type(),
190 Qt.Key_Left,
191 event.modifiers())
192 # Remap 'J' to 'Down'
193 elif key == Qt.Key_J:
194 event = QtGui.QKeyEvent(event.type(),
195 Qt.Key_Down,
196 event.modifiers())
197 # Remap 'K' to 'Up'
198 elif key == Qt.Key_K:
199 event = QtGui.QKeyEvent(event.type(),
200 Qt.Key_Up,
201 event.modifiers())
202 # Remap 'L' to 'Right'
203 elif key == Qt.Key_L:
204 event = QtGui.QKeyEvent(event.type(),
205 Qt.Key_Right,
206 event.modifiers())
208 # Re-read the event key to take the remappings into account
209 key = event.key()
210 if key == Qt.Key_Up:
211 idxs = self.selectedIndexes()
212 rows = [idx.row() for idx in idxs]
213 if len(rows) == 1 and rows[0] == 0:
214 # The cursor is at the beginning of the line.
215 # If we have selection then simply reset the cursor.
216 # Otherwise, emit a signal so that the parent can
217 # change focus.
218 self.emit(SIGNAL('up()'))
220 elif key == Qt.Key_Space:
221 self.emit(SIGNAL('space()'))
223 result = self.QtClass.keyPressEvent(self, event)
225 # Let others hook in here before we change the indexes
226 self.emit(SIGNAL('indexAboutToChange()'))
228 # Automatically select the first entry when expanding a directory
229 if (key == Qt.Key_Right and was_collapsed and
230 self.isExpanded(index)):
231 index = self.moveCursor(self.MoveDown, event.modifiers())
232 self.setCurrentIndex(index)
234 # Process non-root entries with valid parents only.
235 elif key == Qt.Key_Left and index.parent().isValid():
237 # File entries have rowCount() == 0
238 if self.model().itemFromIndex(index).rowCount() == 0:
239 self.setCurrentIndex(index.parent())
241 # Otherwise, do this for collapsed directories only
242 elif was_collapsed:
243 self.setCurrentIndex(index.parent())
245 # If it's a movement key ensure we have a selection
246 elif key in (Qt.Key_Left, Qt.Key_Up, Qt.Key_Right, Qt.Key_Down):
247 # Try to select the first item if the model index is invalid
248 item = self.selected_item()
249 if item is None or not index.isValid():
250 index = self.model().index(0, 0, QtCore.QModelIndex())
251 if index.isValid():
252 self.setCurrentIndex(index)
254 return result
256 def items(self):
257 root = self.invisibleRootItem()
258 child = root.child
259 count = root.childCount()
260 return [child(i) for i in range(count)]
262 def selected_items(self):
263 """Return all selected items"""
264 if hasattr(self, 'selectedItems'):
265 return self.selectedItems()
266 else:
267 item_from_index = self.model().itemFromIndex
268 return [item_from_index(i) for i in self.selectedIndexes()]
270 def selected_item(self):
271 """Return the first selected item"""
272 selected_items = self.selected_items()
273 if not selected_items:
274 return None
275 return selected_items[0]
277 def current_item(self):
278 if hasattr(self, 'currentItem'):
279 item = self.currentItem()
280 else:
281 index = self.currentIndex()
282 if index.isValid():
283 item = self.model().itemFromIndex(index)
284 else:
285 item = None
286 return item
289 class DraggableTreeMixin(TreeMixin):
290 """A tree widget with internal drag+drop reordering of rows"""
292 ITEMS_MOVED_SIGNAL = 'items_moved'
294 def __init__(self, QtClass):
295 super(DraggableTreeMixin, self).__init__(QtClass)
296 self.setAcceptDrops(True)
297 self.setSelectionMode(self.SingleSelection)
298 self.setDragEnabled(True)
299 self.setDropIndicatorShown(True)
300 self.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
301 self.setSortingEnabled(False)
302 self._inner_drag = False
304 def dragEnterEvent(self, event):
305 """Accept internal drags only"""
306 self.QtClass.dragEnterEvent(self, event)
307 self._inner_drag = event.source() == self
308 if self._inner_drag:
309 event.acceptProposedAction()
310 else:
311 event.ignore()
313 def dragLeaveEvent(self, event):
314 self.QtClass.dragLeaveEvent(self, event)
315 if self._inner_drag:
316 event.accept()
317 else:
318 event.ignore()
319 self._inner_drag = False
321 def dropEvent(self, event):
322 """Re-select selected items after an internal move"""
323 if not self._inner_drag:
324 event.ignore()
325 return
326 clicked_items = self.selected_items()
327 event.setDropAction(Qt.MoveAction)
328 self.QtClass.dropEvent(self, event)
330 if clicked_items:
331 self.clearSelection()
332 for item in clicked_items:
333 self.setItemSelected(item, True)
334 self.emit(SIGNAL(self.ITEMS_MOVED_SIGNAL), clicked_items)
335 self._inner_drag = False
336 event.accept() # must be called after dropEvent()
338 def mousePressEvent(self, event):
339 """Clear the selection when a mouse click hits no item"""
340 clicked_item = self.itemAt(event.pos())
341 if clicked_item is None:
342 self.clearSelection()
343 return self.QtClass.mousePressEvent(self, event)
346 class Widget(WidgetMixin, QtGui.QWidget):
348 def __init__(self, parent=None):
349 QtGui.QWidget.__init__(self, parent)
350 WidgetMixin.__init__(self, QtGui.QWidget)
353 class Dialog(WidgetMixin, QtGui.QDialog):
355 def __init__(self, parent=None, save_settings=False):
356 QtGui.QDialog.__init__(self, parent)
357 WidgetMixin.__init__(self, QtGui.QDialog)
358 self._save_settings = save_settings
360 def close(self):
361 if self._save_settings:
362 self.save_settings()
363 return self.QtClass.close(self)
365 def reject(self):
366 if self._save_settings:
367 self.save_settings()
368 return self.QtClass.reject(self)
371 class MainWindow(MainWindowMixin, QtGui.QMainWindow):
373 def __init__(self, parent=None):
374 QtGui.QMainWindow.__init__(self, parent)
375 MainWindowMixin.__init__(self, QtGui.QMainWindow)
376 self.setStyleSheet("""
377 QMainWindow::separator {
378 width: %(separator)spx;
379 height: %(separator)spx;
381 QMainWindow::separator:hover {
382 background: white;
384 """ % dict(separator=defs.separator))
387 class TreeView(TreeMixin, QtGui.QTreeView):
389 def __init__(self, parent=None):
390 QtGui.QTreeView.__init__(self, parent)
391 TreeMixin.__init__(self, QtGui.QTreeView)
394 class TreeWidget(TreeMixin, QtGui.QTreeWidget):
396 def __init__(self, parent=None):
397 QtGui.QTreeWidget.__init__(self, parent)
398 TreeMixin.__init__(self, QtGui.QTreeWidget)
401 class DraggableTreeWidget(DraggableTreeMixin, QtGui.QTreeWidget):
403 def __init__(self, parent=None):
404 QtGui.QTreeWidget.__init__(self, parent)
405 DraggableTreeMixin.__init__(self, QtGui.QTreeWidget)
408 class ProgressDialog(QtGui.QProgressDialog):
409 """Custom progress dialog
411 This dialog ignores the ESC key so that it is not
412 prematurely closed.
414 An thread is spawned to animate the progress label text.
417 def __init__(self, title, label, parent):
418 QtGui.QProgressDialog.__init__(self, parent)
419 self.setFont(qtutils.diff_font())
420 self.setRange(0, 0)
421 self.setCancelButton(None)
422 if parent is not None:
423 self.setWindowModality(Qt.WindowModal)
424 self.progress_thread = ProgressAnimationThread(label, self)
425 self.connect(self.progress_thread,
426 SIGNAL('update_progress(PyQt_PyObject)'),
427 self.update_progress, Qt.QueuedConnection)
429 self.set_details(title, label)
430 self.reset()
432 def set_details(self, title, label):
433 self.setWindowTitle(title)
434 self.setLabelText(label + ' ')
435 self.progress_thread.set_text(label)
437 def update_progress(self, txt):
438 self.setLabelText(txt)
440 def keyPressEvent(self, event):
441 if event.key() != Qt.Key_Escape:
442 QtGui.QProgressDialog.keyPressEvent(self, event)
444 def show(self):
445 QtGui.QApplication.setOverrideCursor(Qt.WaitCursor)
446 self.progress_thread.start()
447 QtGui.QProgressDialog.show(self)
449 def hide(self):
450 QtGui.QApplication.restoreOverrideCursor()
451 self.progress_thread.stop()
452 self.progress_thread.wait()
453 QtGui.QProgressDialog.hide(self)
456 class ProgressAnimationThread(QtCore.QThread):
457 """Emits a pseudo-animated text stream for progress bars"""
459 def __init__(self, txt, parent, timeout=0.1):
460 QtCore.QThread.__init__(self, parent)
461 self.running = False
462 self.txt = txt
463 self.timeout = timeout
464 self.symbols = [
465 '. ..',
466 '.. .',
467 '... ',
468 ' ... ',
469 ' ...',
471 self.idx = -1
473 def set_text(self, txt):
474 self.txt = txt
476 def cycle(self):
477 self.idx = (self.idx + 1) % len(self.symbols)
478 return self.txt + self.symbols[self.idx]
480 def stop(self):
481 self.running = False
483 def run(self):
484 self.running = True
485 while self.running:
486 self.emit(SIGNAL('update_progress(PyQt_PyObject)'), self.cycle())
487 time.sleep(self.timeout)
490 class SpinBox(QtGui.QSpinBox):
491 def __init__(self, parent=None):
492 QtGui.QSpinBox.__init__(self, parent)
493 self.setMinimum(1)
494 self.setMaximum(99999)
495 self.setPrefix('')
496 self.setSuffix('')