1 from functools
import partial
5 from qtpy
import QtCore
7 from qtpy
import QtWidgets
8 from qtpy
.QtCore
import Qt
9 from qtpy
.QtCore
import Signal
10 from qtpy
.QtWidgets
import QDockWidget
13 from ..interaction
import Interaction
14 from ..settings
import Settings
, mklist
15 from ..models
import prefs
17 from .. import hotkeys
19 from .. import qtcompat
20 from .. import qtutils
26 """Mix-in for common utilities and serialization of widget state"""
28 closed
= Signal(QtWidgets
.QWidget
)
31 self
._unmaximized
_rect
= {}
34 parent
= self
.parent()
38 width
= parent
.width()
39 center_x
= left
+ width
// 2
40 x
= center_x
- self
.width() // 2
45 def resize_to_desktop(self
):
46 width
, height
= qtutils
.desktop_size()
48 self
.resize(width
, height
)
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
56 self
.setWindowState(Qt
.WindowMaximized
)
61 """Returns the name of the view class"""
62 return self
.__class
__.__name
__.lower()
64 def save_state(self
, settings
=None):
67 context
= getattr(self
, 'context', None)
70 save
= cfg
.get('cola.savewindowsettings', default
=True)
71 sync
= cfg
.get('cola.sync', default
=True)
74 settings
= Settings
.read()
75 settings
.save_gui_state(self
, sync
=sync
)
77 def restore_state(self
, settings
=None):
79 settings
= Settings
.read()
80 state
= settings
.get_gui_state(self
)
82 result
= self
.apply_state(state
)
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', '')
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
)
109 def export_state(self
):
110 """Exports data for view save/restore"""
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()
121 def save_settings(self
, settings
=None):
122 return self
.save_state(settings
=settings
)
124 def closeEvent(self
, event
):
126 self
.closed
.emit(self
)
127 self
.Base
.closeEvent(self
, event
)
129 def init_size(self
, parent
=None, settings
=None, width
=0, height
=0):
131 width
= defs
.dialog_w
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
)
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
):
150 WidgetMixin
.__init
__(self
)
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().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')
171 def save_settings(self
, settings
=None):
173 context
= getattr(self
, 'context', None)
175 settings
= Settings
.read()
177 settings
= context
.settings
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', '')
186 from_base64
= QtCore
.QByteArray
.fromBase64
189 from_base64(core
.encode(windowstate
)), self
.widget_version
196 self
.lock_layout
= state
.get('lock_layout', self
.lock_layout
)
197 self
.update_dockwidget_lock_state()
198 self
.update_dockwidget_tooltips()
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
):
211 features
= QDockWidget
.DockWidgetClosable
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 class ListWidget(QtWidgets
.QListWidget
):
228 """QListWidget with vim j/k navigation hotkeys"""
230 def __init__(self
, parent
=None):
231 super().__init
__(parent
)
233 self
.up_action
= qtutils
.add_action(
238 hotkeys
.MOVE_UP_SECONDARY
,
241 self
.down_action
= qtutils
.add_action(
246 hotkeys
.MOVE_DOWN_SECONDARY
,
249 def selected_item(self
):
250 return self
.currentItem()
252 def selected_items(self
):
253 return self
.selectedItems()
261 def move(self
, direction
):
262 item
= self
.selected_item()
264 row
= (self
.row(item
) + direction
) % self
.count()
265 elif self
.count() > 0:
266 row
= (self
.count() + direction
) % self
.count()
269 new_item
= self
.item(row
)
271 self
.setCurrentItem(new_item
)
275 def __init__(self
, widget
, Base
):
279 widget
.setAlternatingRowColors(True)
280 widget
.setUniformRowHeights(True)
281 widget
.setAllColumnsShowFocus(True)
282 widget
.setAnimated(True)
283 widget
.setRootIsDecorated(False)
285 def keyPressEvent(self
, event
):
287 Make LeftArrow to work on non-directories.
289 When LeftArrow is pressed on a file entry or an unexpanded
290 directory, then move the current index to the parent directory.
292 This simplifies navigation using the keyboard.
293 For power-users, we support Vim keybindings ;-P
296 # Check whether the item is expanded before calling the base class
297 # keyPressEvent otherwise we end up collapsing and changing the
298 # current index in one shot, which we don't want to do.
300 index
= widget
.currentIndex()
301 was_expanded
= widget
.isExpanded(index
)
302 was_collapsed
= not was_expanded
305 event
= _create_vim_navigation_key_event(event
)
307 # Read the updated event key to take the mappings into account
310 idxs
= widget
.selectedIndexes()
311 rows
= [idx
.row() for idx
in idxs
]
312 if len(rows
) == 1 and rows
[0] == 0:
313 # The cursor is at the beginning of the line.
314 # If we have selection then simply reset the cursor.
315 # Otherwise, emit a signal so that the parent can
319 elif key
== Qt
.Key_Space
:
322 result
= self
.Base
.keyPressEvent(widget
, event
)
324 # Let others hook in here before we change the indexes
325 widget
.index_about_to_change
.emit()
327 # Automatically select the first entry when expanding a directory
328 if key
== Qt
.Key_Right
and was_collapsed
and widget
.isExpanded(index
):
329 index
= widget
.moveCursor(widget
.MoveDown
, event
.modifiers())
330 widget
.setCurrentIndex(index
)
332 # Process non-root entries with valid parents only.
333 elif key
== Qt
.Key_Left
and index
.parent().isValid():
334 # File entries have rowCount() == 0
335 model
= widget
.model()
336 if hasattr(model
, 'itemFromIndex'):
337 item
= model
.itemFromIndex(index
)
338 if hasattr(item
, 'rowCount') and item
.rowCount() == 0:
339 widget
.setCurrentIndex(index
.parent())
341 # Otherwise, do this for collapsed directories only
343 widget
.setCurrentIndex(index
.parent())
345 # If it's a movement key ensure we have a selection
346 elif key
in (Qt
.Key_Left
, Qt
.Key_Up
, Qt
.Key_Right
, Qt
.Key_Down
):
347 # Try to select the first item if the model index is invalid
348 item
= self
.selected_item()
349 if item
is None or not index
.isValid():
350 index
= widget
.model().index(0, 0, QtCore
.QModelIndex())
352 widget
.setCurrentIndex(index
)
356 def item_from_index(self
, item
):
357 """Return a QModelIndex from the provided item"""
358 if hasattr(self
, 'itemFromIndex'):
359 index
= self
.itemFromIndex(item
)
361 index
= self
.model().itemFromIndex()
365 root
= self
.widget
.invisibleRootItem()
367 count
= root
.childCount()
368 return [child(i
) for i
in range(count
)]
370 def selected_items(self
):
371 """Return all selected items"""
373 if hasattr(widget
, 'selectedItems'):
374 return widget
.selectedItems()
375 if hasattr(widget
, 'itemFromIndex'):
376 item_from_index
= widget
.itemFromIndex
378 item_from_index
= widget
.model().itemFromIndex
379 return [item_from_index(i
) for i
in widget
.selectedIndexes()]
381 def selected_item(self
):
382 """Return the first selected item"""
383 selected_items
= self
.selected_items()
384 if not selected_items
:
386 return selected_items
[0]
388 def current_item(self
):
391 if hasattr(widget
, 'currentItem'):
392 item
= widget
.currentItem()
394 index
= widget
.currentIndex()
396 item
= widget
.model().itemFromIndex(index
)
399 def column_widths(self
):
400 """Return the tree's column widths"""
402 count
= widget
.header().count()
403 return [widget
.columnWidth(i
) for i
in range(count
)]
405 def set_column_widths(self
, widths
):
406 """Set the tree's column widths"""
409 count
= widget
.header().count()
410 if len(widths
) > count
:
411 widths
= widths
[:count
]
412 for idx
, value
in enumerate(widths
):
413 widget
.setColumnWidth(idx
, value
)
416 def _create_vim_navigation_key_event(event
):
417 """Support minimal Vim-like keybindings by rewriting the QKeyEvents"""
419 # Remap 'H' to 'Left'
421 event
= QtGui
.QKeyEvent(event
.type(), Qt
.Key_Left
, event
.modifiers())
422 # Remap 'J' to 'Down'
423 elif key
== Qt
.Key_J
:
424 event
= QtGui
.QKeyEvent(event
.type(), Qt
.Key_Down
, event
.modifiers())
426 elif key
== Qt
.Key_K
:
427 event
= QtGui
.QKeyEvent(event
.type(), Qt
.Key_Up
, event
.modifiers())
428 # Remap 'L' to 'Right'
429 elif key
== Qt
.Key_L
:
430 event
= QtGui
.QKeyEvent(event
.type(), Qt
.Key_Right
, event
.modifiers())
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().__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"""
455 self
.Base
.dragEnterEvent(widget
, event
)
456 self
._inner
_drag
= event
.source() == widget
458 event
.acceptProposedAction()
462 def dragLeaveEvent(self
, event
):
464 self
.Base
.dragLeaveEvent(widget
, event
)
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
:
477 clicked_items
= self
.selected_items()
478 event
.setDropAction(Qt
.MoveAction
)
479 self
.Base
.dropEvent(widget
, event
)
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"""
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
)
521 return self
.Base
.accept(self
)
526 return self
.Base
.reject(self
)
529 """Extension method for model de-registration in sub-classes"""
533 """save_settings() is handled by accept() and reject()"""
535 self
.Base
.close(self
)
537 def closeEvent(self
, event
):
538 """save_settings() is handled by accept() and reject()"""
540 self
.Base
.closeEvent(self
, event
)
543 class MainWindow(MainWindowMixin
, QtWidgets
.QMainWindow
):
544 Base
= QtWidgets
.QMainWindow
546 def __init__(self
, parent
=None):
547 QtWidgets
.QMainWindow
.__init
__(self
, parent
)
548 MainWindowMixin
.__init
__(self
)
551 class TreeView(QtWidgets
.QTreeView
):
556 index_about_to_change
= Signal()
558 def __init__(self
, parent
=None):
559 QtWidgets
.QTreeView
.__init
__(self
, parent
)
560 self
._mixin
= self
.Mixin(self
, QtWidgets
.QTreeView
)
562 def keyPressEvent(self
, event
):
563 return self
._mixin
.keyPressEvent(event
)
565 def current_item(self
):
566 return self
._mixin
.current_item()
568 def selected_item(self
):
569 return self
._mixin
.selected_item()
571 def selected_items(self
):
572 return self
._mixin
.selected_items()
575 return self
._mixin
.items()
577 def column_widths(self
):
578 return self
._mixin
.column_widths()
580 def set_column_widths(self
, widths
):
581 return self
._mixin
.set_column_widths(widths
)
584 class TreeWidget(QtWidgets
.QTreeWidget
):
589 index_about_to_change
= Signal()
591 def __init__(self
, parent
=None):
592 super().__init
__(parent
)
593 self
._mixin
= self
.Mixin(self
, QtWidgets
.QTreeWidget
)
595 def keyPressEvent(self
, event
):
596 return self
._mixin
.keyPressEvent(event
)
598 def current_item(self
):
599 return self
._mixin
.current_item()
601 def selected_item(self
):
602 return self
._mixin
.selected_item()
604 def selected_items(self
):
605 return self
._mixin
.selected_items()
608 return self
._mixin
.items()
610 def column_widths(self
):
611 return self
._mixin
.column_widths()
613 def set_column_widths(self
, widths
):
614 return self
._mixin
.set_column_widths(widths
)
617 class DraggableTreeWidget(TreeWidget
):
618 Mixin
= DraggableTreeMixin
619 items_moved
= Signal(object)
621 def mousePressEvent(self
, event
):
622 return self
._mixin
.mousePressEvent(event
)
624 def dropEvent(self
, event
):
625 return self
._mixin
.dropEvent(event
)
627 def dragLeaveEvent(self
, event
):
628 return self
._mixin
.dragLeaveEvent(event
)
630 def dragEnterEvent(self
, event
):
631 return self
._mixin
.dragEnterEvent(event
)
634 class ProgressDialog(QtWidgets
.QProgressDialog
):
635 """Custom progress dialog
637 This dialog ignores the ESC key so that it is not
640 A thread is spawned to animate the progress label text.
644 def __init__(self
, title
, label
, parent
):
645 QtWidgets
.QProgressDialog
.__init
__(self
, parent
)
646 self
._parent
= parent
647 if parent
is not None:
648 self
.setWindowModality(Qt
.WindowModal
)
650 self
.animation_thread
= ProgressAnimationThread(label
, self
)
651 self
.animation_thread
.updated
.connect(self
.set_text
, type=Qt
.QueuedConnection
)
655 self
.setMinimumDuration(0)
656 self
.setCancelButton(None)
657 self
.setFont(qtutils
.default_monospace_font())
658 self
.set_details(title
, label
)
660 def set_details(self
, title
, label
):
661 """Update the window title and progress label"""
662 self
.setWindowTitle(title
)
663 self
.setLabelText(label
+ ' ')
664 self
.animation_thread
.set_text(label
)
666 def set_text(self
, txt
):
667 """Set the label text"""
668 self
.setLabelText(txt
)
670 def keyPressEvent(self
, event
):
671 """Customize keyPressEvent to remove the ESC key cancel feature"""
672 if event
.key() != Qt
.Key_Escape
:
673 super().keyPressEvent(event
)
676 """Start the animation thread and use a wait cursor"""
678 QtWidgets
.QApplication
.setOverrideCursor(Qt
.WaitCursor
)
679 self
.animation_thread
.start()
682 """Stop the animation thread and restore the normal cursor"""
683 self
.animation_thread
.stop()
684 self
.animation_thread
.wait()
685 QtWidgets
.QApplication
.restoreOverrideCursor()
689 class ProgressAnimationThread(QtCore
.QThread
):
690 """Emits a pseudo-animated text stream for progress bars"""
692 # The updated signal is emitted on each tick.
693 updated
= Signal(object)
695 def __init__(self
, txt
, parent
, sleep_time
=0.1):
696 QtCore
.QThread
.__init
__(self
, parent
)
699 self
.sleep_time
= sleep_time
709 def set_text(self
, txt
):
710 """Set the text prefix"""
714 """Tick to the next animated text value"""
715 self
.idx
= (self
.idx
+ 1) % len(self
.symbols
)
716 return self
.txt
+ self
.symbols
[self
.idx
]
719 """Stop the animation thread"""
723 """Emit ticks until stopped"""
726 self
.updated
.emit(self
.tick())
727 time
.sleep(self
.sleep_time
)
730 class ProgressTickThread(QtCore
.QThread
):
731 """Emits an int stream for progress bars"""
733 # The updated signal emits progress tick values.
734 updated
= Signal(int)
735 # The activated signal is emitted when the progress bar is displayed.
745 QtCore
.QThread
.__init
__(self
, parent
)
747 self
.sleep_time
= sleep_time
748 self
.maximum
= maximum
749 self
.start_time
= start_time
754 """Cycle to the next tick value
756 Returned values are in the inclusive (0, maximum + 1) range.
758 self
.value
= (self
.value
+ self
.step
) % (self
.maximum
+ 1)
759 if self
.value
== self
.maximum
:
761 elif self
.value
== 0:
766 """Stop the tick thread and reset to the initial state"""
772 """Start the tick thread
774 The progress bar will not be activated until after the start_time
775 interval has elapsed.
777 initial_time
= time
.time()
784 self
.updated
.emit(self
.tick())
787 if self
.start_time
< (now
- initial_time
):
789 self
.activated
.emit()
790 time
.sleep(self
.sleep_time
)
793 class SpinBox(QtWidgets
.QSpinBox
):
795 self
, parent
=None, value
=None, mini
=1, maxi
=99999, step
=0, prefix
='', suffix
=''
797 QtWidgets
.QSpinBox
.__init
__(self
, parent
)
798 self
.setPrefix(prefix
)
799 self
.setSuffix(suffix
)
800 self
.setWrapping(True)
801 self
.setMinimum(mini
)
802 self
.setMaximum(maxi
)
804 self
.setSingleStep(step
)
805 if value
is not None:
807 text_width
= qtutils
.text_width(self
.font(), 'MMMMMM')
808 width
= max(self
.minimumWidth(), text_width
)
809 self
.setMinimumWidth(width
)
812 class DirectoryPathLineEdit(QtWidgets
.QWidget
):
813 """A combined line edit and file browser button"""
815 def __init__(self
, path
, parent
):
816 QtWidgets
.QWidget
.__init
__(self
, parent
)
818 self
.line_edit
= QtWidgets
.QLineEdit()
819 self
.line_edit
.setText(path
)
821 self
.browse_button
= qtutils
.create_button(
822 tooltip
=N_('Select directory'), icon
=icons
.folder()
824 layout
= qtutils
.hbox(
830 self
.setLayout(layout
)
832 qtutils
.connect_button(self
.browse_button
, self
._select
_directory
)
834 def set_value(self
, value
):
835 """Set the path value"""
836 self
.line_edit
.setText(value
)
839 """Return the current path value"""
840 return self
.line_edit
.text().strip()
842 def _select_directory(self
):
843 """Open a file browser and select a directory"""
844 output_dir
= qtutils
.opendir_dialog(N_('Select directory'), self
.value())
847 # Make the directory relative only if it the current directory or
848 # or subdirectory from the current directory.
849 current_dir
= core
.getcwd()
850 if output_dir
== current_dir
:
852 elif output_dir
.startswith(current_dir
+ os
.sep
):
853 output_dir
= os
.path
.relpath(output_dir
)
854 self
.set_value(output_dir
)
857 def export_header_columns(widget
, state
):
858 """Save QHeaderView column sizes"""
860 header
= widget
.horizontalHeader()
861 for idx
in range(header
.count()):
862 columns
.append(header
.sectionSize(idx
))
864 state
['columns'] = columns
867 def apply_header_columns(widget
, state
):
868 """Apply QHeaderView column sizes"""
869 columns
= mklist(state
.get('columns', []))
870 header
= widget
.horizontalHeader()
871 if header
.stretchLastSection():
872 # Setting the size will make the section wider than necessary, which
873 # defeats the purpose of the stretch flag. Skip the last column when
874 # it's stretchy so that it retains the stretchy behavior.
875 columns
= columns
[:-1]
876 for idx
, size
in enumerate(columns
):
877 header
.resizeSection(idx
, size
)
880 class MessageBox(Dialog
):
881 """Improved QMessageBox replacement
883 QMessageBox has a lot of usability issues. It sometimes cannot be
884 resized, and it brings along a lots of annoying properties that we'd have
885 to workaround, so we use a simple custom dialog instead.
903 Dialog
.__init
__(self
, parent
=parent
)
906 self
.setWindowModality(Qt
.WindowModal
)
908 self
.setWindowTitle(title
)
910 self
.logo_label
= QtWidgets
.QLabel()
912 # Render into a 1-inch wide pixmap
913 pixmap
= logo
.pixmap(defs
.large_icon
)
914 self
.logo_label
.setPixmap(pixmap
)
916 self
.logo_label
.hide()
918 self
.text_label
= QtWidgets
.QLabel()
919 self
.text_label
.setText(text
)
921 self
.info_label
= QtWidgets
.QLabel()
923 self
.info_label
.setText(info
)
925 self
.info_label
.hide()
927 ok_icon
= icons
.mkicon(ok_icon
, icons
.ok
)
928 self
.button_ok
= qtutils
.create_button(text
=ok_text
, icon
=ok_icon
)
930 self
.button_close
= qtutils
.close_button(text
=cancel_text
, icon
=cancel_icon
)
933 self
.button_ok
.setText(ok_text
)
935 self
.button_ok
.hide()
937 self
.details_text
= QtWidgets
.QPlainTextEdit()
938 self
.details_text
.setReadOnly(True)
940 self
.details_text
.setFont(qtutils
.default_monospace_font())
941 self
.details_text
.setPlainText(details
)
943 self
.details_text
.hide()
945 self
.info_layout
= qtutils
.vbox(
953 self
.top_layout
= qtutils
.hbox(
961 self
.buttons_layout
= qtutils
.hbox(
969 self
.main_layout
= qtutils
.vbox(
976 self
.main_layout
.setStretchFactor(self
.details_text
, 2)
977 self
.setLayout(self
.main_layout
)
980 self
.button_ok
.setDefault(True)
981 self
.button_ok
.setFocus()
983 self
.button_close
.setDefault(True)
984 self
.button_close
.setFocus()
986 qtutils
.connect_button(self
.button_ok
, self
.accept
)
987 qtutils
.connect_button(self
.button_close
, self
.reject
)
988 self
.init_state(None, self
.set_initial_size
)
990 def set_initial_size(self
):
991 width
= defs
.dialog_w
992 height
= defs
.msgbox_h
993 self
.resize(width
, height
)
995 def keyPressEvent(self
, event
):
996 """Handle Y/N hotkeys"""
999 QtCore
.QTimer
.singleShot(0, self
.accept
)
1000 elif key
in (Qt
.Key_N
, Qt
.Key_Q
):
1001 QtCore
.QTimer
.singleShot(0, self
.reject
)
1002 elif key
== Qt
.Key_Tab
:
1003 if self
.button_ok
.isVisible():
1005 if self
.focusWidget() == self
.button_close
:
1006 self
.button_ok
.setFocus()
1008 self
.button_close
.setFocus()
1010 Dialog
.keyPressEvent(self
, event
)
1016 def apply_state(self
, state
):
1017 """Imports data for view save/restore"""
1018 desktop_width
, desktop_height
= qtutils
.desktop_size()
1019 width
= min(desktop_width
, utils
.asint(state
.get('width')))
1020 height
= min(desktop_height
, utils
.asint(state
.get('height')))
1021 x
= min(desktop_width
, utils
.asint(state
.get('x')))
1022 y
= min(desktop_height
, utils
.asint(state
.get('y')))
1025 if width
and height
:
1026 self
.resize(width
, height
)
1032 def export_state(self
):
1033 """Exports data for view save/restore"""
1034 desktop_width
, desktop_height
= qtutils
.desktop_size()
1036 state
['width'] = min(desktop_width
, self
.width())
1037 state
['height'] = min(desktop_height
, self
.height())
1038 state
['x'] = min(desktop_width
, self
.x())
1039 state
['y'] = min(desktop_height
, self
.y())
1053 """Confirm that an action should take place"""
1054 cancel_text
= cancel_text
or N_('Cancel')
1055 logo
= icons
.from_style(QtWidgets
.QStyle
.SP_MessageBoxQuestion
)
1058 parent
=qtutils
.active_window(),
1061 info
=informative_text
,
1064 cancel_text
=cancel_text
,
1065 cancel_icon
=cancel_icon
,
1070 return mbox
.run() == mbox
.Accepted
1073 def critical(title
, message
=None, details
=None):
1074 """Show a warning with the provided title and message."""
1077 logo
= icons
.from_style(QtWidgets
.QStyle
.SP_MessageBoxCritical
)
1079 parent
=qtutils
.active_window(),
1088 def command_error(title
, cmd
, status
, out
, err
):
1089 """Report an error message about a failed command"""
1090 details
= Interaction
.format_out_err(out
, err
)
1091 message
= Interaction
.format_command_status(cmd
, status
)
1092 critical(title
, message
=message
, details
=details
)
1095 def information(title
, message
=None, details
=None, informative_text
=None):
1096 """Show information with the provided title and message."""
1100 parent
=qtutils
.active_window(),
1103 info
=informative_text
,
1110 def progress(title
, text
, parent
):
1111 """Create a new ProgressDialog"""
1112 return ProgressDialog(title
, text
, parent
)
1115 class ProgressBar(QtWidgets
.QProgressBar
):
1116 """An indeterminate progress bar with animated scrolling"""
1118 def __init__(self
, parent
, maximum
, hide
=(), disable
=(), visible
=False):
1119 super().__init
__(parent
)
1120 self
.setTextVisible(False)
1121 self
.setMaximum(maximum
)
1123 self
.setVisible(False)
1124 self
.progress_thread
= ProgressTickThread(self
, maximum
)
1125 self
.progress_thread
.updated
.connect(self
.setValue
, type=Qt
.QueuedConnection
)
1126 self
.progress_thread
.activated
.connect(self
.activate
, type=Qt
.QueuedConnection
)
1127 self
._widgets
_to
_hide
= hide
1128 self
._widgets
_to
_disable
= disable
1131 """Start the progress tick thread"""
1132 QtWidgets
.QApplication
.setOverrideCursor(Qt
.WaitCursor
)
1134 for widget
in self
._widgets
_to
_disable
:
1135 widget
.setEnabled(False)
1137 self
.progress_thread
.start()
1140 """Hide widgets and display the progress bar"""
1141 for widget
in self
._widgets
_to
_hide
:
1146 """Stop the progress tick thread, re-enable and display widgets"""
1147 self
.progress_thread
.stop()
1148 self
.progress_thread
.wait()
1150 for widget
in self
._widgets
_to
_disable
:
1151 widget
.setEnabled(True)
1154 for widget
in self
._widgets
_to
_hide
:
1157 QtWidgets
.QApplication
.restoreOverrideCursor()
1160 def progress_bar(parent
, maximum
=10, hide
=(), disable
=()):
1161 """Return a text-less progress bar"""
1162 widget
= ProgressBar(parent
, maximum
, hide
=hide
, disable
=disable
)
1166 def question(title
, text
, default
=True, logo
=None):
1167 """Launches a QMessageBox question with the provided title and message.
1168 Passing "default=False" will make "No" the default choice."""
1169 parent
= qtutils
.active_window()
1171 logo
= icons
.from_style(QtWidgets
.QStyle
.SP_MessageBoxQuestion
)
1172 msgbox
= MessageBox(
1179 cancel_text
=N_('No'),
1181 return msgbox
.run() == msgbox
.Accepted
1184 def save_as(filename
, title
):
1185 return qtutils
.save_as(filename
, title
=title
)
1188 def async_command(title
, cmd
, runtask
):
1189 task
= qtutils
.SimpleTask(partial(core
.run_command
, cmd
))
1190 task
.connect(partial(async_command_result
, title
, cmd
))
1194 def async_command_result(title
, cmd
, result
):
1195 status
, out
, err
= result
1196 cmd_string
= core
.list2cmdline(cmd
)
1197 Interaction
.command(title
, cmd_string
, status
, out
, err
)
1201 """Install the GUI-model interaction hooks"""
1202 Interaction
.critical
= staticmethod(critical
)
1203 Interaction
.confirm
= staticmethod(confirm
)
1204 Interaction
.question
= staticmethod(question
)
1205 Interaction
.information
= staticmethod(information
)
1206 Interaction
.command_error
= staticmethod(command_error
)
1207 Interaction
.save_as
= staticmethod(save_as
)
1208 Interaction
.async_command
= staticmethod(async_command
)