Merge pull request #1202 from living180/no_channel_parent
[git-cola.git] / cola / widgets / standard.py
blobfbbb58018fe5f21a1ab2e4b10b0d02e666933fc3
1 from __future__ import absolute_import, division, print_function, unicode_literals
2 import time
3 from functools import partial
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(object):
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 desktop = QtWidgets.QApplication.instance().desktop()
47 width = desktop.width()
48 height = desktop.height()
49 if utils.is_darwin():
50 self.resize(width, height)
51 else:
52 shown = self.isVisible()
53 # earlier show() fools Windows focus stealing prevention. the main
54 # window is blocked for the duration of "git rebase" and we don't
55 # want to present a blocked window with git-cola-sequence-editor
56 # hidden somewhere.
57 self.show()
58 self.setWindowState(Qt.WindowMaximized)
59 if not shown:
60 self.hide()
62 def name(self):
63 """Returns the name of the view class"""
64 return self.__class__.__name__.lower()
66 def save_state(self, settings=None):
67 save = True
68 context = getattr(self, 'context', None)
69 if context:
70 cfg = context.cfg
71 save = cfg.get('cola.savewindowsettings', default=True)
72 if save:
73 if settings is None:
74 settings = Settings.read()
75 settings.save_gui_state(self)
77 def restore_state(self, settings=None):
78 if settings is None:
79 settings = Settings.read()
80 state = settings.get_gui_state(self)
81 if state:
82 result = self.apply_state(state)
83 else:
84 result = False
85 return result
87 def apply_state(self, state):
88 """Imports data for view save/restore"""
90 width = utils.asint(state.get('width'))
91 height = utils.asint(state.get('height'))
92 x = utils.asint(state.get('x'))
93 y = utils.asint(state.get('y'))
95 geometry = state.get('geometry', '')
96 if geometry:
97 from_base64 = QtCore.QByteArray.fromBase64
98 result = self.restoreGeometry(from_base64(core.encode(geometry)))
99 elif width and height:
100 # Users migrating from older versions won't have 'geometry'.
101 # They'll be upgraded to the new format on shutdown.
102 self.resize(width, height)
103 self.move(x, y)
104 result = True
105 else:
106 result = False
107 return result
109 def export_state(self):
110 """Exports data for view save/restore"""
111 state = {}
112 geometry = self.saveGeometry()
113 state['geometry'] = geometry.toBase64().data().decode('ascii')
114 # Until 2020: co-exist with older versions
115 state['width'] = self.width()
116 state['height'] = self.height()
117 state['x'] = self.x()
118 state['y'] = self.y()
119 return state
121 def save_settings(self, settings=None):
122 return self.save_state(settings=settings)
124 def closeEvent(self, event):
125 self.save_settings()
126 self.closed.emit(self)
127 self.Base.closeEvent(self, event)
129 def init_size(self, parent=None, settings=None, width=0, height=0):
130 if not width:
131 width = defs.dialog_w
132 if not height:
133 height = defs.dialog_h
134 self.init_state(settings, self.resize_to_parent, parent, width, height)
136 def init_state(self, settings, callback, *args, **kwargs):
137 """Restore saved settings or set the initial location"""
138 if not self.restore_state(settings=settings):
139 callback(*args, **kwargs)
140 self.center()
142 def resize_to_parent(self, parent, w, h):
143 """Set the initial size of the widget"""
144 width, height = qtutils.default_size(parent, w, h)
145 self.resize(width, height)
148 class MainWindowMixin(WidgetMixin):
149 def __init__(self):
150 WidgetMixin.__init__(self)
151 # Dockwidget options
152 self.dockwidgets = []
153 self.lock_layout = False
154 self.widget_version = 0
155 qtcompat.set_common_dock_options(self)
156 self.default_state = None
158 def init_state(self, settings, callback, *args, **kwargs):
159 """Save the initial state before calling the parent initializer"""
160 self.default_state = self.saveState(self.widget_version)
161 super(MainWindowMixin, self).init_state(settings, callback, *args, **kwargs)
163 def export_state(self):
164 """Exports data for save/restore"""
165 state = WidgetMixin.export_state(self)
166 windowstate = self.saveState(self.widget_version)
167 state['lock_layout'] = self.lock_layout
168 state['windowstate'] = windowstate.toBase64().data().decode('ascii')
169 return state
171 def save_settings(self, settings=None):
172 if settings is None:
173 context = getattr(self, 'context', None)
174 if context is None:
175 settings = Settings.read()
176 else:
177 settings = context.settings
178 settings.load()
179 settings.add_recent(core.getcwd(), prefs.maxrecent(context))
180 return WidgetMixin.save_settings(self, settings=settings)
182 def apply_state(self, state):
183 result = WidgetMixin.apply_state(self, state)
184 windowstate = state.get('windowstate', '')
185 if windowstate:
186 from_base64 = QtCore.QByteArray.fromBase64
187 result = (
188 self.restoreState(
189 from_base64(core.encode(windowstate)), self.widget_version
191 and result
193 else:
194 result = False
196 self.lock_layout = state.get('lock_layout', self.lock_layout)
197 self.update_dockwidget_lock_state()
198 self.update_dockwidget_tooltips()
200 return result
202 def reset_layout(self):
203 self.restoreState(self.default_state, self.widget_version)
205 def set_lock_layout(self, lock_layout):
206 self.lock_layout = lock_layout
207 self.update_dockwidget_lock_state()
209 def update_dockwidget_lock_state(self):
210 if self.lock_layout:
211 features = QDockWidget.DockWidgetClosable | QDockWidget.DockWidgetFloatable
212 else:
213 features = (
214 QDockWidget.DockWidgetClosable
215 | QDockWidget.DockWidgetFloatable
216 | QDockWidget.DockWidgetMovable
218 for widget in self.dockwidgets:
219 widget.titleBarWidget().update_tooltips()
220 widget.setFeatures(features)
222 def update_dockwidget_tooltips(self):
223 for widget in self.dockwidgets:
224 widget.titleBarWidget().update_tooltips()
227 # pylint: disable=too-many-ancestors
228 class ListWidget(QtWidgets.QListWidget):
229 """QListWidget with vim j/k navigation hotkeys"""
231 def __init__(self, parent=None):
232 super(ListWidget, self).__init__(parent)
234 self.up_action = qtutils.add_action(
235 self,
236 N_('Move Up'),
237 self.move_up,
238 hotkeys.MOVE_UP,
239 hotkeys.MOVE_UP_SECONDARY,
242 self.down_action = qtutils.add_action(
243 self,
244 N_('Move Down'),
245 self.move_down,
246 hotkeys.MOVE_DOWN,
247 hotkeys.MOVE_DOWN_SECONDARY,
250 def selected_item(self):
251 return self.currentItem()
253 def selected_items(self):
254 return self.selectedItems()
256 def move_up(self):
257 self.move(-1)
259 def move_down(self):
260 self.move(1)
262 def move(self, direction):
263 item = self.selected_item()
264 if item:
265 row = (self.row(item) + direction) % self.count()
266 elif self.count() > 0:
267 row = (self.count() + direction) % self.count()
268 else:
269 return
270 new_item = self.item(row)
271 if new_item:
272 self.setCurrentItem(new_item)
275 class TreeMixin(object):
276 def __init__(self, widget, Base):
277 self.widget = widget
278 self.Base = Base
280 widget.setAlternatingRowColors(True)
281 widget.setUniformRowHeights(True)
282 widget.setAllColumnsShowFocus(True)
283 widget.setAnimated(True)
284 widget.setRootIsDecorated(False)
286 def keyPressEvent(self, event):
288 Make LeftArrow to work on non-directories.
290 When LeftArrow is pressed on a file entry or an unexpanded
291 directory, then move the current index to the parent directory.
293 This simplifies navigation using the keyboard.
294 For power-users, we support Vim keybindings ;-P
297 # Check whether the item is expanded before calling the base class
298 # keyPressEvent otherwise we end up collapsing and changing the
299 # current index in one shot, which we don't want to do.
300 widget = self.widget
301 index = widget.currentIndex()
302 was_expanded = widget.isExpanded(index)
303 was_collapsed = not was_expanded
305 # Vim keybindings...
306 # Rewrite the event before marshalling to QTreeView.event()
307 key = event.key()
309 # Remap 'H' to 'Left'
310 if key == Qt.Key_H:
311 event = QtGui.QKeyEvent(event.type(), Qt.Key_Left, event.modifiers())
312 # Remap 'J' to 'Down'
313 elif key == Qt.Key_J:
314 event = QtGui.QKeyEvent(event.type(), Qt.Key_Down, event.modifiers())
315 # Remap 'K' to 'Up'
316 elif key == Qt.Key_K:
317 event = QtGui.QKeyEvent(event.type(), Qt.Key_Up, event.modifiers())
318 # Remap 'L' to 'Right'
319 elif key == Qt.Key_L:
320 event = QtGui.QKeyEvent(event.type(), Qt.Key_Right, event.modifiers())
322 # Re-read the event key to take the remappings into account
323 key = event.key()
324 if key == Qt.Key_Up:
325 idxs = widget.selectedIndexes()
326 rows = [idx.row() for idx in idxs]
327 if len(rows) == 1 and rows[0] == 0:
328 # The cursor is at the beginning of the line.
329 # If we have selection then simply reset the cursor.
330 # Otherwise, emit a signal so that the parent can
331 # change focus.
332 widget.up.emit()
334 elif key == Qt.Key_Space:
335 widget.space.emit()
337 result = self.Base.keyPressEvent(widget, event)
339 # Let others hook in here before we change the indexes
340 widget.index_about_to_change.emit()
342 # Automatically select the first entry when expanding a directory
343 if key == Qt.Key_Right and was_collapsed and widget.isExpanded(index):
344 index = widget.moveCursor(widget.MoveDown, event.modifiers())
345 widget.setCurrentIndex(index)
347 # Process non-root entries with valid parents only.
348 elif key == Qt.Key_Left and index.parent().isValid():
350 # File entries have rowCount() == 0
351 model = widget.model()
352 if (
353 hasattr(model, 'itemFromIndex')
354 and model.itemFromIndex(index).rowCount() == 0
356 widget.setCurrentIndex(index.parent())
358 # Otherwise, do this for collapsed directories only
359 elif was_collapsed:
360 widget.setCurrentIndex(index.parent())
362 # If it's a movement key ensure we have a selection
363 elif key in (Qt.Key_Left, Qt.Key_Up, Qt.Key_Right, Qt.Key_Down):
364 # Try to select the first item if the model index is invalid
365 item = self.selected_item()
366 if item is None or not index.isValid():
367 index = widget.model().index(0, 0, QtCore.QModelIndex())
368 if index.isValid():
369 widget.setCurrentIndex(index)
371 return result
373 def item_from_index(self, item):
374 """Return a QModelIndex from the provided item"""
375 if hasattr(self, 'itemFromIndex'):
376 index = self.itemFromIndex(item)
377 else:
378 index = self.model().itemFromIndex()
379 return index
381 def items(self):
382 root = self.widget.invisibleRootItem()
383 child = root.child
384 count = root.childCount()
385 return [child(i) for i in range(count)]
387 def selected_items(self):
388 """Return all selected items"""
389 widget = self.widget
390 if hasattr(widget, 'selectedItems'):
391 return widget.selectedItems()
392 else:
393 if hasattr(widget, 'itemFromIndex'):
394 item_from_index = widget.itemFromIndex
395 else:
396 item_from_index = widget.model().itemFromIndex
397 return [item_from_index(i) for i in widget.selectedIndexes()]
399 def selected_item(self):
400 """Return the first selected item"""
401 selected_items = self.selected_items()
402 if not selected_items:
403 return None
404 return selected_items[0]
406 def current_item(self):
407 item = None
408 widget = self.widget
409 if hasattr(widget, 'currentItem'):
410 item = widget.currentItem()
411 else:
412 index = widget.currentIndex()
413 if index.isValid():
414 item = widget.model().itemFromIndex(index)
415 return item
417 def column_widths(self):
418 """Return the tree's column widths"""
419 widget = self.widget
420 count = widget.header().count()
421 return [widget.columnWidth(i) for i in range(count)]
423 def set_column_widths(self, widths):
424 """Set the tree's column widths"""
425 if widths:
426 widget = self.widget
427 count = widget.header().count()
428 if len(widths) > count:
429 widths = widths[:count]
430 for idx, value in enumerate(widths):
431 widget.setColumnWidth(idx, value)
434 class DraggableTreeMixin(TreeMixin):
435 """A tree widget with internal drag+drop reordering of rows
437 Expects that the widget provides an `items_moved` signal.
441 def __init__(self, widget, Base):
442 super(DraggableTreeMixin, self).__init__(widget, Base)
444 self._inner_drag = False
445 widget.setAcceptDrops(True)
446 widget.setSelectionMode(widget.SingleSelection)
447 widget.setDragEnabled(True)
448 widget.setDropIndicatorShown(True)
449 widget.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
450 widget.setSortingEnabled(False)
452 def dragEnterEvent(self, event):
453 """Accept internal drags only"""
454 widget = self.widget
455 self.Base.dragEnterEvent(widget, event)
456 self._inner_drag = event.source() == widget
457 if self._inner_drag:
458 event.acceptProposedAction()
459 else:
460 event.ignore()
462 def dragLeaveEvent(self, event):
463 widget = self.widget
464 self.Base.dragLeaveEvent(widget, event)
465 if self._inner_drag:
466 event.accept()
467 else:
468 event.ignore()
469 self._inner_drag = False
471 def dropEvent(self, event):
472 """Re-select selected items after an internal move"""
473 if not self._inner_drag:
474 event.ignore()
475 return
476 widget = self.widget
477 clicked_items = self.selected_items()
478 event.setDropAction(Qt.MoveAction)
479 self.Base.dropEvent(widget, event)
481 if clicked_items:
482 widget.clearSelection()
483 for item in clicked_items:
484 item.setSelected(True)
485 widget.items_moved.emit(clicked_items)
486 self._inner_drag = False
487 event.accept() # must be called after dropEvent()
489 def mousePressEvent(self, event):
490 """Clear the selection when a mouse click hits no item"""
491 widget = self.widget
492 clicked_item = widget.itemAt(event.pos())
493 if clicked_item is None:
494 widget.clearSelection()
495 return self.Base.mousePressEvent(widget, event)
498 class Widget(WidgetMixin, QtWidgets.QWidget):
499 Base = QtWidgets.QWidget
501 def __init__(self, parent=None):
502 QtWidgets.QWidget.__init__(self, parent)
503 WidgetMixin.__init__(self)
506 class Dialog(WidgetMixin, QtWidgets.QDialog):
507 Base = QtWidgets.QDialog
509 def __init__(self, parent=None):
510 QtWidgets.QDialog.__init__(self, parent)
511 WidgetMixin.__init__(self)
512 # Disable the Help button hint on Windows
513 if hasattr(Qt, 'WindowContextHelpButtonHint'):
514 help_hint = Qt.WindowContextHelpButtonHint
515 flags = self.windowFlags() & ~help_hint
516 self.setWindowFlags(flags)
518 def accept(self):
519 self.save_settings()
520 self.dispose()
521 return self.Base.accept(self)
523 def reject(self):
524 self.save_settings()
525 self.dispose()
526 return self.Base.reject(self)
528 # pylint: disable=no-self-use
529 def dispose(self):
530 """Extension method for model deregistration in sub-classes"""
531 return
533 def close(self):
534 """save_settings() is handled by accept() and reject()"""
535 self.dispose()
536 self.Base.close(self)
538 def closeEvent(self, event):
539 """save_settings() is handled by accept() and reject()"""
540 self.dispose()
541 self.Base.closeEvent(self, event)
544 class MainWindow(MainWindowMixin, QtWidgets.QMainWindow):
545 Base = QtWidgets.QMainWindow
547 def __init__(self, parent=None):
548 QtWidgets.QMainWindow.__init__(self, parent)
549 MainWindowMixin.__init__(self)
552 # pylint: disable=too-many-ancestors
553 class TreeView(QtWidgets.QTreeView):
554 Mixin = TreeMixin
556 up = Signal()
557 space = Signal()
558 index_about_to_change = Signal()
560 def __init__(self, parent=None):
561 QtWidgets.QTreeView.__init__(self, parent)
562 self._mixin = self.Mixin(self, QtWidgets.QTreeView)
564 def keyPressEvent(self, event):
565 return self._mixin.keyPressEvent(event)
567 def current_item(self):
568 return self._mixin.current_item()
570 def selected_item(self):
571 return self._mixin.selected_item()
573 def selected_items(self):
574 return self._mixin.selected_items()
576 def items(self):
577 return self._mixin.items()
579 def column_widths(self):
580 return self._mixin.column_widths()
582 def set_column_widths(self, widths):
583 return self._mixin.set_column_widths(widths)
586 # pylint: disable=too-many-ancestors
587 class TreeWidget(QtWidgets.QTreeWidget):
588 Mixin = TreeMixin
590 up = Signal()
591 space = Signal()
592 index_about_to_change = Signal()
594 def __init__(self, parent=None):
595 super(TreeWidget, self).__init__(parent)
596 self._mixin = self.Mixin(self, QtWidgets.QTreeWidget)
598 def keyPressEvent(self, event):
599 return self._mixin.keyPressEvent(event)
601 def current_item(self):
602 return self._mixin.current_item()
604 def selected_item(self):
605 return self._mixin.selected_item()
607 def selected_items(self):
608 return self._mixin.selected_items()
610 def items(self):
611 return self._mixin.items()
613 def column_widths(self):
614 return self._mixin.column_widths()
616 def set_column_widths(self, widths):
617 return self._mixin.set_column_widths(widths)
620 # pylint: disable=too-many-ancestors
621 class DraggableTreeWidget(TreeWidget):
622 Mixin = DraggableTreeMixin
623 items_moved = Signal(object)
625 def mousePressEvent(self, event):
626 return self._mixin.mousePressEvent(event)
628 def dropEvent(self, event):
629 return self._mixin.dropEvent(event)
631 def dragLeaveEvent(self, event):
632 return self._mixin.dragLeaveEvent(event)
634 def dragEnterEvent(self, event):
635 return self._mixin.dragEnterEvent(event)
638 class ProgressDialog(QtWidgets.QProgressDialog):
639 """Custom progress dialog
641 This dialog ignores the ESC key so that it is not
642 prematurely closed.
644 A thread is spawned to animate the progress label text.
648 def __init__(self, title, label, parent):
649 QtWidgets.QProgressDialog.__init__(self, parent)
650 if parent is not None:
651 self.setWindowModality(Qt.WindowModal)
652 self.reset()
653 self.setRange(0, 0)
654 self.setMinimumDuration(0)
655 self.setCancelButton(None)
656 self.setFont(qtutils.default_monospace_font())
657 self.thread = ProgressAnimationThread(label, self)
658 self.thread.updated.connect(self.refresh, type=Qt.QueuedConnection)
660 self.set_details(title, label)
662 def set_details(self, title, label):
663 self.setWindowTitle(title)
664 self.setLabelText(label + ' ')
665 self.thread.set_text(label)
667 def refresh(self, txt):
668 self.setLabelText(txt)
670 def keyPressEvent(self, event):
671 if event.key() != Qt.Key_Escape:
672 super(ProgressDialog, self).keyPressEvent(event)
674 def show(self):
675 QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor)
676 super(ProgressDialog, self).show()
677 self.thread.start()
679 def hide(self):
680 QtWidgets.QApplication.restoreOverrideCursor()
681 self.thread.stop()
682 self.thread.wait()
683 super(ProgressDialog, self).hide()
686 class ProgressAnimationThread(QtCore.QThread):
687 """Emits a pseudo-animated text stream for progress bars"""
689 updated = Signal(object)
691 def __init__(self, txt, parent, timeout=0.1):
692 QtCore.QThread.__init__(self, parent)
693 self.running = False
694 self.txt = txt
695 self.timeout = timeout
696 self.symbols = [
697 '. ..',
698 '.. .',
699 '... ',
700 ' ... ',
701 ' ...',
703 self.idx = -1
705 def set_text(self, txt):
706 self.txt = txt
708 def cycle(self):
709 self.idx = (self.idx + 1) % len(self.symbols)
710 return self.txt + self.symbols[self.idx]
712 def stop(self):
713 self.running = False
715 def run(self):
716 self.running = True
717 while self.running:
718 self.updated.emit(self.cycle())
719 time.sleep(self.timeout)
722 class SpinBox(QtWidgets.QSpinBox):
723 def __init__(
724 self, parent=None, value=None, mini=1, maxi=99999, step=0, prefix='', suffix=''
726 QtWidgets.QSpinBox.__init__(self, parent)
727 self.setPrefix(prefix)
728 self.setSuffix(suffix)
729 self.setWrapping(True)
730 self.setMinimum(mini)
731 self.setMaximum(maxi)
732 if step:
733 self.setSingleStep(step)
734 if value is not None:
735 self.setValue(value)
737 font = self.font()
738 metrics = QtGui.QFontMetrics(font)
739 width = max(self.minimumWidth(), metrics.width('XXXXXX'))
740 self.setMinimumWidth(width)
743 def export_header_columns(widget, state):
744 """Save QHeaderView column sizes"""
745 columns = []
746 header = widget.horizontalHeader()
747 for idx in range(header.count()):
748 columns.append(header.sectionSize(idx))
750 state['columns'] = columns
753 def apply_header_columns(widget, state):
754 """Apply QHeaderView column sizes"""
755 columns = mklist(state.get('columns', []))
756 header = widget.horizontalHeader()
757 if header.stretchLastSection():
758 # Setting the size will make the section wider than necessary, which
759 # defeats the purpose of the stretch flag. Skip the last column when
760 # it's stretchy so that it retains the stretchy behavior.
761 columns = columns[:-1]
762 for idx, size in enumerate(columns):
763 header.resizeSection(idx, size)
766 class MessageBox(Dialog):
767 """Improved QMessageBox replacement
769 QMessageBox has a lot of usability issues. It sometimes cannot be
770 resized, and it brings along a lots of annoying properties that we'd have
771 to workaround, so we use a simple custom dialog instead.
775 def __init__(
776 self,
777 parent=None,
778 title='',
779 text='',
780 info='',
781 details='',
782 logo=None,
783 default=False,
784 ok_icon=None,
785 ok_text='',
786 cancel_text=None,
787 cancel_icon=None,
790 Dialog.__init__(self, parent=parent)
792 if parent:
793 self.setWindowModality(Qt.WindowModal)
794 if title:
795 self.setWindowTitle(title)
797 self.logo_label = QtWidgets.QLabel()
798 if logo:
799 # Render into a 1-inch wide pixmap
800 pixmap = logo.pixmap(defs.large_icon)
801 self.logo_label.setPixmap(pixmap)
802 else:
803 self.logo_label.hide()
805 self.text_label = QtWidgets.QLabel()
806 self.text_label.setText(text)
808 self.info_label = QtWidgets.QLabel()
809 if info:
810 self.info_label.setText(info)
811 else:
812 self.info_label.hide()
814 ok_icon = icons.mkicon(ok_icon, icons.ok)
815 self.button_ok = qtutils.create_button(text=ok_text, icon=ok_icon)
817 self.button_close = qtutils.close_button(text=cancel_text, icon=cancel_icon)
819 if ok_text:
820 self.button_ok.setText(ok_text)
821 else:
822 self.button_ok.hide()
824 self.details_text = QtWidgets.QPlainTextEdit()
825 self.details_text.setReadOnly(True)
826 if details:
827 self.details_text.setFont(qtutils.default_monospace_font())
828 self.details_text.setPlainText(details)
829 else:
830 self.details_text.hide()
832 self.info_layout = qtutils.vbox(
833 defs.large_margin,
834 defs.button_spacing,
835 self.text_label,
836 self.info_label,
837 qtutils.STRETCH,
840 self.top_layout = qtutils.hbox(
841 defs.large_margin,
842 defs.button_spacing,
843 self.logo_label,
844 self.info_layout,
845 qtutils.STRETCH,
848 self.buttons_layout = qtutils.hbox(
849 defs.no_margin,
850 defs.button_spacing,
851 qtutils.STRETCH,
852 self.button_close,
853 self.button_ok,
856 self.main_layout = qtutils.vbox(
857 defs.margin,
858 defs.button_spacing,
859 self.top_layout,
860 self.buttons_layout,
861 self.details_text,
863 self.main_layout.setStretchFactor(self.details_text, 2)
864 self.setLayout(self.main_layout)
866 if default:
867 self.button_ok.setDefault(True)
868 self.button_ok.setFocus()
869 else:
870 self.button_close.setDefault(True)
871 self.button_close.setFocus()
873 qtutils.connect_button(self.button_ok, self.accept)
874 qtutils.connect_button(self.button_close, self.reject)
875 self.init_state(None, self.set_initial_size)
877 def set_initial_size(self):
878 width = defs.dialog_w
879 height = defs.msgbox_h
880 self.resize(width, height)
882 def keyPressEvent(self, event):
883 """Handle Y/N hotkeys"""
884 key = event.key()
885 if key == Qt.Key_Y:
886 QtCore.QTimer.singleShot(0, self.accept)
887 elif key in (Qt.Key_N, Qt.Key_Q):
888 QtCore.QTimer.singleShot(0, self.reject)
889 elif key == Qt.Key_Tab:
890 if self.button_ok.isVisible():
891 event.accept()
892 if self.focusWidget() == self.button_close:
893 self.button_ok.setFocus()
894 else:
895 self.button_close.setFocus()
896 return
897 Dialog.keyPressEvent(self, event)
899 def run(self):
900 self.show()
901 return self.exec_()
904 def confirm(
905 title,
906 text,
907 informative_text,
908 ok_text,
909 icon=None,
910 default=True,
911 cancel_text=None,
912 cancel_icon=None,
914 """Confirm that an action should take place"""
915 cancel_text = cancel_text or N_('Cancel')
916 logo = icons.from_style(QtWidgets.QStyle.SP_MessageBoxQuestion)
918 mbox = MessageBox(
919 parent=qtutils.active_window(),
920 title=title,
921 text=text,
922 info=informative_text,
923 ok_text=ok_text,
924 ok_icon=icon,
925 cancel_text=cancel_text,
926 cancel_icon=cancel_icon,
927 logo=logo,
928 default=default,
931 return mbox.run() == mbox.Accepted
934 def critical(title, message=None, details=None):
935 """Show a warning with the provided title and message."""
936 if message is None:
937 message = title
938 logo = icons.from_style(QtWidgets.QStyle.SP_MessageBoxCritical)
939 mbox = MessageBox(
940 parent=qtutils.active_window(),
941 title=title,
942 text=message,
943 details=details,
944 logo=logo,
946 mbox.run()
949 def command_error(title, cmd, status, out, err):
950 """Report an error message about a failed command"""
951 details = Interaction.format_out_err(out, err)
952 message = Interaction.format_command_status(cmd, status)
953 critical(title, message=message, details=details)
956 def information(title, message=None, details=None, informative_text=None):
957 """Show information with the provided title and message."""
958 if message is None:
959 message = title
960 mbox = MessageBox(
961 parent=qtutils.active_window(),
962 title=title,
963 text=message,
964 info=informative_text,
965 details=details,
966 logo=icons.cola(),
968 mbox.run()
971 def progress(title, text, parent):
972 """Create a new ProgressDialog"""
973 return ProgressDialog(title, text, parent)
976 def question(title, text, default=True, logo=None):
977 """Launches a QMessageBox question with the provided title and message.
978 Passing "default=False" will make "No" the default choice."""
979 parent = qtutils.active_window()
980 if logo is None:
981 logo = icons.from_style(QtWidgets.QStyle.SP_MessageBoxQuestion)
982 msgbox = MessageBox(
983 parent=parent,
984 title=title,
985 text=text,
986 default=default,
987 logo=logo,
988 ok_text=N_('Yes'),
989 cancel_text=N_('No'),
991 return msgbox.run() == msgbox.Accepted
994 def save_as(filename, title):
995 return qtutils.save_as(filename, title=title)
998 def async_command(title, cmd, runtask):
999 task = qtutils.SimpleTask(partial(core.run_command, cmd))
1000 task.connect(partial(async_command_result, title, cmd))
1001 runtask.start(task)
1004 def async_command_result(title, cmd, result):
1005 status, out, err = result
1006 cmd_string = core.list2cmdline(cmd)
1007 Interaction.command(title, cmd_string, status, out, err)
1010 def install():
1011 """Install the GUI-model interaction hooks"""
1012 Interaction.critical = staticmethod(critical)
1013 Interaction.confirm = staticmethod(confirm)
1014 Interaction.question = staticmethod(question)
1015 Interaction.information = staticmethod(information)
1016 Interaction.command_error = staticmethod(command_error)
1017 Interaction.save_as = staticmethod(save_as)
1018 Interaction.async_command = staticmethod(async_command)