qtutils: guard against unhashable widget types
[git-cola.git] / cola / qtutils.py
blob2a8c7d1cdd54b5c0e21d0c21b056e1cffd4c0088
1 # Copyright (C) 2007-2018 David Aguilar and contributors
2 """Miscellaneous Qt utility functions."""
3 from __future__ import division, absolute_import, unicode_literals
4 import os
6 from qtpy import compat
7 from qtpy import QtGui
8 from qtpy import QtCore
9 from qtpy import QtWidgets
10 from qtpy.QtCore import Qt
11 from qtpy.QtCore import Signal
13 from . import core
14 from . import hotkeys
15 from . import icons
16 from . import utils
17 from .i18n import N_
18 from .compat import int_types
19 from .compat import ustr
20 from .models import prefs
21 from .widgets import defs
24 STRETCH = object()
25 SKIPPED = object()
28 def active_window():
29 """Return the active window for the current application"""
30 return QtWidgets.QApplication.activeWindow()
33 def connect_action(action, fn):
34 """Connect an action to a function"""
35 action.triggered[bool].connect(lambda x: fn())
38 def connect_action_bool(action, fn):
39 """Connect a triggered(bool) action to a function"""
40 action.triggered[bool].connect(fn)
43 def connect_button(button, fn):
44 """Connect a button to a function"""
45 # Some versions of Qt send the `bool` argument to the clicked callback,
46 # and some do not. The lambda consumes all callback-provided arguments.
47 button.clicked.connect(lambda *args, **kwargs: fn())
50 def connect_checkbox(widget, fn):
51 """Connect a checkbox to a function taking bool"""
52 widget.clicked.connect(lambda *args, **kwargs: fn(get(checkbox)))
55 def connect_released(button, fn):
56 """Connect a button to a function"""
57 button.released.connect(fn)
60 def button_action(button, action):
61 """Make a button trigger an action"""
62 connect_button(button, action.trigger)
65 def connect_toggle(toggle, fn):
66 """Connect a toggle button to a function"""
67 toggle.toggled.connect(fn)
70 def disconnect(signal):
71 """Disconnect signal from all slots"""
72 try:
73 signal.disconnect()
74 except TypeError: # allow unconnected slots
75 pass
78 def get(widget):
79 """Query a widget for its python value"""
80 if hasattr(widget, 'isChecked'):
81 value = widget.isChecked()
82 elif hasattr(widget, 'value'):
83 value = widget.value()
84 elif hasattr(widget, 'text'):
85 value = widget.text()
86 elif hasattr(widget, 'toPlainText'):
87 value = widget.toPlainText()
88 elif hasattr(widget, 'sizes'):
89 value = widget.sizes()
90 elif hasattr(widget, 'date'):
91 value = widget.date().toString(Qt.ISODate)
92 else:
93 value = None
94 return value
97 def hbox(margin, spacing, *items):
98 """Create an HBoxLayout with the specified sizes and items"""
99 return box(QtWidgets.QHBoxLayout, margin, spacing, *items)
102 def vbox(margin, spacing, *items):
103 """Create a VBoxLayout with the specified sizes and items"""
104 return box(QtWidgets.QVBoxLayout, margin, spacing, *items)
107 def buttongroup(*items):
108 """Create a QButtonGroup for the specified items"""
109 group = QtWidgets.QButtonGroup()
110 for i in items:
111 group.addButton(i)
112 return group
115 def set_margin(layout, margin):
116 """Set the content margins for a layout"""
117 layout.setContentsMargins(margin, margin, margin, margin)
120 def box(cls, margin, spacing, *items):
121 """Create a QBoxLayout with the specified sizes and items"""
122 stretch = STRETCH
123 skipped = SKIPPED
124 layout = cls()
125 layout.setSpacing(spacing)
126 set_margin(layout, margin)
128 for i in items:
129 if isinstance(i, QtWidgets.QWidget):
130 layout.addWidget(i)
131 elif isinstance(
134 QtWidgets.QHBoxLayout,
135 QtWidgets.QVBoxLayout,
136 QtWidgets.QFormLayout,
137 QtWidgets.QLayout,
140 layout.addLayout(i)
141 elif i is stretch:
142 layout.addStretch()
143 elif i is skipped:
144 continue
145 elif isinstance(i, int_types):
146 layout.addSpacing(i)
148 return layout
151 def form(margin, spacing, *widgets):
152 """Create a QFormLayout with the specified sizes and items"""
153 layout = QtWidgets.QFormLayout()
154 layout.setSpacing(spacing)
155 layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.ExpandingFieldsGrow)
156 set_margin(layout, margin)
158 for idx, (name, widget) in enumerate(widgets):
159 if isinstance(name, (str, ustr)):
160 layout.addRow(name, widget)
161 else:
162 layout.setWidget(idx, QtWidgets.QFormLayout.LabelRole, name)
163 layout.setWidget(idx, QtWidgets.QFormLayout.FieldRole, widget)
165 return layout
168 def grid(margin, spacing, *widgets):
169 """Create a QGridLayout with the specified sizes and items"""
170 layout = QtWidgets.QGridLayout()
171 layout.setSpacing(spacing)
172 set_margin(layout, margin)
174 for row in widgets:
175 item = row[0]
176 if isinstance(item, QtWidgets.QWidget):
177 layout.addWidget(*row)
178 elif isinstance(item, QtWidgets.QLayoutItem):
179 layout.addItem(*row)
181 return layout
184 def splitter(orientation, *widgets):
185 """Create a spliter over the specified widgets
187 :param orientation: Qt.Horizontal or Qt.Vertical
190 layout = QtWidgets.QSplitter()
191 layout.setOrientation(orientation)
192 layout.setHandleWidth(defs.handle_width)
193 layout.setChildrenCollapsible(True)
195 for idx, widget in enumerate(widgets):
196 layout.addWidget(widget)
197 layout.setStretchFactor(idx, 1)
199 # Workaround for Qt not setting the WA_Hover property for QSplitter
200 # Cf. https://bugreports.qt.io/browse/QTBUG-13768
201 layout.handle(1).setAttribute(Qt.WA_Hover)
203 return layout
206 def label(text=None, align=None, fmt=None, selectable=True):
207 """Create a QLabel with the specified properties"""
208 widget = QtWidgets.QLabel()
209 if align is not None:
210 widget.setAlignment(align)
211 if fmt is not None:
212 widget.setTextFormat(fmt)
213 if selectable:
214 widget.setTextInteractionFlags(Qt.TextBrowserInteraction)
215 widget.setOpenExternalLinks(True)
216 if text:
217 widget.setText(text)
218 return widget
221 class ComboBox(QtWidgets.QComboBox):
222 """Custom read-only combobox with a convenient API"""
224 def __init__(self, items=None, editable=False, parent=None, transform=None):
225 super(ComboBox, self).__init__(parent)
226 self.setEditable(editable)
227 self.transform = transform
228 self.item_data = []
229 if items:
230 self.addItems(items)
231 self.item_data.extend(items)
233 def set_index(self, idx):
234 idx = utils.clamp(idx, 0, self.count() - 1)
235 self.setCurrentIndex(idx)
237 def add_item(self, text, data):
238 self.addItem(text)
239 self.item_data.append(data)
241 def current_data(self):
242 return self.item_data[self.currentIndex()]
244 def set_value(self, value):
245 if self.transform:
246 value = self.transform(value)
247 try:
248 index = self.item_data.index(value)
249 except ValueError:
250 index = 0
251 self.setCurrentIndex(index)
254 def combo(items, editable=False, parent=None):
255 """Create a readonly (by default) combobox from a list of items"""
256 return ComboBox(editable=editable, items=items, parent=parent)
259 def combo_mapped(data, editable=False, transform=None, parent=None):
260 """Create a readonly (by default) combobox from a list of items"""
261 widget = ComboBox(editable=editable, transform=transform, parent=parent)
262 for (k, v) in data:
263 widget.add_item(k, v)
264 return widget
267 def textbrowser(text=None):
268 """Create a QTextBrowser for the specified text"""
269 widget = QtWidgets.QTextBrowser()
270 widget.setOpenExternalLinks(True)
271 if text:
272 widget.setText(text)
273 return widget
276 def add_completer(widget, items):
277 """Add simple completion to a widget"""
278 completer = QtWidgets.QCompleter(items, widget)
279 completer.setCaseSensitivity(Qt.CaseInsensitive)
280 completer.setCompletionMode(QtWidgets.QCompleter.InlineCompletion)
281 widget.setCompleter(completer)
284 def prompt(msg, title=None, text='', parent=None):
285 """Presents the user with an input widget and returns the input."""
286 if title is None:
287 title = msg
288 if parent is None:
289 parent = active_window()
290 result = QtWidgets.QInputDialog.getText(
291 parent, title, msg, QtWidgets.QLineEdit.Normal, text
293 return (result[0], result[1])
296 def prompt_n(msg, inputs):
297 """Presents the user with N input widgets and returns the results"""
298 dialog = QtWidgets.QDialog(active_window())
299 dialog.setWindowModality(Qt.WindowModal)
300 dialog.setWindowTitle(msg)
302 long_value = msg
303 for k, v in inputs:
304 if len(k + v) > len(long_value):
305 long_value = k + v
307 metrics = QtGui.QFontMetrics(dialog.font())
308 min_width = metrics.width(long_value) + 100
309 if min_width > 720:
310 min_width = 720
311 dialog.setMinimumWidth(min_width)
313 ok_b = ok_button(msg, enabled=False)
314 close_b = close_button()
316 form_widgets = []
318 def get_values():
319 return [pair[1].text().strip() for pair in form_widgets]
321 for name, value in inputs:
322 lineedit = QtWidgets.QLineEdit()
323 # Enable the OK button only when all fields have been populated
324 # pylint: disable=no-member
325 lineedit.textChanged.connect(lambda x: ok_b.setEnabled(all(get_values())))
326 if value:
327 lineedit.setText(value)
328 form_widgets.append((name, lineedit))
330 # layouts
331 form_layout = form(defs.no_margin, defs.button_spacing, *form_widgets)
332 button_layout = hbox(defs.no_margin, defs.button_spacing, STRETCH, close_b, ok_b)
333 main_layout = vbox(defs.margin, defs.button_spacing, form_layout, button_layout)
334 dialog.setLayout(main_layout)
336 # connections
337 connect_button(ok_b, dialog.accept)
338 connect_button(close_b, dialog.reject)
340 accepted = dialog.exec_() == QtWidgets.QDialog.Accepted
341 text = get_values()
342 ok = accepted and all(text)
343 return (ok, text)
346 class TreeWidgetItem(QtWidgets.QTreeWidgetItem):
348 TYPE = QtGui.QStandardItem.UserType + 101
350 def __init__(self, path, icon, deleted):
351 QtWidgets.QTreeWidgetItem.__init__(self)
352 self.path = path
353 self.deleted = deleted
354 self.setIcon(0, icons.from_name(icon))
355 self.setText(0, path)
357 def type(self):
358 return self.TYPE
361 def paths_from_indexes(model, indexes, item_type=TreeWidgetItem.TYPE, item_filter=None):
362 """Return paths from a list of QStandardItemModel indexes"""
363 items = [model.itemFromIndex(i) for i in indexes]
364 return paths_from_items(items, item_type=item_type, item_filter=item_filter)
367 def _true_filter(_x):
368 return True
371 def paths_from_items(items, item_type=TreeWidgetItem.TYPE, item_filter=None):
372 """Return a list of paths from a list of items"""
373 if item_filter is None:
374 item_filter = _true_filter
375 return [i.path for i in items if i.type() == item_type and item_filter(i)]
378 def tree_selection(tree_item, items):
379 """Returns an array of model items that correspond to the selected
380 QTreeWidgetItem children"""
381 selected = []
382 count = min(tree_item.childCount(), len(items))
383 for idx in range(count):
384 if tree_item.child(idx).isSelected():
385 selected.append(items[idx])
387 return selected
390 def tree_selection_items(tree_item):
391 """Returns selected widget items"""
392 selected = []
393 for idx in range(tree_item.childCount()):
394 child = tree_item.child(idx)
395 if child.isSelected():
396 selected.append(child)
398 return selected
401 def selected_item(list_widget, items):
402 """Returns the model item that corresponds to the selected QListWidget
403 row."""
404 widget_items = list_widget.selectedItems()
405 if not widget_items:
406 return None
407 widget_item = widget_items[0]
408 row = list_widget.row(widget_item)
409 if row < len(items):
410 item = items[row]
411 else:
412 item = None
413 return item
416 def selected_items(list_widget, items):
417 """Returns an array of model items that correspond to the selected
418 QListWidget rows."""
419 item_count = len(items)
420 selected = []
421 for widget_item in list_widget.selectedItems():
422 row = list_widget.row(widget_item)
423 if row < item_count:
424 selected.append(items[row])
425 return selected
428 def open_file(title, directory=None):
429 """Creates an Open File dialog and returns a filename."""
430 result = compat.getopenfilename(
431 parent=active_window(), caption=title, basedir=directory
433 return result[0]
436 def open_files(title, directory=None, filters=''):
437 """Creates an Open File dialog and returns a list of filenames."""
438 result = compat.getopenfilenames(
439 parent=active_window(), caption=title, basedir=directory, filters=filters
441 return result[0]
444 def opendir_dialog(caption, path):
445 """Prompts for a directory path"""
447 options = (
448 QtWidgets.QFileDialog.ShowDirsOnly | QtWidgets.QFileDialog.DontResolveSymlinks
450 return compat.getexistingdirectory(
451 parent=active_window(), caption=caption, basedir=path, options=options
455 def save_as(filename, title='Save As...'):
456 """Creates a Save File dialog and returns a filename."""
457 result = compat.getsavefilename(
458 parent=active_window(), caption=title, basedir=filename
460 return result[0]
463 def copy_path(filename, absolute=True):
464 """Copy a filename to the clipboard"""
465 if filename is None:
466 return
467 if absolute:
468 filename = core.abspath(filename)
469 set_clipboard(filename)
472 def set_clipboard(text):
473 """Sets the copy/paste buffer to text."""
474 if not text:
475 return
476 clipboard = QtWidgets.QApplication.clipboard()
477 clipboard.setText(text, QtGui.QClipboard.Clipboard)
478 if not utils.is_darwin() and not utils.is_win32():
479 clipboard.setText(text, QtGui.QClipboard.Selection)
480 persist_clipboard()
483 # pylint: disable=line-too-long
484 def persist_clipboard():
485 """Persist the clipboard
487 X11 stores only a reference to the clipboard data.
488 Send a clipboard event to force a copy of the clipboard to occur.
489 This ensures that the clipboard is present after git-cola exits.
490 Otherwise, the reference is destroyed on exit.
492 C.f. https://stackoverflow.com/questions/2007103/how-can-i-disable-clear-of-clipboard-on-exit-of-pyqt4-application
494 """ # noqa
495 clipboard = QtWidgets.QApplication.clipboard()
496 event = QtCore.QEvent(QtCore.QEvent.Clipboard)
497 QtWidgets.QApplication.sendEvent(clipboard, event)
500 def add_action_bool(widget, text, fn, checked, *shortcuts):
501 tip = text
502 action = _add_action(widget, text, tip, fn, connect_action_bool, *shortcuts)
503 action.setCheckable(True)
504 action.setChecked(checked)
505 return action
508 def add_action(widget, text, fn, *shortcuts):
509 tip = text
510 return _add_action(widget, text, tip, fn, connect_action, *shortcuts)
513 def add_action_with_status_tip(widget, text, tip, fn, *shortcuts):
514 return _add_action(widget, text, tip, fn, connect_action, *shortcuts)
517 def _add_action(widget, text, tip, fn, connect, *shortcuts):
518 action = QtWidgets.QAction(text, widget)
519 if hasattr(action, 'setIconVisibleInMenu'):
520 action.setIconVisibleInMenu(True)
521 if tip:
522 action.setStatusTip(tip)
523 connect(action, fn)
524 if shortcuts:
525 action.setShortcuts(shortcuts)
526 if hasattr(Qt, 'WidgetWithChildrenShortcut'):
527 action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
528 widget.addAction(action)
529 return action
532 def set_selected_item(widget, idx):
533 """Sets a the currently selected item to the item at index idx."""
534 if isinstance(widget, QtWidgets.QTreeWidget):
535 item = widget.topLevelItem(idx)
536 if item:
537 item.setSelected(True)
538 widget.setCurrentItem(item)
541 def add_items(widget, items):
542 """Adds items to a widget."""
543 for item in items:
544 if item is None:
545 continue
546 widget.addItem(item)
549 def set_items(widget, items):
550 """Clear the existing widget contents and set the new items."""
551 widget.clear()
552 add_items(widget, items)
555 def create_treeitem(filename, staged=False, deleted=False, untracked=False):
556 """Given a filename, return a TreeWidgetItem for a status widget
558 "staged", "deleted, and "untracked" control which icon is used.
561 icon_name = icons.status(filename, deleted, staged, untracked)
562 icon = icons.name_from_basename(icon_name)
563 return TreeWidgetItem(filename, icon, deleted=deleted)
566 def add_close_action(widget):
567 """Adds close action and shortcuts to a widget."""
568 return add_action(widget, N_('Close...'), widget.close, hotkeys.CLOSE, hotkeys.QUIT)
571 def app():
572 """Return the current application"""
573 return QtWidgets.QApplication.instance()
576 def desktop():
577 """Return the desktop"""
578 return app().desktop()
581 def desktop_size():
582 desk = desktop()
583 rect = desk.screenGeometry(QtGui.QCursor().pos())
584 return (rect.width(), rect.height())
587 def center_on_screen(widget):
588 """Move widget to the center of the default screen"""
589 width, height = desktop_size()
590 cx = width // 2
591 cy = height // 2
592 widget.move(cx - widget.width() // 2, cy - widget.height() // 2)
595 def default_size(parent, width, height, use_parent_height=True):
596 """Return the parent's size, or the provided defaults"""
597 if parent is not None:
598 width = parent.width()
599 if use_parent_height:
600 height = parent.height()
601 return (width, height)
604 def default_monospace_font():
605 if utils.is_darwin():
606 family = 'Monaco'
607 else:
608 family = 'Monospace'
609 mfont = QtGui.QFont()
610 mfont.setFamily(family)
611 return mfont
614 def diff_font_str(context):
615 cfg = context.cfg
616 font_str = cfg.get(prefs.FONTDIFF)
617 if not font_str:
618 font_str = default_monospace_font().toString()
619 return font_str
622 def diff_font(context):
623 return font(diff_font_str(context))
626 def font(string):
627 qfont = QtGui.QFont()
628 qfont.fromString(string)
629 return qfont
632 def create_button(
633 text='', layout=None, tooltip=None, icon=None, enabled=True, default=False
635 """Create a button, set its title, and add it to the parent."""
636 button = QtWidgets.QPushButton()
637 button.setCursor(Qt.PointingHandCursor)
638 button.setFocusPolicy(Qt.NoFocus)
639 if text:
640 button.setText(' ' + text)
641 if icon is not None:
642 button.setIcon(icon)
643 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
644 if tooltip is not None:
645 button.setToolTip(tooltip)
646 if layout is not None:
647 layout.addWidget(button)
648 if not enabled:
649 button.setEnabled(False)
650 if default:
651 button.setDefault(True)
652 return button
655 def tool_button():
656 """Create a flat border-less button"""
657 button = QtWidgets.QToolButton()
658 button.setPopupMode(QtWidgets.QToolButton.InstantPopup)
659 button.setCursor(Qt.PointingHandCursor)
660 button.setFocusPolicy(Qt.NoFocus)
661 # Highlight colors
662 palette = QtGui.QPalette()
663 highlight = palette.color(QtGui.QPalette.Highlight)
664 highlight_rgb = rgb_css(highlight)
666 button.setStyleSheet(
668 /* No borders */
669 QToolButton {
670 border: none;
671 background-color: none;
673 /* Hide the menu indicator */
674 QToolButton::menu-indicator {
675 image: none;
677 QToolButton:hover {
678 border: %(border)spx solid %(highlight_rgb)s;
681 % dict(border=defs.border, highlight_rgb=highlight_rgb)
683 return button
686 def create_action_button(tooltip=None, icon=None):
687 """Create a small toolbutton for use in dock title widgets"""
688 button = tool_button()
689 if tooltip is not None:
690 button.setToolTip(tooltip)
691 if icon is not None:
692 button.setIcon(icon)
693 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
694 return button
697 def ok_button(text, default=True, enabled=True, icon=None):
698 if icon is None:
699 icon = icons.ok()
700 return create_button(text=text, icon=icon, default=default, enabled=enabled)
703 def close_button(text=None, icon=None):
704 text = text or N_('Close')
705 icon = icons.mkicon(icon, icons.close)
706 return create_button(text=text, icon=icon)
709 def edit_button(enabled=True, default=False):
710 return create_button(
711 text=N_('Edit'), icon=icons.edit(), enabled=enabled, default=default
715 def refresh_button(enabled=True, default=False):
716 return create_button(
717 text=N_('Refresh'), icon=icons.sync(), enabled=enabled, default=default
721 def checkbox(text='', tooltip='', checked=None):
722 """Create a checkbox"""
723 return _checkbox(QtWidgets.QCheckBox, text, tooltip, checked)
726 def radio(text='', tooltip='', checked=None):
727 """Create a radio button"""
728 return _checkbox(QtWidgets.QRadioButton, text, tooltip, checked)
731 def _checkbox(cls, text, tooltip, checked):
732 """Create a widget and apply properties"""
733 widget = cls()
734 if text:
735 widget.setText(text)
736 if tooltip:
737 widget.setToolTip(tooltip)
738 if checked is not None:
739 widget.setChecked(checked)
740 return widget
743 class DockTitleBarWidget(QtWidgets.QFrame):
744 def __init__(self, parent, title, stretch=True):
745 QtWidgets.QFrame.__init__(self, parent)
746 self.setAutoFillBackground(True)
747 self.label = qlabel = QtWidgets.QLabel(title, self)
748 qfont = qlabel.font()
749 qfont.setBold(True)
750 qlabel.setFont(qfont)
751 qlabel.setCursor(Qt.OpenHandCursor)
753 self.close_button = create_action_button(
754 tooltip=N_('Close'), icon=icons.close()
757 self.toggle_button = create_action_button(
758 tooltip=N_('Detach'), icon=icons.external()
761 self.corner_layout = hbox(defs.no_margin, defs.spacing)
763 if stretch:
764 separator = STRETCH
765 else:
766 separator = SKIPPED
768 self.main_layout = hbox(
769 defs.small_margin,
770 defs.titlebar_spacing,
771 qlabel,
772 separator,
773 self.corner_layout,
774 self.toggle_button,
775 self.close_button,
777 self.setLayout(self.main_layout)
779 connect_button(self.toggle_button, self.toggle_floating)
780 connect_button(self.close_button, self.toggle_visibility)
782 def toggle_floating(self):
783 self.parent().setFloating(not self.parent().isFloating())
784 self.update_tooltips()
786 def toggle_visibility(self):
787 self.parent().toggleViewAction().trigger()
789 def set_title(self, title):
790 self.label.setText(title)
792 def add_corner_widget(self, widget):
793 self.corner_layout.addWidget(widget)
795 def update_tooltips(self):
796 if self.parent().isFloating():
797 tooltip = N_('Attach')
798 else:
799 tooltip = N_('Detach')
800 self.toggle_button.setToolTip(tooltip)
803 def create_dock(title, parent, stretch=True, widget=None, fn=None):
804 """Create a dock widget and set it up accordingly."""
805 dock = QtWidgets.QDockWidget(parent)
806 dock.setWindowTitle(title)
807 dock.setObjectName(title)
808 titlebar = DockTitleBarWidget(dock, title, stretch=stretch)
809 dock.setTitleBarWidget(titlebar)
810 dock.setAutoFillBackground(True)
811 if hasattr(parent, 'dockwidgets'):
812 parent.dockwidgets.append(dock)
813 if fn:
814 widget = fn(dock)
815 assert isinstance(widget, QtWidgets.QFrame), "Docked widget has to be a QFrame"
816 if widget:
817 dock.setWidget(widget)
818 return dock
821 def hide_dock(widget):
822 widget.toggleViewAction().setChecked(False)
823 widget.hide()
826 def create_menu(title, parent):
827 """Create a menu and set its title."""
828 qmenu = DebouncingMenu(title, parent)
829 return qmenu
832 class DebouncingMenu(QtWidgets.QMenu):
833 """Menu that debounces mouse release action ie. stops it if occurred
834 right after menu creation.
836 Disables annoying behaviour when RMB is pressed to show menu, cursor is
837 moved accidentally 1px onto newly created menu and released causing to
838 execute menu action
841 threshold_ms = 400
843 def __init__(self, title, parent):
844 QtWidgets.QMenu.__init__(self, title, parent)
845 self.created_at = utils.epoch_millis()
846 if hasattr(self, 'setToolTipsVisible'):
847 self.setToolTipsVisible(True)
849 def mouseReleaseEvent(self, event):
850 threshold = DebouncingMenu.threshold_ms
851 if (utils.epoch_millis() - self.created_at) > threshold:
852 QtWidgets.QMenu.mouseReleaseEvent(self, event)
855 def add_menu(title, parent):
856 """Create a menu and set its title."""
857 menu = create_menu(title, parent)
858 if hasattr(parent, 'addMenu'):
859 parent.addMenu(menu)
860 else:
861 parent.addAction(menu.menuAction())
862 return menu
865 def create_toolbutton(text=None, layout=None, tooltip=None, icon=None):
866 button = tool_button()
867 if icon is not None:
868 button.setIcon(icon)
869 button.setIconSize(QtCore.QSize(defs.default_icon, defs.default_icon))
870 if text is not None:
871 button.setText(' ' + text)
872 button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
873 if tooltip is not None:
874 button.setToolTip(tooltip)
875 if layout is not None:
876 layout.addWidget(button)
877 return button
880 # pylint: disable=line-too-long
881 def mimedata_from_paths(context, paths):
882 """Return mimedata with a list of absolute path URLs
884 The text/x-moz-list format is always included by Qt, and doing
885 mimedata.removeFormat('text/x-moz-url') has no effect.
886 C.f. http://www.qtcentre.org/threads/44643-Dragging-text-uri-list-Qt-inserts-garbage
888 gnome-terminal expects utf-16 encoded text, but other terminals,
889 e.g. terminator, prefer utf-8, so allow cola.dragencoding
890 to override the default.
892 """ # noqa
893 cfg = context.cfg
894 abspaths = [core.abspath(path) for path in paths]
895 urls = [QtCore.QUrl.fromLocalFile(path) for path in abspaths]
897 mimedata = QtCore.QMimeData()
898 mimedata.setUrls(urls)
900 paths_text = core.list2cmdline(abspaths)
901 encoding = cfg.get('cola.dragencoding', 'utf-16')
902 moz_text = core.encode(paths_text, encoding=encoding)
903 mimedata.setData('text/x-moz-url', moz_text)
905 return mimedata
908 def path_mimetypes():
909 return ['text/uri-list', 'text/x-moz-url']
912 class BlockSignals(object):
913 """Context manager for blocking a signals on a widget"""
915 def __init__(self, *widgets):
916 self.widgets = widgets
917 self.values = {}
919 def __enter__(self):
920 """Block Qt signals for all of the captured widgets"""
921 for w in self.widgets:
922 self.values[id(w)] = w.blockSignals(True)
923 return self
925 def __exit__(self, exc_type, exc_val, exc_tb):
926 """Restore Qt signals when we exit the scope"""
927 for w in self.widgets:
928 w.blockSignals(self.values[id(w)])
931 class Channel(QtCore.QObject):
932 finished = Signal(object)
933 result = Signal(object)
936 class Task(QtCore.QRunnable):
937 """Disable auto-deletion to avoid gc issues
939 Python's garbage collector will try to double-free the task
940 once it's finished, so disable Qt's auto-deletion as a workaround.
944 def __init__(self, parent):
945 QtCore.QRunnable.__init__(self)
947 self.channel = Channel(parent)
948 self.result = None
949 self.setAutoDelete(False)
951 def run(self):
952 self.result = self.task()
953 self.channel.result.emit(self.result)
954 self.channel.finished.emit(self)
956 # pylint: disable=no-self-use
957 def task(self):
958 return None
960 def connect(self, handler):
961 self.channel.result.connect(handler, type=Qt.QueuedConnection)
964 class SimpleTask(Task):
965 """Run a simple callable as a task"""
967 def __init__(self, parent, fn, *args, **kwargs):
968 Task.__init__(self, parent)
970 self.fn = fn
971 self.args = args
972 self.kwargs = kwargs
974 def task(self):
975 return self.fn(*self.args, **self.kwargs)
978 class RunTask(QtCore.QObject):
979 """Runs QRunnable instances and transfers control when they finish"""
981 def __init__(self, parent=None):
982 QtCore.QObject.__init__(self, parent)
983 self.tasks = []
984 self.task_details = {}
985 self.threadpool = QtCore.QThreadPool.globalInstance()
986 self.result_fn = None
988 def start(self, task, progress=None, finish=None, result=None):
989 """Start the task and register a callback"""
990 self.result_fn = result
991 if progress is not None:
992 progress.show()
993 # prevents garbage collection bugs in certain PyQt4 versions
994 self.tasks.append(task)
995 task_id = id(task)
996 self.task_details[task_id] = (progress, finish, result)
997 task.channel.finished.connect(self.finish, type=Qt.QueuedConnection)
998 self.threadpool.start(task)
1000 def finish(self, task):
1001 task_id = id(task)
1002 try:
1003 self.tasks.remove(task)
1004 except ValueError:
1005 pass
1006 try:
1007 progress, finish, result = self.task_details[task_id]
1008 del self.task_details[task_id]
1009 except KeyError:
1010 finish = progress = result = None
1012 if progress is not None:
1013 progress.hide()
1015 if result is not None:
1016 result(task.result)
1018 if finish is not None:
1019 finish(task)
1022 # Syntax highlighting
1025 def rgb(r, g, b):
1026 color = QtGui.QColor()
1027 color.setRgb(r, g, b)
1028 return color
1031 def rgba(r, g, b, a=255):
1032 color = rgb(r, g, b)
1033 color.setAlpha(a)
1034 return color
1037 def RGB(args):
1038 return rgb(*args)
1041 def rgb_css(color):
1042 """Convert a QColor into an rgb(int, int, int) CSS string"""
1043 return 'rgb(%d, %d, %d)' % (color.red(), color.green(), color.blue())
1046 def rgb_hex(color):
1047 """Convert a QColor into a hex aabbcc string"""
1048 return '%02x%02x%02x' % (color.red(), color.green(), color.blue())
1051 def hsl(h, s, light):
1052 return QtGui.QColor.fromHslF(
1053 utils.clamp(h, 0.0, 1.0), utils.clamp(s, 0.0, 1.0), utils.clamp(light, 0.0, 1.0)
1057 def hsl_css(h, s, light):
1058 return rgb_css(hsl(h, s, light))
1061 def make_format(fg=None, bg=None, bold=False):
1062 fmt = QtGui.QTextCharFormat()
1063 if fg:
1064 fmt.setForeground(fg)
1065 if bg:
1066 fmt.setBackground(bg)
1067 if bold:
1068 fmt.setFontWeight(QtGui.QFont.Bold)
1069 return fmt
1072 class ImageFormats(object):
1073 def __init__(self):
1074 # returns a list of QByteArray objects
1075 formats_qba = QtGui.QImageReader.supportedImageFormats()
1076 # portability: python3 data() returns bytes, python2 returns str
1077 decode = core.decode
1078 formats = [decode(x.data()) for x in formats_qba]
1079 self.extensions = set(['.' + fmt for fmt in formats])
1081 def ok(self, filename):
1082 _, ext = os.path.splitext(filename)
1083 return ext.lower() in self.extensions