widgets: set a unique object name for all dock widgets
[git-cola.git] / cola / qtutils.py
blob270aa4735aff2930561f7d2ceec9a2dffeaf11f4
1 """Miscellaneous Qt utility functions."""
2 from __future__ import absolute_import, division, print_function, unicode_literals
3 import os
5 from qtpy import compat
6 from qtpy import QtGui
7 from qtpy import QtCore
8 from qtpy import QtWidgets
9 from qtpy.QtCore import Qt
10 from qtpy.QtCore import Signal
12 from . import core
13 from . import hotkeys
14 from . import icons
15 from . import utils
16 from .i18n import N_
17 from .compat import int_types
18 from .compat import ustr
19 from .models import prefs
20 from .widgets import defs
23 STRETCH = object()
24 SKIPPED = object()
27 def active_window():
28 """Return the active window for the current application"""
29 return QtWidgets.QApplication.activeWindow()
32 def connect_action(action, fn):
33 """Connect an action to a function"""
34 action.triggered[bool].connect(lambda x: fn())
37 def connect_action_bool(action, fn):
38 """Connect a triggered(bool) action to a function"""
39 action.triggered[bool].connect(fn)
42 def connect_button(button, fn):
43 """Connect a button to a function"""
44 # Some versions of Qt send the `bool` argument to the clicked callback,
45 # and some do not. The lambda consumes all callback-provided arguments.
46 button.clicked.connect(lambda *args, **kwargs: fn())
49 def connect_checkbox(widget, fn):
50 """Connect a checkbox to a function taking bool"""
51 widget.clicked.connect(lambda *args, **kwargs: fn(get(checkbox)))
54 def connect_released(button, fn):
55 """Connect a button to a function"""
56 button.released.connect(fn)
59 def button_action(button, action):
60 """Make a button trigger an action"""
61 connect_button(button, action.trigger)
64 def connect_toggle(toggle, fn):
65 """Connect a toggle button to a function"""
66 toggle.toggled.connect(fn)
69 def disconnect(signal):
70 """Disconnect signal from all slots"""
71 try:
72 signal.disconnect()
73 except TypeError: # allow unconnected slots
74 pass
77 def get(widget):
78 """Query a widget for its python value"""
79 if hasattr(widget, 'isChecked'):
80 value = widget.isChecked()
81 elif hasattr(widget, 'value'):
82 value = widget.value()
83 elif hasattr(widget, 'text'):
84 value = widget.text()
85 elif hasattr(widget, 'toPlainText'):
86 value = widget.toPlainText()
87 elif hasattr(widget, 'sizes'):
88 value = widget.sizes()
89 elif hasattr(widget, 'date'):
90 value = widget.date().toString(Qt.ISODate)
91 else:
92 value = None
93 return value
96 def hbox(margin, spacing, *items):
97 """Create an HBoxLayout with the specified sizes and items"""
98 return box(QtWidgets.QHBoxLayout, margin, spacing, *items)
101 def vbox(margin, spacing, *items):
102 """Create a VBoxLayout with the specified sizes and items"""
103 return box(QtWidgets.QVBoxLayout, margin, spacing, *items)
106 def buttongroup(*items):
107 """Create a QButtonGroup for the specified items"""
108 group = QtWidgets.QButtonGroup()
109 for i in items:
110 group.addButton(i)
111 return group
114 def set_margin(layout, margin):
115 """Set the content margins for a layout"""
116 layout.setContentsMargins(margin, margin, margin, margin)
119 def box(cls, margin, spacing, *items):
120 """Create a QBoxLayout with the specified sizes and items"""
121 stretch = STRETCH
122 skipped = SKIPPED
123 layout = cls()
124 layout.setSpacing(spacing)
125 set_margin(layout, margin)
127 for i in items:
128 if isinstance(i, QtWidgets.QWidget):
129 layout.addWidget(i)
130 elif isinstance(
133 QtWidgets.QHBoxLayout,
134 QtWidgets.QVBoxLayout,
135 QtWidgets.QFormLayout,
136 QtWidgets.QLayout,
139 layout.addLayout(i)
140 elif i is stretch:
141 layout.addStretch()
142 elif i is skipped:
143 continue
144 elif isinstance(i, int_types):
145 layout.addSpacing(i)
147 return layout
150 def form(margin, spacing, *widgets):
151 """Create a QFormLayout with the specified sizes and items"""
152 layout = QtWidgets.QFormLayout()
153 layout.setSpacing(spacing)
154 layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.ExpandingFieldsGrow)
155 set_margin(layout, margin)
157 for idx, (name, widget) in enumerate(widgets):
158 if isinstance(name, (str, ustr)):
159 layout.addRow(name, widget)
160 else:
161 layout.setWidget(idx, QtWidgets.QFormLayout.LabelRole, name)
162 layout.setWidget(idx, QtWidgets.QFormLayout.FieldRole, widget)
164 return layout
167 def grid(margin, spacing, *widgets):
168 """Create a QGridLayout with the specified sizes and items"""
169 layout = QtWidgets.QGridLayout()
170 layout.setSpacing(spacing)
171 set_margin(layout, margin)
173 for row in widgets:
174 item = row[0]
175 if isinstance(item, QtWidgets.QWidget):
176 layout.addWidget(*row)
177 elif isinstance(item, QtWidgets.QLayoutItem):
178 layout.addItem(*row)
180 return layout
183 def splitter(orientation, *widgets):
184 """Create a spliter over the specified widgets
186 :param orientation: Qt.Horizontal or Qt.Vertical
189 layout = QtWidgets.QSplitter()
190 layout.setOrientation(orientation)
191 layout.setHandleWidth(defs.handle_width)
192 layout.setChildrenCollapsible(True)
194 for idx, widget in enumerate(widgets):
195 layout.addWidget(widget)
196 layout.setStretchFactor(idx, 1)
198 # Workaround for Qt not setting the WA_Hover property for QSplitter
199 # Cf. https://bugreports.qt.io/browse/QTBUG-13768
200 layout.handle(1).setAttribute(Qt.WA_Hover)
202 return layout
205 def label(text=None, align=None, fmt=None, selectable=True):
206 """Create a QLabel with the specified properties"""
207 widget = QtWidgets.QLabel()
208 if align is not None:
209 widget.setAlignment(align)
210 if fmt is not None:
211 widget.setTextFormat(fmt)
212 if selectable:
213 widget.setTextInteractionFlags(Qt.TextBrowserInteraction)
214 widget.setOpenExternalLinks(True)
215 if text:
216 widget.setText(text)
217 return widget
220 class ComboBox(QtWidgets.QComboBox):
221 """Custom read-only combobox with a convenient API"""
223 def __init__(self, items=None, editable=False, parent=None, transform=None):
224 super(ComboBox, self).__init__(parent)
225 self.setEditable(editable)
226 self.transform = transform
227 self.item_data = []
228 if items:
229 self.addItems(items)
230 self.item_data.extend(items)
232 def set_index(self, idx):
233 idx = utils.clamp(idx, 0, self.count() - 1)
234 self.setCurrentIndex(idx)
236 def add_item(self, text, data):
237 self.addItem(text)
238 self.item_data.append(data)
240 def current_data(self):
241 return self.item_data[self.currentIndex()]
243 def set_value(self, value):
244 if self.transform:
245 value = self.transform(value)
246 try:
247 index = self.item_data.index(value)
248 except ValueError:
249 index = 0
250 self.setCurrentIndex(index)
253 def combo(items, editable=False, parent=None):
254 """Create a readonly (by default) combobox from a list of items"""
255 return ComboBox(editable=editable, items=items, parent=parent)
258 def combo_mapped(data, editable=False, transform=None, parent=None):
259 """Create a readonly (by default) combobox from a list of items"""
260 widget = ComboBox(editable=editable, transform=transform, parent=parent)
261 for (k, v) in data:
262 widget.add_item(k, v)
263 return widget
266 def textbrowser(text=None):
267 """Create a QTextBrowser for the specified text"""
268 widget = QtWidgets.QTextBrowser()
269 widget.setOpenExternalLinks(True)
270 if text:
271 widget.setText(text)
272 return widget
275 def add_completer(widget, items):
276 """Add simple completion to a widget"""
277 completer = QtWidgets.QCompleter(items, widget)
278 completer.setCaseSensitivity(Qt.CaseInsensitive)
279 completer.setCompletionMode(QtWidgets.QCompleter.InlineCompletion)
280 widget.setCompleter(completer)
283 def prompt(msg, title=None, text='', parent=None):
284 """Presents the user with an input widget and returns the input."""
285 if title is None:
286 title = msg
287 if parent is None:
288 parent = active_window()
289 result = QtWidgets.QInputDialog.getText(
290 parent, title, msg, QtWidgets.QLineEdit.Normal, text
292 return (result[0], result[1])
295 def prompt_n(msg, inputs):
296 """Presents the user with N input widgets and returns the results"""
297 dialog = QtWidgets.QDialog(active_window())
298 dialog.setWindowModality(Qt.WindowModal)
299 dialog.setWindowTitle(msg)
301 long_value = msg
302 for k, v in inputs:
303 if len(k + v) > len(long_value):
304 long_value = k + v
306 metrics = QtGui.QFontMetrics(dialog.font())
307 min_width = metrics.width(long_value) + 100
308 if min_width > 720:
309 min_width = 720
310 dialog.setMinimumWidth(min_width)
312 ok_b = ok_button(msg, enabled=False)
313 close_b = close_button()
315 form_widgets = []
317 def get_values():
318 return [pair[1].text().strip() for pair in form_widgets]
320 for name, value in inputs:
321 lineedit = QtWidgets.QLineEdit()
322 # Enable the OK button only when all fields have been populated
323 # pylint: disable=no-member
324 lineedit.textChanged.connect(lambda x: ok_b.setEnabled(all(get_values())))
325 if value:
326 lineedit.setText(value)
327 form_widgets.append((name, lineedit))
329 # layouts
330 form_layout = form(defs.no_margin, defs.button_spacing, *form_widgets)
331 button_layout = hbox(defs.no_margin, defs.button_spacing, STRETCH, close_b, ok_b)
332 main_layout = vbox(defs.margin, defs.button_spacing, form_layout, button_layout)
333 dialog.setLayout(main_layout)
335 # connections
336 connect_button(ok_b, dialog.accept)
337 connect_button(close_b, dialog.reject)
339 accepted = dialog.exec_() == QtWidgets.QDialog.Accepted
340 text = get_values()
341 ok = accepted and all(text)
342 return (ok, text)
345 class TreeWidgetItem(QtWidgets.QTreeWidgetItem):
347 TYPE = QtGui.QStandardItem.UserType + 101
349 def __init__(self, path, icon, deleted):
350 QtWidgets.QTreeWidgetItem.__init__(self)
351 self.path = path
352 self.deleted = deleted
353 self.setIcon(0, icons.from_name(icon))
354 self.setText(0, path)
356 def type(self):
357 return self.TYPE
360 def paths_from_indexes(model, indexes, item_type=TreeWidgetItem.TYPE, item_filter=None):
361 """Return paths from a list of QStandardItemModel indexes"""
362 items = [model.itemFromIndex(i) for i in indexes]
363 return paths_from_items(items, item_type=item_type, item_filter=item_filter)
366 def _true_filter(_x):
367 return True
370 def paths_from_items(items, item_type=TreeWidgetItem.TYPE, item_filter=None):
371 """Return a list of paths from a list of items"""
372 if item_filter is None:
373 item_filter = _true_filter
374 return [i.path for i in items if i.type() == item_type and item_filter(i)]
377 def tree_selection(tree_item, items):
378 """Returns an array of model items that correspond to the selected
379 QTreeWidgetItem children"""
380 selected = []
381 count = min(tree_item.childCount(), len(items))
382 for idx in range(count):
383 if tree_item.child(idx).isSelected():
384 selected.append(items[idx])
386 return selected
389 def tree_selection_items(tree_item):
390 """Returns selected widget items"""
391 selected = []
392 for idx in range(tree_item.childCount()):
393 child = tree_item.child(idx)
394 if child.isSelected():
395 selected.append(child)
397 return selected
400 def selected_item(list_widget, items):
401 """Returns the model item that corresponds to the selected QListWidget
402 row."""
403 widget_items = list_widget.selectedItems()
404 if not widget_items:
405 return None
406 widget_item = widget_items[0]
407 row = list_widget.row(widget_item)
408 if row < len(items):
409 item = items[row]
410 else:
411 item = None
412 return item
415 def selected_items(list_widget, items):
416 """Returns an array of model items that correspond to the selected
417 QListWidget rows."""
418 item_count = len(items)
419 selected = []
420 for widget_item in list_widget.selectedItems():
421 row = list_widget.row(widget_item)
422 if row < item_count:
423 selected.append(items[row])
424 return selected
427 def open_file(title, directory=None):
428 """Creates an Open File dialog and returns a filename."""
429 result = compat.getopenfilename(
430 parent=active_window(), caption=title, basedir=directory
432 return result[0]
435 def open_files(title, directory=None, filters=''):
436 """Creates an Open File dialog and returns a list of filenames."""
437 result = compat.getopenfilenames(
438 parent=active_window(), caption=title, basedir=directory, filters=filters
440 return result[0]
443 def opendir_dialog(caption, path):
444 """Prompts for a directory path"""
446 options = (
447 QtWidgets.QFileDialog.ShowDirsOnly | QtWidgets.QFileDialog.DontResolveSymlinks
449 return compat.getexistingdirectory(
450 parent=active_window(), caption=caption, basedir=path, options=options
454 def save_as(filename, title='Save As...'):
455 """Creates a Save File dialog and returns a filename."""
456 result = compat.getsavefilename(
457 parent=active_window(), caption=title, basedir=filename
459 return result[0]
462 def copy_path(filename, absolute=True):
463 """Copy a filename to the clipboard"""
464 if filename is None:
465 return
466 if absolute:
467 filename = core.abspath(filename)
468 set_clipboard(filename)
471 def set_clipboard(text):
472 """Sets the copy/paste buffer to text."""
473 if not text:
474 return
475 clipboard = QtWidgets.QApplication.clipboard()
476 clipboard.setText(text, QtGui.QClipboard.Clipboard)
477 if not utils.is_darwin() and not utils.is_win32():
478 clipboard.setText(text, QtGui.QClipboard.Selection)
479 persist_clipboard()
482 # pylint: disable=line-too-long
483 def persist_clipboard():
484 """Persist the clipboard
486 X11 stores only a reference to the clipboard data.
487 Send a clipboard event to force a copy of the clipboard to occur.
488 This ensures that the clipboard is present after git-cola exits.
489 Otherwise, the reference is destroyed on exit.
491 C.f. https://stackoverflow.com/questions/2007103/how-can-i-disable-clear-of-clipboard-on-exit-of-pyqt4-application
493 """ # noqa
494 clipboard = QtWidgets.QApplication.clipboard()
495 event = QtCore.QEvent(QtCore.QEvent.Clipboard)
496 QtWidgets.QApplication.sendEvent(clipboard, event)
499 def add_action_bool(widget, text, fn, checked, *shortcuts):
500 tip = text
501 action = _add_action(widget, text, tip, fn, connect_action_bool, *shortcuts)
502 action.setCheckable(True)
503 action.setChecked(checked)
504 return action
507 def add_action(widget, text, fn, *shortcuts):
508 tip = text
509 return _add_action(widget, text, tip, fn, connect_action, *shortcuts)
512 def add_action_with_status_tip(widget, text, tip, fn, *shortcuts):
513 return _add_action(widget, text, tip, fn, connect_action, *shortcuts)
516 def _add_action(widget, text, tip, fn, connect, *shortcuts):
517 action = QtWidgets.QAction(text, widget)
518 if hasattr(action, 'setIconVisibleInMenu'):
519 action.setIconVisibleInMenu(True)
520 if tip:
521 action.setStatusTip(tip)
522 connect(action, fn)
523 if shortcuts:
524 action.setShortcuts(shortcuts)
525 if hasattr(Qt, 'WidgetWithChildrenShortcut'):
526 action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
527 widget.addAction(action)
528 return action
531 def set_selected_item(widget, idx):
532 """Sets a the currently selected item to the item at index idx."""
533 if isinstance(widget, QtWidgets.QTreeWidget):
534 item = widget.topLevelItem(idx)
535 if item:
536 item.setSelected(True)
537 widget.setCurrentItem(item)
540 def add_items(widget, items):
541 """Adds items to a widget."""
542 for item in items:
543 if item is None:
544 continue
545 widget.addItem(item)
548 def set_items(widget, items):
549 """Clear the existing widget contents and set the new items."""
550 widget.clear()
551 add_items(widget, items)
554 def create_treeitem(filename, staged=False, deleted=False, untracked=False):
555 """Given a filename, return a TreeWidgetItem for a status widget
557 "staged", "deleted, and "untracked" control which icon is used.
560 icon_name = icons.status(filename, deleted, staged, untracked)
561 icon = icons.name_from_basename(icon_name)
562 return TreeWidgetItem(filename, icon, deleted=deleted)
565 def add_close_action(widget):
566 """Adds close action and shortcuts to a widget."""
567 return add_action(widget, N_('Close...'), widget.close, hotkeys.CLOSE, hotkeys.QUIT)
570 def app():
571 """Return the current application"""
572 return QtWidgets.QApplication.instance()
575 def desktop():
576 """Return the desktop"""
577 return app().desktop()
580 def desktop_size():
581 desk = desktop()
582 rect = desk.screenGeometry(QtGui.QCursor().pos())
583 return (rect.width(), rect.height())
586 def center_on_screen(widget):
587 """Move widget to the center of the default screen"""
588 width, height = desktop_size()
589 cx = width // 2
590 cy = height // 2
591 widget.move(cx - widget.width() // 2, cy - widget.height() // 2)
594 def default_size(parent, width, height, use_parent_height=True):
595 """Return the parent's size, or the provided defaults"""
596 if parent is not None:
597 width = parent.width()
598 if use_parent_height:
599 height = parent.height()
600 return (width, height)
603 def default_monospace_font():
604 if utils.is_darwin():
605 family = 'Monaco'
606 else:
607 family = 'Monospace'
608 mfont = QtGui.QFont()
609 mfont.setFamily(family)
610 return mfont
613 def diff_font_str(context):
614 cfg = context.cfg
615 font_str = cfg.get(prefs.FONTDIFF)
616 if not font_str:
617 font_str = default_monospace_font().toString()
618 return font_str
621 def diff_font(context):
622 return font(diff_font_str(context))
625 def font(string):
626 qfont = QtGui.QFont()
627 qfont.fromString(string)
628 return qfont
631 def create_button(
632 text='', layout=None, tooltip=None, icon=None, enabled=True, default=False
634 """Create a button, set its title, and add it to the parent."""
635 button = QtWidgets.QPushButton()
636 button.setCursor(Qt.PointingHandCursor)
637 button.setFocusPolicy(Qt.NoFocus)
638 if text:
639 button.setText(' ' + text)
640 if icon is not None:
641 button.setIcon(icon)
642 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
643 if tooltip is not None:
644 button.setToolTip(tooltip)
645 if layout is not None:
646 layout.addWidget(button)
647 if not enabled:
648 button.setEnabled(False)
649 if default:
650 button.setDefault(True)
651 return button
654 def tool_button():
655 """Create a flat border-less button"""
656 button = QtWidgets.QToolButton()
657 button.setPopupMode(QtWidgets.QToolButton.InstantPopup)
658 button.setCursor(Qt.PointingHandCursor)
659 button.setFocusPolicy(Qt.NoFocus)
660 # Highlight colors
661 palette = QtGui.QPalette()
662 highlight = palette.color(QtGui.QPalette.Highlight)
663 highlight_rgb = rgb_css(highlight)
665 button.setStyleSheet(
667 /* No borders */
668 QToolButton {
669 border: none;
670 background-color: none;
672 /* Hide the menu indicator */
673 QToolButton::menu-indicator {
674 image: none;
676 QToolButton:hover {
677 border: %(border)spx solid %(highlight_rgb)s;
680 % dict(border=defs.border, highlight_rgb=highlight_rgb)
682 return button
685 def create_action_button(tooltip=None, icon=None):
686 """Create a small toolbutton for use in dock title widgets"""
687 button = tool_button()
688 if tooltip is not None:
689 button.setToolTip(tooltip)
690 if icon is not None:
691 button.setIcon(icon)
692 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
693 return button
696 def ok_button(text, default=True, enabled=True, icon=None):
697 if icon is None:
698 icon = icons.ok()
699 return create_button(text=text, icon=icon, default=default, enabled=enabled)
702 def close_button(text=None, icon=None):
703 text = text or N_('Close')
704 icon = icons.mkicon(icon, icons.close)
705 return create_button(text=text, icon=icon)
708 def edit_button(enabled=True, default=False):
709 return create_button(
710 text=N_('Edit'), icon=icons.edit(), enabled=enabled, default=default
714 def refresh_button(enabled=True, default=False):
715 return create_button(
716 text=N_('Refresh'), icon=icons.sync(), enabled=enabled, default=default
720 def checkbox(text='', tooltip='', checked=None):
721 """Create a checkbox"""
722 return _checkbox(QtWidgets.QCheckBox, text, tooltip, checked)
725 def radio(text='', tooltip='', checked=None):
726 """Create a radio button"""
727 return _checkbox(QtWidgets.QRadioButton, text, tooltip, checked)
730 def _checkbox(cls, text, tooltip, checked):
731 """Create a widget and apply properties"""
732 widget = cls()
733 if text:
734 widget.setText(text)
735 if tooltip:
736 widget.setToolTip(tooltip)
737 if checked is not None:
738 widget.setChecked(checked)
739 return widget
742 class DockTitleBarWidget(QtWidgets.QFrame):
743 def __init__(self, parent, title, stretch=True):
744 QtWidgets.QFrame.__init__(self, parent)
745 self.setAutoFillBackground(True)
746 self.label = qlabel = QtWidgets.QLabel(title, self)
747 qfont = qlabel.font()
748 qfont.setBold(True)
749 qlabel.setFont(qfont)
750 qlabel.setCursor(Qt.OpenHandCursor)
752 self.close_button = create_action_button(
753 tooltip=N_('Close'), icon=icons.close()
756 self.toggle_button = create_action_button(
757 tooltip=N_('Detach'), icon=icons.external()
760 self.corner_layout = hbox(defs.no_margin, defs.spacing)
762 if stretch:
763 separator = STRETCH
764 else:
765 separator = SKIPPED
767 self.main_layout = hbox(
768 defs.small_margin,
769 defs.titlebar_spacing,
770 qlabel,
771 separator,
772 self.corner_layout,
773 self.toggle_button,
774 self.close_button,
776 self.setLayout(self.main_layout)
778 connect_button(self.toggle_button, self.toggle_floating)
779 connect_button(self.close_button, self.toggle_visibility)
781 def toggle_floating(self):
782 self.parent().setFloating(not self.parent().isFloating())
783 self.update_tooltips()
785 def toggle_visibility(self):
786 self.parent().toggleViewAction().trigger()
788 def set_title(self, title):
789 self.label.setText(title)
791 def add_corner_widget(self, widget):
792 self.corner_layout.addWidget(widget)
794 def update_tooltips(self):
795 if self.parent().isFloating():
796 tooltip = N_('Attach')
797 else:
798 tooltip = N_('Detach')
799 self.toggle_button.setToolTip(tooltip)
802 def create_dock(name, title, parent, stretch=True, widget=None, fn=None):
803 """Create a dock widget and set it up accordingly."""
804 dock = QtWidgets.QDockWidget(parent)
805 dock.setWindowTitle(title)
806 dock.setObjectName(name)
807 titlebar = DockTitleBarWidget(dock, title, stretch=stretch)
808 dock.setTitleBarWidget(titlebar)
809 dock.setAutoFillBackground(True)
810 if hasattr(parent, 'dockwidgets'):
811 parent.dockwidgets.append(dock)
812 if fn:
813 widget = fn(dock)
814 assert isinstance(widget, QtWidgets.QFrame), "Docked widget has to be a QFrame"
815 if widget:
816 dock.setWidget(widget)
817 return dock
820 def hide_dock(widget):
821 widget.toggleViewAction().setChecked(False)
822 widget.hide()
825 def create_menu(title, parent):
826 """Create a menu and set its title."""
827 qmenu = DebouncingMenu(title, parent)
828 return qmenu
831 class DebouncingMenu(QtWidgets.QMenu):
832 """Menu that debounces mouse release action ie. stops it if occurred
833 right after menu creation.
835 Disables annoying behaviour when RMB is pressed to show menu, cursor is
836 moved accidentally 1px onto newly created menu and released causing to
837 execute menu action
840 threshold_ms = 400
842 def __init__(self, title, parent):
843 QtWidgets.QMenu.__init__(self, title, parent)
844 self.created_at = utils.epoch_millis()
845 if hasattr(self, 'setToolTipsVisible'):
846 self.setToolTipsVisible(True)
848 def mouseReleaseEvent(self, event):
849 threshold = DebouncingMenu.threshold_ms
850 if (utils.epoch_millis() - self.created_at) > threshold:
851 QtWidgets.QMenu.mouseReleaseEvent(self, event)
854 def add_menu(title, parent):
855 """Create a menu and set its title."""
856 menu = create_menu(title, parent)
857 if hasattr(parent, 'addMenu'):
858 parent.addMenu(menu)
859 else:
860 parent.addAction(menu.menuAction())
861 return menu
864 def create_toolbutton(text=None, layout=None, tooltip=None, icon=None):
865 button = tool_button()
866 if icon is not None:
867 button.setIcon(icon)
868 button.setIconSize(QtCore.QSize(defs.default_icon, defs.default_icon))
869 if text is not None:
870 button.setText(' ' + text)
871 button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
872 if tooltip is not None:
873 button.setToolTip(tooltip)
874 if layout is not None:
875 layout.addWidget(button)
876 return button
879 # pylint: disable=line-too-long
880 def mimedata_from_paths(context, paths):
881 """Return mimedata with a list of absolute path URLs
883 The text/x-moz-list format is always included by Qt, and doing
884 mimedata.removeFormat('text/x-moz-url') has no effect.
885 C.f. http://www.qtcentre.org/threads/44643-Dragging-text-uri-list-Qt-inserts-garbage
887 gnome-terminal expects utf-16 encoded text, but other terminals,
888 e.g. terminator, prefer utf-8, so allow cola.dragencoding
889 to override the default.
891 """ # noqa
892 cfg = context.cfg
893 abspaths = [core.abspath(path) for path in paths]
894 urls = [QtCore.QUrl.fromLocalFile(path) for path in abspaths]
896 mimedata = QtCore.QMimeData()
897 mimedata.setUrls(urls)
899 paths_text = core.list2cmdline(abspaths)
900 encoding = cfg.get('cola.dragencoding', 'utf-16')
901 moz_text = core.encode(paths_text, encoding=encoding)
902 mimedata.setData('text/x-moz-url', moz_text)
904 return mimedata
907 def path_mimetypes():
908 return ['text/uri-list', 'text/x-moz-url']
911 class BlockSignals(object):
912 """Context manager for blocking a signals on a widget"""
914 def __init__(self, *widgets):
915 self.widgets = widgets
916 self.values = []
918 def __enter__(self):
919 """Block Qt signals for all of the captured widgets"""
920 self.values = [widget.blockSignals(True) for widget in self.widgets]
921 return self
923 def __exit__(self, exc_type, exc_val, exc_tb):
924 """Restore Qt signals when we exit the scope"""
925 for (widget, value) in zip(self.widgets, self.values):
926 widget.blockSignals(value)
929 class Channel(QtCore.QObject):
930 finished = Signal(object)
931 result = Signal(object)
934 class Task(QtCore.QRunnable):
935 """Disable auto-deletion to avoid gc issues
937 Python's garbage collector will try to double-free the task
938 once it's finished, so disable Qt's auto-deletion as a workaround.
942 def __init__(self, parent):
943 QtCore.QRunnable.__init__(self)
945 self.channel = Channel(parent)
946 self.result = None
947 self.setAutoDelete(False)
949 def run(self):
950 self.result = self.task()
951 self.channel.result.emit(self.result)
952 self.channel.finished.emit(self)
954 # pylint: disable=no-self-use
955 def task(self):
956 return None
958 def connect(self, handler):
959 self.channel.result.connect(handler, type=Qt.QueuedConnection)
962 class SimpleTask(Task):
963 """Run a simple callable as a task"""
965 def __init__(self, parent, fn, *args, **kwargs):
966 Task.__init__(self, parent)
968 self.fn = fn
969 self.args = args
970 self.kwargs = kwargs
972 def task(self):
973 return self.fn(*self.args, **self.kwargs)
976 class RunTask(QtCore.QObject):
977 """Runs QRunnable instances and transfers control when they finish"""
979 def __init__(self, parent=None):
980 QtCore.QObject.__init__(self, parent)
981 self.tasks = []
982 self.task_details = {}
983 self.threadpool = QtCore.QThreadPool.globalInstance()
984 self.result_fn = None
986 def start(self, task, progress=None, finish=None, result=None):
987 """Start the task and register a callback"""
988 self.result_fn = result
989 if progress is not None:
990 progress.show()
991 # prevents garbage collection bugs in certain PyQt4 versions
992 self.tasks.append(task)
993 task_id = id(task)
994 self.task_details[task_id] = (progress, finish, result)
995 task.channel.finished.connect(self.finish, type=Qt.QueuedConnection)
996 self.threadpool.start(task)
998 def finish(self, task):
999 task_id = id(task)
1000 try:
1001 self.tasks.remove(task)
1002 except ValueError:
1003 pass
1004 try:
1005 progress, finish, result = self.task_details[task_id]
1006 del self.task_details[task_id]
1007 except KeyError:
1008 finish = progress = result = None
1010 if progress is not None:
1011 progress.hide()
1013 if result is not None:
1014 result(task.result)
1016 if finish is not None:
1017 finish(task)
1020 # Syntax highlighting
1023 def rgb(r, g, b):
1024 color = QtGui.QColor()
1025 color.setRgb(r, g, b)
1026 return color
1029 def rgba(r, g, b, a=255):
1030 color = rgb(r, g, b)
1031 color.setAlpha(a)
1032 return color
1035 def RGB(args):
1036 return rgb(*args)
1039 def rgb_css(color):
1040 """Convert a QColor into an rgb(int, int, int) CSS string"""
1041 return 'rgb(%d, %d, %d)' % (color.red(), color.green(), color.blue())
1044 def rgb_hex(color):
1045 """Convert a QColor into a hex aabbcc string"""
1046 return '%02x%02x%02x' % (color.red(), color.green(), color.blue())
1049 def hsl(h, s, light):
1050 return QtGui.QColor.fromHslF(
1051 utils.clamp(h, 0.0, 1.0), utils.clamp(s, 0.0, 1.0), utils.clamp(light, 0.0, 1.0)
1055 def hsl_css(h, s, light):
1056 return rgb_css(hsl(h, s, light))
1059 def make_format(fg=None, bg=None, bold=False):
1060 fmt = QtGui.QTextCharFormat()
1061 if fg:
1062 fmt.setForeground(fg)
1063 if bg:
1064 fmt.setBackground(bg)
1065 if bold:
1066 fmt.setFontWeight(QtGui.QFont.Bold)
1067 return fmt
1070 class ImageFormats(object):
1071 def __init__(self):
1072 # returns a list of QByteArray objects
1073 formats_qba = QtGui.QImageReader.supportedImageFormats()
1074 # portability: python3 data() returns bytes, python2 returns str
1075 decode = core.decode
1076 formats = [decode(x.data()) for x in formats_qba]
1077 self.extensions = set(['.' + fmt for fmt in formats])
1079 def ok(self, filename):
1080 _, ext = os.path.splitext(filename)
1081 return ext.lower() in self.extensions
1084 def set_scrollbar_values(widget, hscroll_value, vscroll_value):
1085 """Set scrollbars to the specified values"""
1086 hscroll = widget.horizontalScrollBar()
1087 if hscroll and hscroll_value is not None:
1088 hscroll.setValue(hscroll_value)
1090 vscroll = widget.verticalScrollBar()
1091 if vscroll and vscroll_value is not None:
1092 vscroll.setValue(vscroll_value)
1095 def get_scrollbar_values(widget):
1096 """Return the current (hscroll, vscroll) scrollbar values for a widget"""
1097 hscroll = widget.horizontalScrollBar()
1098 if hscroll:
1099 hscroll_value = get(hscroll)
1100 else:
1101 hscroll_value = None
1102 vscroll = widget.verticalScrollBar()
1103 if vscroll:
1104 vscroll_value = get(vscroll)
1105 else:
1106 vscroll_value = None
1107 return (hscroll_value, vscroll_value)
1110 def scroll_to_item(widget, item):
1111 """Scroll to an item while retaining the horizontal scroll position"""
1112 hscroll = None
1113 hscrollbar = widget.horizontalScrollBar()
1114 if hscrollbar:
1115 hscroll = get(hscrollbar)
1116 widget.scrollToItem(item)
1117 if hscroll is not None:
1118 hscrollbar.setValue(hscroll)
1121 def select_item(widget, item):
1122 """Scroll to and make a QTreeWidget item selected and current"""
1123 scroll_to_item(widget, item)
1124 widget.setCurrentItem(item)
1125 item.setSelected(True)
1128 def get_selected_values(widget, top_level_idx, values):
1129 """Map the selected items under the top-level item to the values list"""
1130 # Get the top-level item
1131 item = widget.topLevelItem(top_level_idx)
1132 return tree_selection(item, values)
1135 def get_selected_items(widget, idx):
1136 """Return the selected items under the top-level item"""
1137 item = widget.topLevelItem(idx)
1138 return tree_selection_items(item)