widgets: pylint tweaks, docstrings and use queued connections
[git-cola.git] / cola / qtutils.py
blob480bded9372eb529bb103577281648b1c63933cf
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(), type=Qt.QueuedConnection)
37 def connect_action_bool(action, fn):
38 """Connect a triggered(bool) action to a function"""
39 action.triggered[bool].connect(fn, type=Qt.QueuedConnection)
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(), type=Qt.QueuedConnection)
49 def connect_checkbox(widget, fn):
50 """Connect a checkbox to a function taking bool"""
51 widget.clicked.connect(
52 lambda *args, **kwargs: fn(get(checkbox)), type=Qt.QueuedConnection
56 def connect_released(button, fn):
57 """Connect a button to a function"""
58 button.released.connect(fn, type=Qt.QueuedConnection)
61 def button_action(button, action):
62 """Make a button trigger an action"""
63 connect_button(button, action.trigger)
66 def connect_toggle(toggle, fn):
67 """Connect a toggle button to a function"""
68 toggle.toggled.connect(fn, type=Qt.QueuedConnection)
71 def disconnect(signal):
72 """Disconnect signal from all slots"""
73 try:
74 signal.disconnect()
75 except TypeError: # allow unconnected slots
76 pass
79 def get(widget, default=None):
80 """Query a widget for its python value"""
81 if hasattr(widget, 'isChecked'):
82 value = widget.isChecked()
83 elif hasattr(widget, 'value'):
84 value = widget.value()
85 elif hasattr(widget, 'text'):
86 value = widget.text()
87 elif hasattr(widget, 'toPlainText'):
88 value = widget.toPlainText()
89 elif hasattr(widget, 'sizes'):
90 value = widget.sizes()
91 elif hasattr(widget, 'date'):
92 value = widget.date().toString(Qt.ISODate)
93 else:
94 value = default
95 return value
98 def hbox(margin, spacing, *items):
99 """Create an HBoxLayout with the specified sizes and items"""
100 return box(QtWidgets.QHBoxLayout, margin, spacing, *items)
103 def vbox(margin, spacing, *items):
104 """Create a VBoxLayout with the specified sizes and items"""
105 return box(QtWidgets.QVBoxLayout, margin, spacing, *items)
108 def buttongroup(*items):
109 """Create a QButtonGroup for the specified items"""
110 group = QtWidgets.QButtonGroup()
111 for i in items:
112 group.addButton(i)
113 return group
116 def set_margin(layout, margin):
117 """Set the content margins for a layout"""
118 layout.setContentsMargins(margin, margin, margin, margin)
121 def box(cls, margin, spacing, *items):
122 """Create a QBoxLayout with the specified sizes and items"""
123 stretch = STRETCH
124 skipped = SKIPPED
125 layout = cls()
126 layout.setSpacing(spacing)
127 set_margin(layout, margin)
129 for i in items:
130 if isinstance(i, QtWidgets.QWidget):
131 layout.addWidget(i)
132 elif isinstance(
135 QtWidgets.QHBoxLayout,
136 QtWidgets.QVBoxLayout,
137 QtWidgets.QFormLayout,
138 QtWidgets.QLayout,
141 layout.addLayout(i)
142 elif i is stretch:
143 layout.addStretch()
144 elif i is skipped:
145 continue
146 elif isinstance(i, int_types):
147 layout.addSpacing(i)
149 return layout
152 def form(margin, spacing, *widgets):
153 """Create a QFormLayout with the specified sizes and items"""
154 layout = QtWidgets.QFormLayout()
155 layout.setSpacing(spacing)
156 layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.ExpandingFieldsGrow)
157 set_margin(layout, margin)
159 for idx, (name, widget) in enumerate(widgets):
160 if isinstance(name, (str, ustr)):
161 layout.addRow(name, widget)
162 else:
163 layout.setWidget(idx, QtWidgets.QFormLayout.LabelRole, name)
164 layout.setWidget(idx, QtWidgets.QFormLayout.FieldRole, widget)
166 return layout
169 def grid(margin, spacing, *widgets):
170 """Create a QGridLayout with the specified sizes and items"""
171 layout = QtWidgets.QGridLayout()
172 layout.setSpacing(spacing)
173 set_margin(layout, margin)
175 for row in widgets:
176 item = row[0]
177 if isinstance(item, QtWidgets.QWidget):
178 layout.addWidget(*row)
179 elif isinstance(item, QtWidgets.QLayoutItem):
180 layout.addItem(*row)
182 return layout
185 def splitter(orientation, *widgets):
186 """Create a spliter over the specified widgets
188 :param orientation: Qt.Horizontal or Qt.Vertical
191 layout = QtWidgets.QSplitter()
192 layout.setOrientation(orientation)
193 layout.setHandleWidth(defs.handle_width)
194 layout.setChildrenCollapsible(True)
196 for idx, widget in enumerate(widgets):
197 layout.addWidget(widget)
198 layout.setStretchFactor(idx, 1)
200 # Workaround for Qt not setting the WA_Hover property for QSplitter
201 # Cf. https://bugreports.qt.io/browse/QTBUG-13768
202 layout.handle(1).setAttribute(Qt.WA_Hover)
204 return layout
207 def label(text=None, align=None, fmt=None, selectable=True):
208 """Create a QLabel with the specified properties"""
209 widget = QtWidgets.QLabel()
210 if align is not None:
211 widget.setAlignment(align)
212 if fmt is not None:
213 widget.setTextFormat(fmt)
214 if selectable:
215 widget.setTextInteractionFlags(Qt.TextBrowserInteraction)
216 widget.setOpenExternalLinks(True)
217 if text:
218 widget.setText(text)
219 return widget
222 class ComboBox(QtWidgets.QComboBox):
223 """Custom read-only combobox with a convenient API"""
225 def __init__(self, items=None, editable=False, parent=None, transform=None):
226 super(ComboBox, self).__init__(parent)
227 self.setEditable(editable)
228 self.transform = transform
229 self.item_data = []
230 if items:
231 self.addItems(items)
232 self.item_data.extend(items)
234 def set_index(self, idx):
235 idx = utils.clamp(idx, 0, self.count() - 1)
236 self.setCurrentIndex(idx)
238 def add_item(self, text, data):
239 self.addItem(text)
240 self.item_data.append(data)
242 def current_data(self):
243 return self.item_data[self.currentIndex()]
245 def set_value(self, value):
246 if self.transform:
247 value = self.transform(value)
248 try:
249 index = self.item_data.index(value)
250 except ValueError:
251 index = 0
252 self.setCurrentIndex(index)
255 def combo(items, editable=False, parent=None):
256 """Create a readonly (by default) combobox from a list of items"""
257 return ComboBox(editable=editable, items=items, parent=parent)
260 def combo_mapped(data, editable=False, transform=None, parent=None):
261 """Create a readonly (by default) combobox from a list of items"""
262 widget = ComboBox(editable=editable, transform=transform, parent=parent)
263 for (k, v) in data:
264 widget.add_item(k, v)
265 return widget
268 def textbrowser(text=None):
269 """Create a QTextBrowser for the specified text"""
270 widget = QtWidgets.QTextBrowser()
271 widget.setOpenExternalLinks(True)
272 if text:
273 widget.setText(text)
274 return widget
277 def add_completer(widget, items):
278 """Add simple completion to a widget"""
279 completer = QtWidgets.QCompleter(items, widget)
280 completer.setCaseSensitivity(Qt.CaseInsensitive)
281 completer.setCompletionMode(QtWidgets.QCompleter.InlineCompletion)
282 widget.setCompleter(completer)
285 def prompt(msg, title=None, text='', parent=None):
286 """Presents the user with an input widget and returns the input."""
287 if title is None:
288 title = msg
289 if parent is None:
290 parent = active_window()
291 result = QtWidgets.QInputDialog.getText(
292 parent, title, msg, QtWidgets.QLineEdit.Normal, text
294 return (result[0], result[1])
297 def prompt_n(msg, inputs):
298 """Presents the user with N input widgets and returns the results"""
299 dialog = QtWidgets.QDialog(active_window())
300 dialog.setWindowModality(Qt.WindowModal)
301 dialog.setWindowTitle(msg)
303 long_value = msg
304 for k, v in inputs:
305 if len(k + v) > len(long_value):
306 long_value = k + v
308 metrics = QtGui.QFontMetrics(dialog.font())
309 min_width = min(720, metrics.width(long_value) + 100)
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(
325 lambda x: ok_b.setEnabled(all(get_values())), type=Qt.QueuedConnection
327 if value:
328 lineedit.setText(value)
329 form_widgets.append((name, lineedit))
331 # layouts
332 form_layout = form(defs.no_margin, defs.button_spacing, *form_widgets)
333 button_layout = hbox(defs.no_margin, defs.button_spacing, STRETCH, close_b, ok_b)
334 main_layout = vbox(defs.margin, defs.button_spacing, form_layout, button_layout)
335 dialog.setLayout(main_layout)
337 # connections
338 connect_button(ok_b, dialog.accept)
339 connect_button(close_b, dialog.reject)
341 accepted = dialog.exec_() == QtWidgets.QDialog.Accepted
342 text = get_values()
343 ok = accepted and all(text)
344 return (ok, text)
347 def standard_item_type_value(value):
348 """Return a custom UserType for use in QTreeWidgetItem.type() overrides"""
349 return custom_item_type_value(QtGui.QStandardItem, value)
352 def graphics_item_type_value(value):
353 """Return a custom UserType for use in QGraphicsItem.type() overrides"""
354 return custom_item_type_value(QtWidgets.QGraphicsItem, value)
357 def custom_item_type_value(cls, value):
358 """Return a custom cls.UserType for use in cls.type() overrides"""
359 user_type = enum_value(cls.UserType)
360 return user_type + value
363 def enum_value(value):
364 """Qt6 has enums with an inner '.value' attribute."""
365 if hasattr(value, 'value'):
366 value = value.value
367 return value
370 class TreeWidgetItem(QtWidgets.QTreeWidgetItem):
372 TYPE = standard_item_type_value(101)
374 def __init__(self, path, icon, deleted):
375 QtWidgets.QTreeWidgetItem.__init__(self)
376 self.path = path
377 self.deleted = deleted
378 self.setIcon(0, icons.from_name(icon))
379 self.setText(0, path)
381 def type(self):
382 return self.TYPE
385 def paths_from_indexes(model, indexes, item_type=TreeWidgetItem.TYPE, item_filter=None):
386 """Return paths from a list of QStandardItemModel indexes"""
387 items = [model.itemFromIndex(i) for i in indexes]
388 return paths_from_items(items, item_type=item_type, item_filter=item_filter)
391 def _true_filter(_x):
392 return True
395 def paths_from_items(items, item_type=TreeWidgetItem.TYPE, item_filter=None):
396 """Return a list of paths from a list of items"""
397 if item_filter is None:
398 item_filter = _true_filter
399 return [i.path for i in items if i.type() == item_type and item_filter(i)]
402 def tree_selection(tree_item, items):
403 """Returns an array of model items that correspond to the selected
404 QTreeWidgetItem children"""
405 selected = []
406 count = min(tree_item.childCount(), len(items))
407 for idx in range(count):
408 if tree_item.child(idx).isSelected():
409 selected.append(items[idx])
411 return selected
414 def tree_selection_items(tree_item):
415 """Returns selected widget items"""
416 selected = []
417 for idx in range(tree_item.childCount()):
418 child = tree_item.child(idx)
419 if child.isSelected():
420 selected.append(child)
422 return selected
425 def selected_item(list_widget, items):
426 """Returns the model item that corresponds to the selected QListWidget
427 row."""
428 widget_items = list_widget.selectedItems()
429 if not widget_items:
430 return None
431 widget_item = widget_items[0]
432 row = list_widget.row(widget_item)
433 if row < len(items):
434 item = items[row]
435 else:
436 item = None
437 return item
440 def selected_items(list_widget, items):
441 """Returns an array of model items that correspond to the selected
442 QListWidget rows."""
443 item_count = len(items)
444 selected = []
445 for widget_item in list_widget.selectedItems():
446 row = list_widget.row(widget_item)
447 if row < item_count:
448 selected.append(items[row])
449 return selected
452 def open_file(title, directory=None):
453 """Creates an Open File dialog and returns a filename."""
454 result = compat.getopenfilename(
455 parent=active_window(), caption=title, basedir=directory
457 return result[0]
460 def open_files(title, directory=None, filters=''):
461 """Creates an Open File dialog and returns a list of filenames."""
462 result = compat.getopenfilenames(
463 parent=active_window(), caption=title, basedir=directory, filters=filters
465 return result[0]
468 def opendir_dialog(caption, path):
469 """Prompts for a directory path"""
470 options = (
471 QtWidgets.QFileDialog.Directory
472 | QtWidgets.QFileDialog.DontResolveSymlinks
473 | QtWidgets.QFileDialog.ReadOnly
474 | QtWidgets.QFileDialog.ShowDirsOnly
476 return compat.getexistingdirectory(
477 parent=active_window(), caption=caption, basedir=path, options=options
481 def save_as(filename, title='Save As...'):
482 """Creates a Save File dialog and returns a filename."""
483 result = compat.getsavefilename(
484 parent=active_window(), caption=title, basedir=filename
486 return result[0]
489 def copy_path(filename, absolute=True):
490 """Copy a filename to the clipboard"""
491 if filename is None:
492 return
493 if absolute:
494 filename = core.abspath(filename)
495 set_clipboard(filename)
498 def set_clipboard(text):
499 """Sets the copy/paste buffer to text."""
500 if not text:
501 return
502 clipboard = QtWidgets.QApplication.clipboard()
503 clipboard.setText(text, QtGui.QClipboard.Clipboard)
504 if not utils.is_darwin() and not utils.is_win32():
505 clipboard.setText(text, QtGui.QClipboard.Selection)
506 persist_clipboard()
509 # pylint: disable=line-too-long
510 def persist_clipboard():
511 """Persist the clipboard
513 X11 stores only a reference to the clipboard data.
514 Send a clipboard event to force a copy of the clipboard to occur.
515 This ensures that the clipboard is present after git-cola exits.
516 Otherwise, the reference is destroyed on exit.
518 C.f. https://stackoverflow.com/questions/2007103/how-can-i-disable-clear-of-clipboard-on-exit-of-pyqt4-application
520 """ # noqa
521 clipboard = QtWidgets.QApplication.clipboard()
522 event = QtCore.QEvent(QtCore.QEvent.Clipboard)
523 QtWidgets.QApplication.sendEvent(clipboard, event)
526 def add_action_bool(widget, text, fn, checked, *shortcuts):
527 tip = text
528 action = _add_action(widget, text, tip, fn, connect_action_bool, *shortcuts)
529 action.setCheckable(True)
530 action.setChecked(checked)
531 return action
534 def add_action(widget, text, fn, *shortcuts):
535 tip = text
536 return _add_action(widget, text, tip, fn, connect_action, *shortcuts)
539 def add_action_with_icon(widget, icon, text, fn, *shortcuts):
540 """Create a QAction using a custom icon"""
541 tip = text
542 action = _add_action(widget, text, tip, fn, connect_action, *shortcuts)
543 action.setIcon(icon)
544 return action
547 def add_action_with_status_tip(widget, text, tip, fn, *shortcuts):
548 return _add_action(widget, text, tip, fn, connect_action, *shortcuts)
551 def menu_separator(widget):
552 """Return a QAction whose isSeparator() returns true. Used in context menus"""
553 action = QtWidgets.QAction('', widget)
554 action.setSeparator(True)
555 return action
558 def _add_action(widget, text, tip, fn, connect, *shortcuts):
559 action = QtWidgets.QAction(text, widget)
560 if hasattr(action, 'setIconVisibleInMenu'):
561 action.setIconVisibleInMenu(True)
562 if tip:
563 action.setStatusTip(tip)
564 connect(action, fn)
565 if shortcuts:
566 action.setShortcuts(shortcuts)
567 if hasattr(Qt, 'WidgetWithChildrenShortcut'):
568 action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
569 widget.addAction(action)
570 return action
573 def set_selected_item(widget, idx):
574 """Sets a the currently selected item to the item at index idx."""
575 if isinstance(widget, QtWidgets.QTreeWidget):
576 item = widget.topLevelItem(idx)
577 if item:
578 item.setSelected(True)
579 widget.setCurrentItem(item)
582 def add_items(widget, items):
583 """Adds items to a widget."""
584 for item in items:
585 if item is None:
586 continue
587 widget.addItem(item)
590 def set_items(widget, items):
591 """Clear the existing widget contents and set the new items."""
592 widget.clear()
593 add_items(widget, items)
596 def create_treeitem(filename, staged=False, deleted=False, untracked=False):
597 """Given a filename, return a TreeWidgetItem for a status widget
599 "staged", "deleted, and "untracked" control which icon is used.
602 icon_name = icons.status(filename, deleted, staged, untracked)
603 icon = icons.name_from_basename(icon_name)
604 return TreeWidgetItem(filename, icon, deleted=deleted)
607 def add_close_action(widget):
608 """Adds close action and shortcuts to a widget."""
609 return add_action(widget, N_('Close...'), widget.close, hotkeys.CLOSE, hotkeys.QUIT)
612 def app():
613 """Return the current application"""
614 return QtWidgets.QApplication.instance()
617 def desktop():
618 """Return the desktop"""
619 return app().desktop()
622 def desktop_size():
623 desk = desktop()
624 rect = desk.screenGeometry(QtGui.QCursor().pos())
625 return (rect.width(), rect.height())
628 def center_on_screen(widget):
629 """Move widget to the center of the default screen"""
630 width, height = desktop_size()
631 cx = width // 2
632 cy = height // 2
633 widget.move(cx - widget.width() // 2, cy - widget.height() // 2)
636 def default_size(parent, width, height, use_parent_height=True):
637 """Return the parent's size, or the provided defaults"""
638 if parent is not None:
639 width = parent.width()
640 if use_parent_height:
641 height = parent.height()
642 return (width, height)
645 def default_monospace_font():
646 if utils.is_darwin():
647 family = 'Monaco'
648 else:
649 family = 'Monospace'
650 mfont = QtGui.QFont()
651 mfont.setFamily(family)
652 return mfont
655 def diff_font_str(context):
656 cfg = context.cfg
657 font_str = cfg.get(prefs.FONTDIFF)
658 if not font_str:
659 font_str = default_monospace_font().toString()
660 return font_str
663 def diff_font(context):
664 return font(diff_font_str(context))
667 def font(string):
668 qfont = QtGui.QFont()
669 qfont.fromString(string)
670 return qfont
673 def create_button(
674 text='', layout=None, tooltip=None, icon=None, enabled=True, default=False
676 """Create a button, set its title, and add it to the parent."""
677 button = QtWidgets.QPushButton()
678 button.setCursor(Qt.PointingHandCursor)
679 button.setFocusPolicy(Qt.NoFocus)
680 if text:
681 button.setText(' ' + text)
682 if icon is not None:
683 button.setIcon(icon)
684 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
685 if tooltip is not None:
686 button.setToolTip(tooltip)
687 if layout is not None:
688 layout.addWidget(button)
689 if not enabled:
690 button.setEnabled(False)
691 if default:
692 button.setDefault(True)
693 return button
696 def tool_button():
697 """Create a flat border-less button"""
698 button = QtWidgets.QToolButton()
699 button.setPopupMode(QtWidgets.QToolButton.InstantPopup)
700 button.setCursor(Qt.PointingHandCursor)
701 button.setFocusPolicy(Qt.NoFocus)
702 # Highlight colors
703 palette = QtGui.QPalette()
704 highlight = palette.color(QtGui.QPalette.Highlight)
705 highlight_rgb = rgb_css(highlight)
707 button.setStyleSheet(
709 /* No borders */
710 QToolButton {
711 border: none;
712 background-color: none;
714 /* Hide the menu indicator */
715 QToolButton::menu-indicator {
716 image: none;
718 QToolButton:hover {
719 border: %(border)spx solid %(highlight_rgb)s;
722 % dict(border=defs.border, highlight_rgb=highlight_rgb)
724 return button
727 def create_action_button(tooltip=None, icon=None, visible=True):
728 """Create a small toolbutton for use in dock title widgets"""
729 button = tool_button()
730 if tooltip is not None:
731 button.setToolTip(tooltip)
732 if icon is not None:
733 button.setIcon(icon)
734 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
735 button.setVisible(visible)
736 return button
739 def ok_button(text, default=True, enabled=True, icon=None):
740 if icon is None:
741 icon = icons.ok()
742 return create_button(text=text, icon=icon, default=default, enabled=enabled)
745 def close_button(text=None, icon=None):
746 text = text or N_('Close')
747 icon = icons.mkicon(icon, icons.close)
748 return create_button(text=text, icon=icon)
751 def edit_button(enabled=True, default=False):
752 return create_button(
753 text=N_('Edit'), icon=icons.edit(), enabled=enabled, default=default
757 def refresh_button(enabled=True, default=False):
758 return create_button(
759 text=N_('Refresh'), icon=icons.sync(), enabled=enabled, default=default
763 def checkbox(text='', tooltip='', checked=None):
764 """Create a checkbox"""
765 return _checkbox(QtWidgets.QCheckBox, text, tooltip, checked)
768 def radio(text='', tooltip='', checked=None):
769 """Create a radio button"""
770 return _checkbox(QtWidgets.QRadioButton, text, tooltip, checked)
773 def _checkbox(cls, text, tooltip, checked):
774 """Create a widget and apply properties"""
775 widget = cls()
776 if text:
777 widget.setText(text)
778 if tooltip:
779 widget.setToolTip(tooltip)
780 if checked is not None:
781 widget.setChecked(checked)
782 return widget
785 class DockTitleBarWidget(QtWidgets.QFrame):
786 def __init__(self, parent, title, stretch=True):
787 QtWidgets.QFrame.__init__(self, parent)
788 self.setAutoFillBackground(True)
789 self.label = qlabel = QtWidgets.QLabel(title, self)
790 qfont = qlabel.font()
791 qfont.setBold(True)
792 qlabel.setFont(qfont)
793 qlabel.setCursor(Qt.OpenHandCursor)
795 self.close_button = create_action_button(
796 tooltip=N_('Close'), icon=icons.close()
799 self.toggle_button = create_action_button(
800 tooltip=N_('Detach'), icon=icons.external()
803 self.corner_layout = hbox(defs.no_margin, defs.spacing)
805 if stretch:
806 separator = STRETCH
807 else:
808 separator = SKIPPED
810 self.main_layout = hbox(
811 defs.small_margin,
812 defs.titlebar_spacing,
813 qlabel,
814 separator,
815 self.corner_layout,
816 self.toggle_button,
817 self.close_button,
819 self.setLayout(self.main_layout)
821 connect_button(self.toggle_button, self.toggle_floating)
822 connect_button(self.close_button, self.toggle_visibility)
824 def toggle_floating(self):
825 self.parent().setFloating(not self.parent().isFloating())
826 self.update_tooltips()
828 def toggle_visibility(self):
829 self.parent().toggleViewAction().trigger()
831 def set_title(self, title):
832 self.label.setText(title)
834 def add_corner_widget(self, widget):
835 self.corner_layout.addWidget(widget)
837 def update_tooltips(self):
838 if self.parent().isFloating():
839 tooltip = N_('Attach')
840 else:
841 tooltip = N_('Detach')
842 self.toggle_button.setToolTip(tooltip)
845 def create_dock(name, title, parent, stretch=True, widget=None, fn=None):
846 """Create a dock widget and set it up accordingly."""
847 dock = QtWidgets.QDockWidget(parent)
848 dock.setWindowTitle(title)
849 dock.setObjectName(name)
850 titlebar = DockTitleBarWidget(dock, title, stretch=stretch)
851 dock.setTitleBarWidget(titlebar)
852 dock.setAutoFillBackground(True)
853 if hasattr(parent, 'dockwidgets'):
854 parent.dockwidgets.append(dock)
855 if fn:
856 widget = fn(dock)
857 assert isinstance(widget, QtWidgets.QFrame), "Docked widget has to be a QFrame"
858 if widget:
859 dock.setWidget(widget)
860 return dock
863 def hide_dock(widget):
864 widget.toggleViewAction().setChecked(False)
865 widget.hide()
868 def create_menu(title, parent):
869 """Create a menu and set its title."""
870 qmenu = DebouncingMenu(title, parent)
871 return qmenu
874 class DebouncingMenu(QtWidgets.QMenu):
875 """Menu that debounces mouse release action ie. stops it if occurred
876 right after menu creation.
878 Disables annoying behaviour when RMB is pressed to show menu, cursor is
879 moved accidentally 1px onto newly created menu and released causing to
880 execute menu action
883 threshold_ms = 400
885 def __init__(self, title, parent):
886 QtWidgets.QMenu.__init__(self, title, parent)
887 self.created_at = utils.epoch_millis()
888 if hasattr(self, 'setToolTipsVisible'):
889 self.setToolTipsVisible(True)
891 def mouseReleaseEvent(self, event):
892 threshold = DebouncingMenu.threshold_ms
893 if (utils.epoch_millis() - self.created_at) > threshold:
894 QtWidgets.QMenu.mouseReleaseEvent(self, event)
897 def add_menu(title, parent):
898 """Create a menu and set its title."""
899 menu = create_menu(title, parent)
900 if hasattr(parent, 'addMenu'):
901 parent.addMenu(menu)
902 else:
903 parent.addAction(menu.menuAction())
904 return menu
907 def create_toolbutton(text=None, layout=None, tooltip=None, icon=None):
908 button = tool_button()
909 if icon is not None:
910 button.setIcon(icon)
911 button.setIconSize(QtCore.QSize(defs.default_icon, defs.default_icon))
912 if text is not None:
913 button.setText(' ' + text)
914 button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
915 if tooltip is not None:
916 button.setToolTip(tooltip)
917 if layout is not None:
918 layout.addWidget(button)
919 return button
922 def create_toolbutton_with_callback(callback, text, icon, tooltip, layout=None):
923 """Create a toolbutton that runs the specified callback"""
924 toolbutton = create_toolbutton(text=text, layout=layout, tooltip=tooltip, icon=icon)
925 connect_button(toolbutton, callback)
926 return toolbutton
929 # pylint: disable=line-too-long
930 def mimedata_from_paths(context, paths, include_urls=True):
931 """Return mimedata with a list of absolute path URLs
933 Set `include_urls` to False to prevent URLs from being included
934 in the mimedata. This is useful in some terminals that do not gracefully handle
935 multiple URLs being included in the payload.
937 This allows the mimedata to contain just plain a plain text value that we
938 are able to format ourselves.
940 Older verisons of gnome-terminal expected a utf-16 encoding, but that
941 behavior is no longer needed.
942 """ # noqa
943 abspaths = [core.abspath(path) for path in paths]
944 paths_text = core.list2cmdline(abspaths)
946 # The text/x-moz-list format is always included by Qt, and doing
947 # mimedata.removeFormat('text/x-moz-url') has no effect.
948 # http://www.qtcentre.org/threads/44643-Dragging-text-uri-list-Qt-inserts-garbage
950 # Older versions of gnome-terminal expect utf-16 encoded text, but other terminals,
951 # e.g. terminator, expect utf-8, so use cola.dragencoding to override the default.
952 # NOTE: text/x-moz-url does not seem to be used/needed by modern versions of
953 # gnome-terminal, kitty, and terminator.
954 mimedata = QtCore.QMimeData()
955 mimedata.setText(paths_text)
956 if include_urls:
957 urls = [QtCore.QUrl.fromLocalFile(path) for path in abspaths]
958 encoding = context.cfg.get('cola.dragencoding', 'utf-16')
959 encoded_text = core.encode(paths_text, encoding=encoding)
960 mimedata.setUrls(urls)
961 mimedata.setData('text/x-moz-url', encoded_text)
963 return mimedata
966 def path_mimetypes(include_urls=True):
967 """Return a list of mimetypes that we generate"""
968 mime_types = [
969 'text/plain',
970 'text/plain;charset=utf-8',
972 if include_urls:
973 mime_types.append('text/uri-list')
974 mime_types.append('text/x-moz-url')
975 return mime_types
978 class BlockSignals(object):
979 """Context manager for blocking a signals on a widget"""
981 def __init__(self, *widgets):
982 self.widgets = widgets
983 self.values = []
985 def __enter__(self):
986 """Block Qt signals for all of the captured widgets"""
987 self.values = [widget.blockSignals(True) for widget in self.widgets]
988 return self
990 def __exit__(self, exc_type, exc_val, exc_tb):
991 """Restore Qt signals when we exit the scope"""
992 for (widget, value) in zip(self.widgets, self.values):
993 widget.blockSignals(value)
996 class Channel(QtCore.QObject):
997 finished = Signal(object)
998 result = Signal(object)
1001 class Task(QtCore.QRunnable):
1002 """Disable auto-deletion to avoid gc issues
1004 Python's garbage collector will try to double-free the task
1005 once it's finished, so disable Qt's auto-deletion as a workaround.
1009 def __init__(self):
1010 QtCore.QRunnable.__init__(self)
1012 self.channel = Channel()
1013 self.result = None
1014 self.setAutoDelete(False)
1016 def run(self):
1017 self.result = self.task()
1018 self.channel.result.emit(self.result)
1019 self.channel.finished.emit(self)
1021 # pylint: disable=no-self-use
1022 def task(self):
1023 return None
1025 def connect(self, handler):
1026 self.channel.result.connect(handler, type=Qt.QueuedConnection)
1029 class SimpleTask(Task):
1030 """Run a simple callable as a task"""
1032 def __init__(self, fn, *args, **kwargs):
1033 Task.__init__(self)
1035 self.fn = fn
1036 self.args = args
1037 self.kwargs = kwargs
1039 def task(self):
1040 return self.fn(*self.args, **self.kwargs)
1043 class RunTask(QtCore.QObject):
1044 """Runs QRunnable instances and transfers control when they finish"""
1046 def __init__(self, parent=None):
1047 QtCore.QObject.__init__(self, parent)
1048 self.tasks = []
1049 self.task_details = {}
1050 self.threadpool = QtCore.QThreadPool.globalInstance()
1051 self.result_fn = None
1053 def start(self, task, progress=None, finish=None, result=None):
1054 """Start the task and register a callback"""
1055 self.result_fn = result
1056 if progress is not None:
1057 progress.show()
1058 # prevents garbage collection bugs in certain PyQt4 versions
1059 self.tasks.append(task)
1060 task_id = id(task)
1061 self.task_details[task_id] = (progress, finish, result)
1062 task.channel.finished.connect(self.finish, type=Qt.QueuedConnection)
1063 self.threadpool.start(task)
1065 def finish(self, task):
1066 task_id = id(task)
1067 try:
1068 self.tasks.remove(task)
1069 except ValueError:
1070 pass
1071 try:
1072 progress, finish, result = self.task_details[task_id]
1073 del self.task_details[task_id]
1074 except KeyError:
1075 finish = progress = result = None
1077 if progress is not None:
1078 progress.hide()
1080 if result is not None:
1081 result(task.result)
1083 if finish is not None:
1084 finish(task)
1087 # Syntax highlighting
1090 def rgb(r, g, b):
1091 color = QtGui.QColor()
1092 color.setRgb(r, g, b)
1093 return color
1096 def rgba(r, g, b, a=255):
1097 color = rgb(r, g, b)
1098 color.setAlpha(a)
1099 return color
1102 def RGB(args):
1103 return rgb(*args)
1106 def rgb_css(color):
1107 """Convert a QColor into an rgb(int, int, int) CSS string"""
1108 return 'rgb(%d, %d, %d)' % (color.red(), color.green(), color.blue())
1111 def rgb_hex(color):
1112 """Convert a QColor into a hex aabbcc string"""
1113 return '%02x%02x%02x' % (color.red(), color.green(), color.blue())
1116 def hsl(h, s, light):
1117 return QtGui.QColor.fromHslF(
1118 utils.clamp(h, 0.0, 1.0), utils.clamp(s, 0.0, 1.0), utils.clamp(light, 0.0, 1.0)
1122 def hsl_css(h, s, light):
1123 return rgb_css(hsl(h, s, light))
1126 def make_format(fg=None, bg=None, bold=False):
1127 fmt = QtGui.QTextCharFormat()
1128 if fg:
1129 fmt.setForeground(fg)
1130 if bg:
1131 fmt.setBackground(bg)
1132 if bold:
1133 fmt.setFontWeight(QtGui.QFont.Bold)
1134 return fmt
1137 class ImageFormats(object):
1138 def __init__(self):
1139 # returns a list of QByteArray objects
1140 formats_qba = QtGui.QImageReader.supportedImageFormats()
1141 # portability: python3 data() returns bytes, python2 returns str
1142 decode = core.decode
1143 formats = [decode(x.data()) for x in formats_qba]
1144 self.extensions = {'.' + fmt for fmt in formats}
1146 def ok(self, filename):
1147 _, ext = os.path.splitext(filename)
1148 return ext.lower() in self.extensions
1151 def set_scrollbar_values(widget, hscroll_value, vscroll_value):
1152 """Set scrollbars to the specified values"""
1153 hscroll = widget.horizontalScrollBar()
1154 if hscroll and hscroll_value is not None:
1155 hscroll.setValue(hscroll_value)
1157 vscroll = widget.verticalScrollBar()
1158 if vscroll and vscroll_value is not None:
1159 vscroll.setValue(vscroll_value)
1162 def get_scrollbar_values(widget):
1163 """Return the current (hscroll, vscroll) scrollbar values for a widget"""
1164 hscroll = widget.horizontalScrollBar()
1165 if hscroll:
1166 hscroll_value = get(hscroll)
1167 else:
1168 hscroll_value = None
1169 vscroll = widget.verticalScrollBar()
1170 if vscroll:
1171 vscroll_value = get(vscroll)
1172 else:
1173 vscroll_value = None
1174 return (hscroll_value, vscroll_value)
1177 def scroll_to_item(widget, item):
1178 """Scroll to an item while retaining the horizontal scroll position"""
1179 hscroll = None
1180 hscrollbar = widget.horizontalScrollBar()
1181 if hscrollbar:
1182 hscroll = get(hscrollbar)
1183 widget.scrollToItem(item)
1184 if hscroll is not None:
1185 hscrollbar.setValue(hscroll)
1188 def select_item(widget, item):
1189 """Scroll to and make a QTreeWidget item selected and current"""
1190 scroll_to_item(widget, item)
1191 widget.setCurrentItem(item)
1192 item.setSelected(True)
1195 def get_selected_values(widget, top_level_idx, values):
1196 """Map the selected items under the top-level item to the values list"""
1197 # Get the top-level item
1198 item = widget.topLevelItem(top_level_idx)
1199 return tree_selection(item, values)
1202 def get_selected_items(widget, idx):
1203 """Return the selected items under the top-level item"""
1204 item = widget.topLevelItem(idx)
1205 return tree_selection_items(item)
1208 def add_menu_actions(menu, menu_actions):
1209 """Add actions to a menu, treating None as a separator"""
1210 current_actions = menu.actions()
1211 if current_actions:
1212 first_action = current_actions[0]
1213 else:
1214 first_action = None
1215 menu.addSeparator()
1217 for action in menu_actions:
1218 if action is None:
1219 action = menu_separator(menu)
1220 menu.insertAction(first_action, action)