about: update authors list
[git-cola.git] / cola / widgets / standard.py
blob8eb7d9580fa350961ed1a3156991bbad15e01afc
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 sync = True
67 context = getattr(self, 'context', None)
68 if context:
69 cfg = context.cfg
70 save = cfg.get('cola.savewindowsettings', default=True)
71 sync = cfg.get('cola.sync', default=True)
72 if save:
73 if settings is None:
74 settings = Settings.read()
75 settings.save_gui_state(self, sync=sync)
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().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
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 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(
234 self,
235 N_('Move Up'),
236 self.move_up,
237 hotkeys.MOVE_UP,
238 hotkeys.MOVE_UP_SECONDARY,
241 self.down_action = qtutils.add_action(
242 self,
243 N_('Move Down'),
244 self.move_down,
245 hotkeys.MOVE_DOWN,
246 hotkeys.MOVE_DOWN_SECONDARY,
249 def selected_item(self):
250 return self.currentItem()
252 def selected_items(self):
253 return self.selectedItems()
255 def move_up(self):
256 self.move(-1)
258 def move_down(self):
259 self.move(1)
261 def move(self, direction):
262 item = self.selected_item()
263 if item:
264 row = (self.row(item) + direction) % self.count()
265 elif self.count() > 0:
266 row = (self.count() + direction) % self.count()
267 else:
268 return
269 new_item = self.item(row)
270 if new_item:
271 self.setCurrentItem(new_item)
274 class TreeMixin:
275 def __init__(self, widget, Base):
276 self.widget = widget
277 self.Base = 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.
299 widget = self.widget
300 index = widget.currentIndex()
301 was_expanded = widget.isExpanded(index)
302 was_collapsed = not was_expanded
304 # Vim keybindings...
305 event = _create_vim_navigation_key_event(event)
307 # Read the updated event key to take the mappings into account
308 key = event.key()
309 if key == Qt.Key_Up:
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
316 # change focus.
317 widget.up.emit()
319 elif key == Qt.Key_Space:
320 widget.space.emit()
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
342 elif was_collapsed:
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())
351 if index.isValid():
352 widget.setCurrentIndex(index)
354 return result
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)
360 else:
361 index = self.model().itemFromIndex()
362 return index
364 def items(self):
365 root = self.widget.invisibleRootItem()
366 child = root.child
367 count = root.childCount()
368 return [child(i) for i in range(count)]
370 def selected_items(self):
371 """Return all selected items"""
372 widget = self.widget
373 if hasattr(widget, 'selectedItems'):
374 return widget.selectedItems()
375 if hasattr(widget, 'itemFromIndex'):
376 item_from_index = widget.itemFromIndex
377 else:
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:
385 return None
386 return selected_items[0]
388 def current_item(self):
389 item = None
390 widget = self.widget
391 if hasattr(widget, 'currentItem'):
392 item = widget.currentItem()
393 else:
394 index = widget.currentIndex()
395 if index.isValid():
396 item = widget.model().itemFromIndex(index)
397 return item
399 def column_widths(self):
400 """Return the tree's column widths"""
401 widget = self.widget
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"""
407 if widths:
408 widget = self.widget
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"""
418 key = event.key()
419 # Remap 'H' to 'Left'
420 if key == Qt.Key_H:
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())
425 # Remap 'K' to 'Up'
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())
431 return event
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"""
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 def dispose(self):
529 """Extension method for model de-registration in sub-classes"""
530 return
532 def close(self):
533 """save_settings() is handled by accept() and reject()"""
534 self.dispose()
535 self.Base.close(self)
537 def closeEvent(self, event):
538 """save_settings() is handled by accept() and reject()"""
539 self.dispose()
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):
552 Mixin = TreeMixin
554 up = Signal()
555 space = Signal()
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()
574 def items(self):
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):
585 Mixin = TreeMixin
587 up = Signal()
588 space = Signal()
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()
607 def items(self):
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
638 prematurely closed.
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)
653 self.reset()
654 self.setRange(0, 0)
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)
675 def start(self):
676 """Start the animation thread and use a wait cursor"""
677 self.show()
678 QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor)
679 self.animation_thread.start()
681 def stop(self):
682 """Stop the animation thread and restore the normal cursor"""
683 self.animation_thread.stop()
684 self.animation_thread.wait()
685 QtWidgets.QApplication.restoreOverrideCursor()
686 self.hide()
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)
697 self.running = False
698 self.txt = txt
699 self.sleep_time = sleep_time
700 self.symbols = [
701 '. ..',
702 '.. .',
703 '... ',
704 ' ... ',
705 ' ...',
707 self.idx = -1
709 def set_text(self, txt):
710 """Set the text prefix"""
711 self.txt = txt
713 def tick(self):
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]
718 def stop(self):
719 """Stop the animation thread"""
720 self.running = False
722 def run(self):
723 """Emit ticks until stopped"""
724 self.running = True
725 while self.running:
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.
736 activated = Signal()
738 def __init__(
739 self,
740 parent,
741 maximum,
742 start_time=1.0,
743 sleep_time=0.05,
745 QtCore.QThread.__init__(self, parent)
746 self.running = False
747 self.sleep_time = sleep_time
748 self.maximum = maximum
749 self.start_time = start_time
750 self.value = 0
751 self.step = 1
753 def tick(self):
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:
760 self.step = -1
761 elif self.value == 0:
762 self.step = 1
763 return self.value
765 def stop(self):
766 """Stop the tick thread and reset to the initial state"""
767 self.running = False
768 self.value = 0
769 self.step = 1
771 def run(self):
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()
778 active = False
779 self.running = True
780 self.value = 0
781 self.step = 1
782 while self.running:
783 if active:
784 self.updated.emit(self.tick())
785 else:
786 now = time.time()
787 if self.start_time < (now - initial_time):
788 active = True
789 self.activated.emit()
790 time.sleep(self.sleep_time)
793 class SpinBox(QtWidgets.QSpinBox):
794 def __init__(
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)
803 if step:
804 self.setSingleStep(step)
805 if value is not None:
806 self.setValue(value)
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(
825 defs.no_margin,
826 defs.spacing,
827 self.browse_button,
828 self.line_edit,
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)
838 def value(self):
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())
845 if not output_dir:
846 return
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:
851 output_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"""
859 columns = []
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.
889 def __init__(
890 self,
891 parent=None,
892 title='',
893 text='',
894 info='',
895 details='',
896 logo=None,
897 default=False,
898 ok_icon=None,
899 ok_text='',
900 cancel_text=None,
901 cancel_icon=None,
903 Dialog.__init__(self, parent=parent)
905 if parent:
906 self.setWindowModality(Qt.WindowModal)
907 if title:
908 self.setWindowTitle(title)
910 self.logo_label = QtWidgets.QLabel()
911 if logo:
912 # Render into a 1-inch wide pixmap
913 pixmap = logo.pixmap(defs.large_icon)
914 self.logo_label.setPixmap(pixmap)
915 else:
916 self.logo_label.hide()
918 self.text_label = QtWidgets.QLabel()
919 self.text_label.setText(text)
921 self.info_label = QtWidgets.QLabel()
922 if info:
923 self.info_label.setText(info)
924 else:
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)
932 if ok_text:
933 self.button_ok.setText(ok_text)
934 else:
935 self.button_ok.hide()
937 self.details_text = QtWidgets.QPlainTextEdit()
938 self.details_text.setReadOnly(True)
939 if details:
940 self.details_text.setFont(qtutils.default_monospace_font())
941 self.details_text.setPlainText(details)
942 else:
943 self.details_text.hide()
945 self.info_layout = qtutils.vbox(
946 defs.large_margin,
947 defs.button_spacing,
948 self.text_label,
949 self.info_label,
950 qtutils.STRETCH,
953 self.top_layout = qtutils.hbox(
954 defs.large_margin,
955 defs.button_spacing,
956 self.logo_label,
957 self.info_layout,
958 qtutils.STRETCH,
961 self.buttons_layout = qtutils.hbox(
962 defs.no_margin,
963 defs.button_spacing,
964 qtutils.STRETCH,
965 self.button_close,
966 self.button_ok,
969 self.main_layout = qtutils.vbox(
970 defs.margin,
971 defs.button_spacing,
972 self.top_layout,
973 self.buttons_layout,
974 self.details_text,
976 self.main_layout.setStretchFactor(self.details_text, 2)
977 self.setLayout(self.main_layout)
979 if default:
980 self.button_ok.setDefault(True)
981 self.button_ok.setFocus()
982 else:
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"""
997 key = event.key()
998 if key == Qt.Key_Y:
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():
1004 event.accept()
1005 if self.focusWidget() == self.button_close:
1006 self.button_ok.setFocus()
1007 else:
1008 self.button_close.setFocus()
1009 return
1010 Dialog.keyPressEvent(self, event)
1012 def run(self):
1013 self.show()
1014 return self.exec_()
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')))
1023 result = False
1025 if width and height:
1026 self.resize(width, height)
1027 self.move(x, y)
1028 result = True
1030 return result
1032 def export_state(self):
1033 """Exports data for view save/restore"""
1034 desktop_width, desktop_height = qtutils.desktop_size()
1035 state = {}
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())
1040 return state
1043 def confirm(
1044 title,
1045 text,
1046 informative_text,
1047 ok_text,
1048 icon=None,
1049 default=True,
1050 cancel_text=None,
1051 cancel_icon=None,
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)
1057 mbox = MessageBox(
1058 parent=qtutils.active_window(),
1059 title=title,
1060 text=text,
1061 info=informative_text,
1062 ok_text=ok_text,
1063 ok_icon=icon,
1064 cancel_text=cancel_text,
1065 cancel_icon=cancel_icon,
1066 logo=logo,
1067 default=default,
1070 return mbox.run() == mbox.Accepted
1073 def critical(title, message=None, details=None):
1074 """Show a warning with the provided title and message."""
1075 if message is None:
1076 message = title
1077 logo = icons.from_style(QtWidgets.QStyle.SP_MessageBoxCritical)
1078 mbox = MessageBox(
1079 parent=qtutils.active_window(),
1080 title=title,
1081 text=message,
1082 details=details,
1083 logo=logo,
1085 mbox.run()
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."""
1097 if message is None:
1098 message = title
1099 mbox = MessageBox(
1100 parent=qtutils.active_window(),
1101 title=title,
1102 text=message,
1103 info=informative_text,
1104 details=details,
1105 logo=icons.cola(),
1107 mbox.run()
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)
1122 if not visible:
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
1130 def start(self):
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()
1139 def activate(self):
1140 """Hide widgets and display the progress bar"""
1141 for widget in self._widgets_to_hide:
1142 widget.hide()
1143 self.show()
1145 def stop(self):
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)
1153 self.hide()
1154 for widget in self._widgets_to_hide:
1155 widget.show()
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)
1163 return widget
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()
1170 if logo is None:
1171 logo = icons.from_style(QtWidgets.QStyle.SP_MessageBoxQuestion)
1172 msgbox = MessageBox(
1173 parent=parent,
1174 title=title,
1175 text=text,
1176 default=default,
1177 logo=logo,
1178 ok_text=N_('Yes'),
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))
1191 runtask.start(task)
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)
1200 def install():
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)