Merge pull request #1387 from davvid/remote-dialog
[git-cola.git] / cola / widgets / standard.py
blob2fd7fc687280e8e1417bfa3c7c33725b5d105117
1 from functools import partial
2 import os
3 import time
5 from qtpy import QtCore
6 from qtpy import QtGui
7 from qtpy import QtWidgets
8 from qtpy.QtCore import Qt
9 from qtpy.QtCore import Signal
10 from qtpy.QtWidgets import QDockWidget
12 from ..i18n import N_
13 from ..interaction import Interaction
14 from ..settings import Settings, mklist
15 from ..models import prefs
16 from .. import core
17 from .. import hotkeys
18 from .. import icons
19 from .. import qtcompat
20 from .. import qtutils
21 from .. import utils
22 from . import defs
25 class WidgetMixin:
26 """Mix-in for common utilities and serialization of widget state"""
28 closed = Signal(QtWidgets.QWidget)
30 def __init__(self):
31 self._unmaximized_rect = {}
33 def center(self):
34 parent = self.parent()
35 if parent is None:
36 return
37 left = parent.x()
38 width = parent.width()
39 center_x = left + width // 2
40 x = center_x - self.width() // 2
41 y = parent.y()
43 self.move(x, y)
45 def resize_to_desktop(self):
46 width, height = qtutils.desktop_size()
47 if utils.is_darwin():
48 self.resize(width, height)
49 else:
50 shown = self.isVisible()
51 # earlier show() fools Windows focus stealing prevention. The main
52 # window is blocked for the duration of "git rebase" and we don't
53 # want to present a blocked window with git-cola-sequence-editor
54 # hidden somewhere.
55 self.show()
56 self.setWindowState(Qt.WindowMaximized)
57 if not shown:
58 self.hide()
60 def name(self):
61 """Returns the name of the view class"""
62 return self.__class__.__name__.lower()
64 def save_state(self, settings=None):
65 save = True
66 context = getattr(self, 'context', None)
67 if context:
68 cfg = context.cfg
69 save = cfg.get('cola.savewindowsettings', default=True)
70 if save:
71 if settings is None:
72 settings = Settings.read()
73 settings.save_gui_state(self)
75 def restore_state(self, settings=None):
76 if settings is None:
77 settings = Settings.read()
78 state = settings.get_gui_state(self)
79 if state:
80 result = self.apply_state(state)
81 else:
82 result = False
83 return result
85 def apply_state(self, state):
86 """Imports data for view save/restore"""
88 width = utils.asint(state.get('width'))
89 height = utils.asint(state.get('height'))
90 x = utils.asint(state.get('x'))
91 y = utils.asint(state.get('y'))
93 geometry = state.get('geometry', '')
94 if geometry:
95 from_base64 = QtCore.QByteArray.fromBase64
96 result = self.restoreGeometry(from_base64(core.encode(geometry)))
97 elif width and height:
98 # Users migrating from older versions won't have 'geometry'.
99 # They'll be upgraded to the new format on shutdown.
100 self.resize(width, height)
101 self.move(x, y)
102 result = True
103 else:
104 result = False
105 return result
107 def export_state(self):
108 """Exports data for view save/restore"""
109 state = {}
110 geometry = self.saveGeometry()
111 state['geometry'] = geometry.toBase64().data().decode('ascii')
112 # Until 2020: co-exist with older versions
113 state['width'] = self.width()
114 state['height'] = self.height()
115 state['x'] = self.x()
116 state['y'] = self.y()
117 return state
119 def save_settings(self, settings=None):
120 return self.save_state(settings=settings)
122 def closeEvent(self, event):
123 self.save_settings()
124 self.closed.emit(self)
125 self.Base.closeEvent(self, event)
127 def init_size(self, parent=None, settings=None, width=0, height=0):
128 if not width:
129 width = defs.dialog_w
130 if not height:
131 height = defs.dialog_h
132 self.init_state(settings, self.resize_to_parent, parent, width, height)
134 def init_state(self, settings, callback, *args, **kwargs):
135 """Restore saved settings or set the initial location"""
136 if not self.restore_state(settings=settings):
137 callback(*args, **kwargs)
138 self.center()
140 def resize_to_parent(self, parent, w, h):
141 """Set the initial size of the widget"""
142 width, height = qtutils.default_size(parent, w, h)
143 self.resize(width, height)
146 class MainWindowMixin(WidgetMixin):
147 def __init__(self):
148 WidgetMixin.__init__(self)
149 # Dockwidget options
150 self.dockwidgets = []
151 self.lock_layout = False
152 self.widget_version = 0
153 qtcompat.set_common_dock_options(self)
154 self.default_state = None
156 def init_state(self, settings, callback, *args, **kwargs):
157 """Save the initial state before calling the parent initializer"""
158 self.default_state = self.saveState(self.widget_version)
159 super().init_state(settings, callback, *args, **kwargs)
161 def export_state(self):
162 """Exports data for save/restore"""
163 state = WidgetMixin.export_state(self)
164 windowstate = self.saveState(self.widget_version)
165 state['lock_layout'] = self.lock_layout
166 state['windowstate'] = windowstate.toBase64().data().decode('ascii')
167 return state
169 def save_settings(self, settings=None):
170 if settings is None:
171 context = getattr(self, 'context', None)
172 if context is None:
173 settings = Settings.read()
174 else:
175 settings = context.settings
176 settings.load()
177 settings.add_recent(core.getcwd(), prefs.maxrecent(context))
178 return WidgetMixin.save_settings(self, settings=settings)
180 def apply_state(self, state):
181 result = WidgetMixin.apply_state(self, state)
182 windowstate = state.get('windowstate', '')
183 if windowstate:
184 from_base64 = QtCore.QByteArray.fromBase64
185 result = (
186 self.restoreState(
187 from_base64(core.encode(windowstate)), self.widget_version
189 and result
191 else:
192 result = False
194 self.lock_layout = state.get('lock_layout', self.lock_layout)
195 self.update_dockwidget_lock_state()
196 self.update_dockwidget_tooltips()
198 return result
200 def reset_layout(self):
201 self.restoreState(self.default_state, self.widget_version)
203 def set_lock_layout(self, lock_layout):
204 self.lock_layout = lock_layout
205 self.update_dockwidget_lock_state()
207 def update_dockwidget_lock_state(self):
208 if self.lock_layout:
209 features = QDockWidget.DockWidgetClosable
210 else:
211 features = (
212 QDockWidget.DockWidgetClosable
213 | QDockWidget.DockWidgetFloatable
214 | QDockWidget.DockWidgetMovable
216 for widget in self.dockwidgets:
217 widget.titleBarWidget().update_tooltips()
218 widget.setFeatures(features)
220 def update_dockwidget_tooltips(self):
221 for widget in self.dockwidgets:
222 widget.titleBarWidget().update_tooltips()
225 class ListWidget(QtWidgets.QListWidget):
226 """QListWidget with vim j/k navigation hotkeys"""
228 def __init__(self, parent=None):
229 super().__init__(parent)
231 self.up_action = qtutils.add_action(
232 self,
233 N_('Move Up'),
234 self.move_up,
235 hotkeys.MOVE_UP,
236 hotkeys.MOVE_UP_SECONDARY,
239 self.down_action = qtutils.add_action(
240 self,
241 N_('Move Down'),
242 self.move_down,
243 hotkeys.MOVE_DOWN,
244 hotkeys.MOVE_DOWN_SECONDARY,
247 def selected_item(self):
248 return self.currentItem()
250 def selected_items(self):
251 return self.selectedItems()
253 def move_up(self):
254 self.move(-1)
256 def move_down(self):
257 self.move(1)
259 def move(self, direction):
260 item = self.selected_item()
261 if item:
262 row = (self.row(item) + direction) % self.count()
263 elif self.count() > 0:
264 row = (self.count() + direction) % self.count()
265 else:
266 return
267 new_item = self.item(row)
268 if new_item:
269 self.setCurrentItem(new_item)
272 class TreeMixin:
273 def __init__(self, widget, Base):
274 self.widget = widget
275 self.Base = Base
277 widget.setAlternatingRowColors(True)
278 widget.setUniformRowHeights(True)
279 widget.setAllColumnsShowFocus(True)
280 widget.setAnimated(True)
281 widget.setRootIsDecorated(False)
283 def keyPressEvent(self, event):
285 Make LeftArrow to work on non-directories.
287 When LeftArrow is pressed on a file entry or an unexpanded
288 directory, then move the current index to the parent directory.
290 This simplifies navigation using the keyboard.
291 For power-users, we support Vim keybindings ;-P
294 # Check whether the item is expanded before calling the base class
295 # keyPressEvent otherwise we end up collapsing and changing the
296 # current index in one shot, which we don't want to do.
297 widget = self.widget
298 index = widget.currentIndex()
299 was_expanded = widget.isExpanded(index)
300 was_collapsed = not was_expanded
302 # Vim keybindings...
303 event = _create_vim_navigation_key_event(event)
305 # Read the updated event key to take the mappings into account
306 key = event.key()
307 if key == Qt.Key_Up:
308 idxs = widget.selectedIndexes()
309 rows = [idx.row() for idx in idxs]
310 if len(rows) == 1 and rows[0] == 0:
311 # The cursor is at the beginning of the line.
312 # If we have selection then simply reset the cursor.
313 # Otherwise, emit a signal so that the parent can
314 # change focus.
315 widget.up.emit()
317 elif key == Qt.Key_Space:
318 widget.space.emit()
320 result = self.Base.keyPressEvent(widget, event)
322 # Let others hook in here before we change the indexes
323 widget.index_about_to_change.emit()
325 # Automatically select the first entry when expanding a directory
326 if key == Qt.Key_Right and was_collapsed and widget.isExpanded(index):
327 index = widget.moveCursor(widget.MoveDown, event.modifiers())
328 widget.setCurrentIndex(index)
330 # Process non-root entries with valid parents only.
331 elif key == Qt.Key_Left and index.parent().isValid():
332 # File entries have rowCount() == 0
333 model = widget.model()
334 if hasattr(model, 'itemFromIndex'):
335 item = model.itemFromIndex(index)
336 if hasattr(item, 'rowCount') and item.rowCount() == 0:
337 widget.setCurrentIndex(index.parent())
339 # Otherwise, do this for collapsed directories only
340 elif was_collapsed:
341 widget.setCurrentIndex(index.parent())
343 # If it's a movement key ensure we have a selection
344 elif key in (Qt.Key_Left, Qt.Key_Up, Qt.Key_Right, Qt.Key_Down):
345 # Try to select the first item if the model index is invalid
346 item = self.selected_item()
347 if item is None or not index.isValid():
348 index = widget.model().index(0, 0, QtCore.QModelIndex())
349 if index.isValid():
350 widget.setCurrentIndex(index)
352 return result
354 def item_from_index(self, item):
355 """Return a QModelIndex from the provided item"""
356 if hasattr(self, 'itemFromIndex'):
357 index = self.itemFromIndex(item)
358 else:
359 index = self.model().itemFromIndex()
360 return index
362 def items(self):
363 root = self.widget.invisibleRootItem()
364 child = root.child
365 count = root.childCount()
366 return [child(i) for i in range(count)]
368 def selected_items(self):
369 """Return all selected items"""
370 widget = self.widget
371 if hasattr(widget, 'selectedItems'):
372 return widget.selectedItems()
373 if hasattr(widget, 'itemFromIndex'):
374 item_from_index = widget.itemFromIndex
375 else:
376 item_from_index = widget.model().itemFromIndex
377 return [item_from_index(i) for i in widget.selectedIndexes()]
379 def selected_item(self):
380 """Return the first selected item"""
381 selected_items = self.selected_items()
382 if not selected_items:
383 return None
384 return selected_items[0]
386 def current_item(self):
387 item = None
388 widget = self.widget
389 if hasattr(widget, 'currentItem'):
390 item = widget.currentItem()
391 else:
392 index = widget.currentIndex()
393 if index.isValid():
394 item = widget.model().itemFromIndex(index)
395 return item
397 def column_widths(self):
398 """Return the tree's column widths"""
399 widget = self.widget
400 count = widget.header().count()
401 return [widget.columnWidth(i) for i in range(count)]
403 def set_column_widths(self, widths):
404 """Set the tree's column widths"""
405 if widths:
406 widget = self.widget
407 count = widget.header().count()
408 if len(widths) > count:
409 widths = widths[:count]
410 for idx, value in enumerate(widths):
411 widget.setColumnWidth(idx, value)
414 def _create_vim_navigation_key_event(event):
415 """Support minimal Vim-like keybindings by rewriting the QKeyEvents"""
416 key = event.key()
417 # Remap 'H' to 'Left'
418 if key == Qt.Key_H:
419 event = QtGui.QKeyEvent(event.type(), Qt.Key_Left, event.modifiers())
420 # Remap 'J' to 'Down'
421 elif key == Qt.Key_J:
422 event = QtGui.QKeyEvent(event.type(), Qt.Key_Down, event.modifiers())
423 # Remap 'K' to 'Up'
424 elif key == Qt.Key_K:
425 event = QtGui.QKeyEvent(event.type(), Qt.Key_Up, event.modifiers())
426 # Remap 'L' to 'Right'
427 elif key == Qt.Key_L:
428 event = QtGui.QKeyEvent(event.type(), Qt.Key_Right, event.modifiers())
429 return event
432 class DraggableTreeMixin(TreeMixin):
433 """A tree widget with internal drag+drop reordering of rows
435 Expects that the widget provides an `items_moved` signal.
439 def __init__(self, widget, Base):
440 super().__init__(widget, Base)
442 self._inner_drag = False
443 widget.setAcceptDrops(True)
444 widget.setSelectionMode(widget.SingleSelection)
445 widget.setDragEnabled(True)
446 widget.setDropIndicatorShown(True)
447 widget.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
448 widget.setSortingEnabled(False)
450 def dragEnterEvent(self, event):
451 """Accept internal drags only"""
452 widget = self.widget
453 self.Base.dragEnterEvent(widget, event)
454 self._inner_drag = event.source() == widget
455 if self._inner_drag:
456 event.acceptProposedAction()
457 else:
458 event.ignore()
460 def dragLeaveEvent(self, event):
461 widget = self.widget
462 self.Base.dragLeaveEvent(widget, event)
463 if self._inner_drag:
464 event.accept()
465 else:
466 event.ignore()
467 self._inner_drag = False
469 def dropEvent(self, event):
470 """Re-select selected items after an internal move"""
471 if not self._inner_drag:
472 event.ignore()
473 return
474 widget = self.widget
475 clicked_items = self.selected_items()
476 event.setDropAction(Qt.MoveAction)
477 self.Base.dropEvent(widget, event)
479 if clicked_items:
480 widget.clearSelection()
481 for item in clicked_items:
482 item.setSelected(True)
483 widget.items_moved.emit(clicked_items)
484 self._inner_drag = False
485 event.accept() # must be called after dropEvent()
487 def mousePressEvent(self, event):
488 """Clear the selection when a mouse click hits no item"""
489 widget = self.widget
490 clicked_item = widget.itemAt(event.pos())
491 if clicked_item is None:
492 widget.clearSelection()
493 return self.Base.mousePressEvent(widget, event)
496 class Widget(WidgetMixin, QtWidgets.QWidget):
497 Base = QtWidgets.QWidget
499 def __init__(self, parent=None):
500 QtWidgets.QWidget.__init__(self, parent)
501 WidgetMixin.__init__(self)
504 class Dialog(WidgetMixin, QtWidgets.QDialog):
505 Base = QtWidgets.QDialog
507 def __init__(self, parent=None):
508 QtWidgets.QDialog.__init__(self, parent)
509 WidgetMixin.__init__(self)
510 # Disable the Help button hint on Windows
511 if hasattr(Qt, 'WindowContextHelpButtonHint'):
512 help_hint = Qt.WindowContextHelpButtonHint
513 flags = self.windowFlags() & ~help_hint
514 self.setWindowFlags(flags)
516 def accept(self):
517 self.save_settings()
518 self.dispose()
519 return self.Base.accept(self)
521 def reject(self):
522 self.save_settings()
523 self.dispose()
524 return self.Base.reject(self)
526 def dispose(self):
527 """Extension method for model de-registration in sub-classes"""
528 return
530 def close(self):
531 """save_settings() is handled by accept() and reject()"""
532 self.dispose()
533 self.Base.close(self)
535 def closeEvent(self, event):
536 """save_settings() is handled by accept() and reject()"""
537 self.dispose()
538 self.Base.closeEvent(self, event)
541 class MainWindow(MainWindowMixin, QtWidgets.QMainWindow):
542 Base = QtWidgets.QMainWindow
544 def __init__(self, parent=None):
545 QtWidgets.QMainWindow.__init__(self, parent)
546 MainWindowMixin.__init__(self)
549 class TreeView(QtWidgets.QTreeView):
550 Mixin = TreeMixin
552 up = Signal()
553 space = Signal()
554 index_about_to_change = Signal()
556 def __init__(self, parent=None):
557 QtWidgets.QTreeView.__init__(self, parent)
558 self._mixin = self.Mixin(self, QtWidgets.QTreeView)
560 def keyPressEvent(self, event):
561 return self._mixin.keyPressEvent(event)
563 def current_item(self):
564 return self._mixin.current_item()
566 def selected_item(self):
567 return self._mixin.selected_item()
569 def selected_items(self):
570 return self._mixin.selected_items()
572 def items(self):
573 return self._mixin.items()
575 def column_widths(self):
576 return self._mixin.column_widths()
578 def set_column_widths(self, widths):
579 return self._mixin.set_column_widths(widths)
582 class TreeWidget(QtWidgets.QTreeWidget):
583 Mixin = TreeMixin
585 up = Signal()
586 space = Signal()
587 index_about_to_change = Signal()
589 def __init__(self, parent=None):
590 super().__init__(parent)
591 self._mixin = self.Mixin(self, QtWidgets.QTreeWidget)
593 def keyPressEvent(self, event):
594 return self._mixin.keyPressEvent(event)
596 def current_item(self):
597 return self._mixin.current_item()
599 def selected_item(self):
600 return self._mixin.selected_item()
602 def selected_items(self):
603 return self._mixin.selected_items()
605 def items(self):
606 return self._mixin.items()
608 def column_widths(self):
609 return self._mixin.column_widths()
611 def set_column_widths(self, widths):
612 return self._mixin.set_column_widths(widths)
615 class DraggableTreeWidget(TreeWidget):
616 Mixin = DraggableTreeMixin
617 items_moved = Signal(object)
619 def mousePressEvent(self, event):
620 return self._mixin.mousePressEvent(event)
622 def dropEvent(self, event):
623 return self._mixin.dropEvent(event)
625 def dragLeaveEvent(self, event):
626 return self._mixin.dragLeaveEvent(event)
628 def dragEnterEvent(self, event):
629 return self._mixin.dragEnterEvent(event)
632 class ProgressDialog(QtWidgets.QProgressDialog):
633 """Custom progress dialog
635 This dialog ignores the ESC key so that it is not
636 prematurely closed.
638 A thread is spawned to animate the progress label text.
642 def __init__(self, title, label, parent):
643 QtWidgets.QProgressDialog.__init__(self, parent)
644 self._parent = parent
645 if parent is not None:
646 self.setWindowModality(Qt.WindowModal)
648 self.animation_thread = ProgressAnimationThread(label, self)
649 self.animation_thread.updated.connect(self.set_text, type=Qt.QueuedConnection)
651 self.reset()
652 self.setRange(0, 0)
653 self.setMinimumDuration(0)
654 self.setCancelButton(None)
655 self.setFont(qtutils.default_monospace_font())
656 self.set_details(title, label)
658 def set_details(self, title, label):
659 """Update the window title and progress label"""
660 self.setWindowTitle(title)
661 self.setLabelText(label + ' ')
662 self.animation_thread.set_text(label)
664 def set_text(self, txt):
665 """Set the label text"""
666 self.setLabelText(txt)
668 def keyPressEvent(self, event):
669 """Customize keyPressEvent to remove the ESC key cancel feature"""
670 if event.key() != Qt.Key_Escape:
671 super().keyPressEvent(event)
673 def start(self):
674 """Start the animation thread and use a wait cursor"""
675 self.show()
676 QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor)
677 self.animation_thread.start()
679 def stop(self):
680 """Stop the animation thread and restore the normal cursor"""
681 self.animation_thread.stop()
682 self.animation_thread.wait()
683 QtWidgets.QApplication.restoreOverrideCursor()
684 self.hide()
687 class ProgressAnimationThread(QtCore.QThread):
688 """Emits a pseudo-animated text stream for progress bars"""
690 # The updated signal is emitted on each tick.
691 updated = Signal(object)
693 def __init__(self, txt, parent, sleep_time=0.1):
694 QtCore.QThread.__init__(self, parent)
695 self.running = False
696 self.txt = txt
697 self.sleep_time = sleep_time
698 self.symbols = [
699 '. ..',
700 '.. .',
701 '... ',
702 ' ... ',
703 ' ...',
705 self.idx = -1
707 def set_text(self, txt):
708 """Set the text prefix"""
709 self.txt = txt
711 def tick(self):
712 """Tick to the next animated text value"""
713 self.idx = (self.idx + 1) % len(self.symbols)
714 return self.txt + self.symbols[self.idx]
716 def stop(self):
717 """Stop the animation thread"""
718 self.running = False
720 def run(self):
721 """Emit ticks until stopped"""
722 self.running = True
723 while self.running:
724 self.updated.emit(self.tick())
725 time.sleep(self.sleep_time)
728 class ProgressTickThread(QtCore.QThread):
729 """Emits an int stream for progress bars"""
731 # The updated signal emits progress tick values.
732 updated = Signal(int)
733 # The activated signal is emitted when the progress bar is displayed.
734 activated = Signal()
736 def __init__(
737 self,
738 parent,
739 maximum,
740 start_time=1.0,
741 sleep_time=0.05,
743 QtCore.QThread.__init__(self, parent)
744 self.running = False
745 self.sleep_time = sleep_time
746 self.maximum = maximum
747 self.start_time = start_time
748 self.value = 0
749 self.step = 1
751 def tick(self):
752 """Cycle to the next tick value
754 Returned values are in the inclusive (0, maximum + 1) range.
756 self.value = (self.value + self.step) % (self.maximum + 1)
757 if self.value == self.maximum:
758 self.step = -1
759 elif self.value == 0:
760 self.step = 1
761 return self.value
763 def stop(self):
764 """Stop the tick thread and reset to the initial state"""
765 self.running = False
766 self.value = 0
767 self.step = 1
769 def run(self):
770 """Start the tick thread
772 The progress bar will not be activated until after the start_time
773 interval has elapsed.
775 initial_time = time.time()
776 active = False
777 self.running = True
778 self.value = 0
779 self.step = 1
780 while self.running:
781 if active:
782 self.updated.emit(self.tick())
783 else:
784 now = time.time()
785 if self.start_time < (now - initial_time):
786 active = True
787 self.activated.emit()
788 time.sleep(self.sleep_time)
791 class SpinBox(QtWidgets.QSpinBox):
792 def __init__(
793 self, parent=None, value=None, mini=1, maxi=99999, step=0, prefix='', suffix=''
795 QtWidgets.QSpinBox.__init__(self, parent)
796 self.setPrefix(prefix)
797 self.setSuffix(suffix)
798 self.setWrapping(True)
799 self.setMinimum(mini)
800 self.setMaximum(maxi)
801 if step:
802 self.setSingleStep(step)
803 if value is not None:
804 self.setValue(value)
805 text_width = qtutils.text_width(self.font(), 'MMMMMM')
806 width = max(self.minimumWidth(), text_width)
807 self.setMinimumWidth(width)
810 class DirectoryPathLineEdit(QtWidgets.QWidget):
811 """A combined line edit and file browser button"""
813 def __init__(self, path, parent):
814 QtWidgets.QWidget.__init__(self, parent)
816 self.line_edit = QtWidgets.QLineEdit()
817 self.line_edit.setText(path)
819 self.browse_button = qtutils.create_button(
820 tooltip=N_('Select directory'), icon=icons.folder()
822 layout = qtutils.hbox(
823 defs.no_margin,
824 defs.spacing,
825 self.browse_button,
826 self.line_edit,
828 self.setLayout(layout)
830 qtutils.connect_button(self.browse_button, self._select_directory)
832 def set_value(self, value):
833 """Set the path value"""
834 self.line_edit.setText(value)
836 def value(self):
837 """Return the current path value"""
838 return self.line_edit.text().strip()
840 def _select_directory(self):
841 """Open a file browser and select a directory"""
842 output_dir = qtutils.opendir_dialog(N_('Select directory'), self.value())
843 if not output_dir:
844 return
845 # Make the directory relative only if it the current directory or
846 # or subdirectory from the current directory.
847 current_dir = core.getcwd()
848 if output_dir == current_dir:
849 output_dir = '.'
850 elif output_dir.startswith(current_dir + os.sep):
851 output_dir = os.path.relpath(output_dir)
852 self.set_value(output_dir)
855 def export_header_columns(widget, state):
856 """Save QHeaderView column sizes"""
857 columns = []
858 header = widget.horizontalHeader()
859 for idx in range(header.count()):
860 columns.append(header.sectionSize(idx))
862 state['columns'] = columns
865 def apply_header_columns(widget, state):
866 """Apply QHeaderView column sizes"""
867 columns = mklist(state.get('columns', []))
868 header = widget.horizontalHeader()
869 if header.stretchLastSection():
870 # Setting the size will make the section wider than necessary, which
871 # defeats the purpose of the stretch flag. Skip the last column when
872 # it's stretchy so that it retains the stretchy behavior.
873 columns = columns[:-1]
874 for idx, size in enumerate(columns):
875 header.resizeSection(idx, size)
878 class MessageBox(Dialog):
879 """Improved QMessageBox replacement
881 QMessageBox has a lot of usability issues. It sometimes cannot be
882 resized, and it brings along a lots of annoying properties that we'd have
883 to workaround, so we use a simple custom dialog instead.
887 def __init__(
888 self,
889 parent=None,
890 title='',
891 text='',
892 info='',
893 details='',
894 logo=None,
895 default=False,
896 ok_icon=None,
897 ok_text='',
898 cancel_text=None,
899 cancel_icon=None,
901 Dialog.__init__(self, parent=parent)
903 if parent:
904 self.setWindowModality(Qt.WindowModal)
905 if title:
906 self.setWindowTitle(title)
908 self.logo_label = QtWidgets.QLabel()
909 if logo:
910 # Render into a 1-inch wide pixmap
911 pixmap = logo.pixmap(defs.large_icon)
912 self.logo_label.setPixmap(pixmap)
913 else:
914 self.logo_label.hide()
916 self.text_label = QtWidgets.QLabel()
917 self.text_label.setText(text)
919 self.info_label = QtWidgets.QLabel()
920 if info:
921 self.info_label.setText(info)
922 else:
923 self.info_label.hide()
925 ok_icon = icons.mkicon(ok_icon, icons.ok)
926 self.button_ok = qtutils.create_button(text=ok_text, icon=ok_icon)
928 self.button_close = qtutils.close_button(text=cancel_text, icon=cancel_icon)
930 if ok_text:
931 self.button_ok.setText(ok_text)
932 else:
933 self.button_ok.hide()
935 self.details_text = QtWidgets.QPlainTextEdit()
936 self.details_text.setReadOnly(True)
937 if details:
938 self.details_text.setFont(qtutils.default_monospace_font())
939 self.details_text.setPlainText(details)
940 else:
941 self.details_text.hide()
943 self.info_layout = qtutils.vbox(
944 defs.large_margin,
945 defs.button_spacing,
946 self.text_label,
947 self.info_label,
948 qtutils.STRETCH,
951 self.top_layout = qtutils.hbox(
952 defs.large_margin,
953 defs.button_spacing,
954 self.logo_label,
955 self.info_layout,
956 qtutils.STRETCH,
959 self.buttons_layout = qtutils.hbox(
960 defs.no_margin,
961 defs.button_spacing,
962 qtutils.STRETCH,
963 self.button_close,
964 self.button_ok,
967 self.main_layout = qtutils.vbox(
968 defs.margin,
969 defs.button_spacing,
970 self.top_layout,
971 self.buttons_layout,
972 self.details_text,
974 self.main_layout.setStretchFactor(self.details_text, 2)
975 self.setLayout(self.main_layout)
977 if default:
978 self.button_ok.setDefault(True)
979 self.button_ok.setFocus()
980 else:
981 self.button_close.setDefault(True)
982 self.button_close.setFocus()
984 qtutils.connect_button(self.button_ok, self.accept)
985 qtutils.connect_button(self.button_close, self.reject)
986 self.init_state(None, self.set_initial_size)
988 def set_initial_size(self):
989 width = defs.dialog_w
990 height = defs.msgbox_h
991 self.resize(width, height)
993 def keyPressEvent(self, event):
994 """Handle Y/N hotkeys"""
995 key = event.key()
996 if key == Qt.Key_Y:
997 QtCore.QTimer.singleShot(0, self.accept)
998 elif key in (Qt.Key_N, Qt.Key_Q):
999 QtCore.QTimer.singleShot(0, self.reject)
1000 elif key == Qt.Key_Tab:
1001 if self.button_ok.isVisible():
1002 event.accept()
1003 if self.focusWidget() == self.button_close:
1004 self.button_ok.setFocus()
1005 else:
1006 self.button_close.setFocus()
1007 return
1008 Dialog.keyPressEvent(self, event)
1010 def run(self):
1011 self.show()
1012 return self.exec_()
1014 def apply_state(self, state):
1015 """Imports data for view save/restore"""
1016 desktop_width, desktop_height = qtutils.desktop_size()
1017 width = min(desktop_width, utils.asint(state.get('width')))
1018 height = min(desktop_height, utils.asint(state.get('height')))
1019 x = min(desktop_width, utils.asint(state.get('x')))
1020 y = min(desktop_height, utils.asint(state.get('y')))
1021 result = False
1023 if width and height:
1024 self.resize(width, height)
1025 self.move(x, y)
1026 result = True
1028 return result
1030 def export_state(self):
1031 """Exports data for view save/restore"""
1032 desktop_width, desktop_height = qtutils.desktop_size()
1033 state = {}
1034 state['width'] = min(desktop_width, self.width())
1035 state['height'] = min(desktop_height, self.height())
1036 state['x'] = min(desktop_width, self.x())
1037 state['y'] = min(desktop_height, self.y())
1038 return state
1041 def confirm(
1042 title,
1043 text,
1044 informative_text,
1045 ok_text,
1046 icon=None,
1047 default=True,
1048 cancel_text=None,
1049 cancel_icon=None,
1051 """Confirm that an action should take place"""
1052 cancel_text = cancel_text or N_('Cancel')
1053 logo = icons.from_style(QtWidgets.QStyle.SP_MessageBoxQuestion)
1055 mbox = MessageBox(
1056 parent=qtutils.active_window(),
1057 title=title,
1058 text=text,
1059 info=informative_text,
1060 ok_text=ok_text,
1061 ok_icon=icon,
1062 cancel_text=cancel_text,
1063 cancel_icon=cancel_icon,
1064 logo=logo,
1065 default=default,
1068 return mbox.run() == mbox.Accepted
1071 def critical(title, message=None, details=None):
1072 """Show a warning with the provided title and message."""
1073 if message is None:
1074 message = title
1075 logo = icons.from_style(QtWidgets.QStyle.SP_MessageBoxCritical)
1076 mbox = MessageBox(
1077 parent=qtutils.active_window(),
1078 title=title,
1079 text=message,
1080 details=details,
1081 logo=logo,
1083 mbox.run()
1086 def command_error(title, cmd, status, out, err):
1087 """Report an error message about a failed command"""
1088 details = Interaction.format_out_err(out, err)
1089 message = Interaction.format_command_status(cmd, status)
1090 critical(title, message=message, details=details)
1093 def information(title, message=None, details=None, informative_text=None):
1094 """Show information with the provided title and message."""
1095 if message is None:
1096 message = title
1097 mbox = MessageBox(
1098 parent=qtutils.active_window(),
1099 title=title,
1100 text=message,
1101 info=informative_text,
1102 details=details,
1103 logo=icons.cola(),
1105 mbox.run()
1108 def progress(title, text, parent):
1109 """Create a new ProgressDialog"""
1110 return ProgressDialog(title, text, parent)
1113 class ProgressBar(QtWidgets.QProgressBar):
1114 """An indeterminate progress bar with animated scrolling"""
1116 def __init__(self, parent, maximum, hide=(), disable=(), visible=False):
1117 super().__init__(parent)
1118 self.setTextVisible(False)
1119 self.setMaximum(maximum)
1120 if not visible:
1121 self.setVisible(False)
1122 self.progress_thread = ProgressTickThread(self, maximum)
1123 self.progress_thread.updated.connect(self.setValue, type=Qt.QueuedConnection)
1124 self.progress_thread.activated.connect(self.activate, type=Qt.QueuedConnection)
1125 self._widgets_to_hide = hide
1126 self._widgets_to_disable = disable
1128 def start(self):
1129 """Start the progress tick thread"""
1130 QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor)
1132 for widget in self._widgets_to_disable:
1133 widget.setEnabled(False)
1135 self.progress_thread.start()
1137 def activate(self):
1138 """Hide widgets and display the progress bar"""
1139 for widget in self._widgets_to_hide:
1140 widget.hide()
1141 self.show()
1143 def stop(self):
1144 """Stop the progress tick thread, re-enable and display widgets"""
1145 self.progress_thread.stop()
1146 self.progress_thread.wait()
1148 for widget in self._widgets_to_disable:
1149 widget.setEnabled(True)
1151 self.hide()
1152 for widget in self._widgets_to_hide:
1153 widget.show()
1155 QtWidgets.QApplication.restoreOverrideCursor()
1158 def progress_bar(parent, maximum=10, hide=(), disable=()):
1159 """Return a text-less progress bar"""
1160 widget = ProgressBar(parent, maximum, hide=hide, disable=disable)
1161 return widget
1164 def question(title, text, default=True, logo=None):
1165 """Launches a QMessageBox question with the provided title and message.
1166 Passing "default=False" will make "No" the default choice."""
1167 parent = qtutils.active_window()
1168 if logo is None:
1169 logo = icons.from_style(QtWidgets.QStyle.SP_MessageBoxQuestion)
1170 msgbox = MessageBox(
1171 parent=parent,
1172 title=title,
1173 text=text,
1174 default=default,
1175 logo=logo,
1176 ok_text=N_('Yes'),
1177 cancel_text=N_('No'),
1179 return msgbox.run() == msgbox.Accepted
1182 def save_as(filename, title):
1183 return qtutils.save_as(filename, title=title)
1186 def async_command(title, cmd, runtask):
1187 task = qtutils.SimpleTask(partial(core.run_command, cmd))
1188 task.connect(partial(async_command_result, title, cmd))
1189 runtask.start(task)
1192 def async_command_result(title, cmd, result):
1193 status, out, err = result
1194 cmd_string = core.list2cmdline(cmd)
1195 Interaction.command(title, cmd_string, status, out, err)
1198 def install():
1199 """Install the GUI-model interaction hooks"""
1200 Interaction.critical = staticmethod(critical)
1201 Interaction.confirm = staticmethod(confirm)
1202 Interaction.question = staticmethod(question)
1203 Interaction.information = staticmethod(information)
1204 Interaction.command_error = staticmethod(command_error)
1205 Interaction.save_as = staticmethod(save_as)
1206 Interaction.async_command = staticmethod(async_command)