dev: format code using "garden fmt" (black)
[git-cola.git] / cola / qtutils.py
blobbc59c3aba4a5a29297b65c1b4dc03a4fdaaaa92c
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 current_palette():
33 """Return the QPalette for the current application"""
34 return QtWidgets.QApplication.instance().palette()
37 def connect_action(action, func):
38 """Connect an action to a function"""
39 action.triggered[bool].connect(lambda x: func(), type=Qt.QueuedConnection)
42 def connect_action_bool(action, func):
43 """Connect a triggered(bool) action to a function"""
44 action.triggered[bool].connect(func, type=Qt.QueuedConnection)
47 def connect_button(button, func):
48 """Connect a button to a function"""
49 # Some versions of Qt send the `bool` argument to the clicked callback,
50 # and some do not. The lambda consumes all callback-provided arguments.
51 button.clicked.connect(lambda *args, **kwargs: func(), type=Qt.QueuedConnection)
54 def connect_checkbox(widget, func):
55 """Connect a checkbox to a function taking bool"""
56 widget.clicked.connect(
57 lambda *args, **kwargs: func(get(checkbox)), type=Qt.QueuedConnection
61 def connect_released(button, func):
62 """Connect a button to a function"""
63 button.released.connect(func, type=Qt.QueuedConnection)
66 def button_action(button, action):
67 """Make a button trigger an action"""
68 connect_button(button, action.trigger)
71 def connect_toggle(toggle, func):
72 """Connect a toggle button to a function"""
73 toggle.toggled.connect(func, type=Qt.QueuedConnection)
76 def disconnect(signal):
77 """Disconnect signal from all slots"""
78 try:
79 signal.disconnect()
80 except TypeError: # allow unconnected slots
81 pass
84 def get(widget, default=None):
85 """Query a widget for its python value"""
86 if hasattr(widget, 'isChecked'):
87 value = widget.isChecked()
88 elif hasattr(widget, 'value'):
89 value = widget.value()
90 elif hasattr(widget, 'text'):
91 value = widget.text()
92 elif hasattr(widget, 'toPlainText'):
93 value = widget.toPlainText()
94 elif hasattr(widget, 'sizes'):
95 value = widget.sizes()
96 elif hasattr(widget, 'date'):
97 value = widget.date().toString(Qt.ISODate)
98 else:
99 value = default
100 return value
103 def hbox(margin, spacing, *items):
104 """Create an HBoxLayout with the specified sizes and items"""
105 return box(QtWidgets.QHBoxLayout, margin, spacing, *items)
108 def vbox(margin, spacing, *items):
109 """Create a VBoxLayout with the specified sizes and items"""
110 return box(QtWidgets.QVBoxLayout, margin, spacing, *items)
113 def buttongroup(*items):
114 """Create a QButtonGroup for the specified items"""
115 group = QtWidgets.QButtonGroup()
116 for i in items:
117 group.addButton(i)
118 return group
121 def set_margin(layout, margin):
122 """Set the content margins for a layout"""
123 layout.setContentsMargins(margin, margin, margin, margin)
126 def box(cls, margin, spacing, *items):
127 """Create a QBoxLayout with the specified sizes and items"""
128 stretch = STRETCH
129 skipped = SKIPPED
130 layout = cls()
131 layout.setSpacing(spacing)
132 set_margin(layout, margin)
134 for i in items:
135 if isinstance(i, QtWidgets.QWidget):
136 layout.addWidget(i)
137 elif isinstance(
140 QtWidgets.QHBoxLayout,
141 QtWidgets.QVBoxLayout,
142 QtWidgets.QFormLayout,
143 QtWidgets.QLayout,
146 layout.addLayout(i)
147 elif i is stretch:
148 layout.addStretch()
149 elif i is skipped:
150 continue
151 elif isinstance(i, int_types):
152 layout.addSpacing(i)
154 return layout
157 def form(margin, spacing, *widgets):
158 """Create a QFormLayout with the specified sizes and items"""
159 layout = QtWidgets.QFormLayout()
160 layout.setSpacing(spacing)
161 layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.ExpandingFieldsGrow)
162 set_margin(layout, margin)
164 for idx, (name, widget) in enumerate(widgets):
165 if isinstance(name, (str, ustr)):
166 layout.addRow(name, widget)
167 else:
168 layout.setWidget(idx, QtWidgets.QFormLayout.LabelRole, name)
169 layout.setWidget(idx, QtWidgets.QFormLayout.FieldRole, widget)
171 return layout
174 def grid(margin, spacing, *widgets):
175 """Create a QGridLayout with the specified sizes and items"""
176 layout = QtWidgets.QGridLayout()
177 layout.setSpacing(spacing)
178 set_margin(layout, margin)
180 for row in widgets:
181 item = row[0]
182 if isinstance(item, QtWidgets.QWidget):
183 layout.addWidget(*row)
184 elif isinstance(item, QtWidgets.QLayoutItem):
185 layout.addItem(*row)
187 return layout
190 def splitter(orientation, *widgets):
191 """Create a spliter over the specified widgets
193 :param orientation: Qt.Horizontal or Qt.Vertical
196 layout = QtWidgets.QSplitter()
197 layout.setOrientation(orientation)
198 layout.setHandleWidth(defs.handle_width)
199 layout.setChildrenCollapsible(True)
201 for idx, widget in enumerate(widgets):
202 layout.addWidget(widget)
203 layout.setStretchFactor(idx, 1)
205 # Workaround for Qt not setting the WA_Hover property for QSplitter
206 # Cf. https://bugreports.qt.io/browse/QTBUG-13768
207 layout.handle(1).setAttribute(Qt.WA_Hover)
209 return layout
212 def label(text=None, align=None, fmt=None, selectable=True):
213 """Create a QLabel with the specified properties"""
214 widget = QtWidgets.QLabel()
215 if align is not None:
216 widget.setAlignment(align)
217 if fmt is not None:
218 widget.setTextFormat(fmt)
219 if selectable:
220 widget.setTextInteractionFlags(Qt.TextBrowserInteraction)
221 widget.setOpenExternalLinks(True)
222 if text:
223 widget.setText(text)
224 return widget
227 class ComboBox(QtWidgets.QComboBox):
228 """Custom read-only combobox with a convenient API"""
230 def __init__(self, items=None, editable=False, parent=None, transform=None):
231 super(ComboBox, self).__init__(parent)
232 self.setEditable(editable)
233 self.transform = transform
234 self.item_data = []
235 if items:
236 self.addItems(items)
237 self.item_data.extend(items)
239 def set_index(self, idx):
240 idx = utils.clamp(idx, 0, self.count() - 1)
241 self.setCurrentIndex(idx)
243 def add_item(self, text, data):
244 self.addItem(text)
245 self.item_data.append(data)
247 def current_data(self):
248 return self.item_data[self.currentIndex()]
250 def set_value(self, value):
251 if self.transform:
252 value = self.transform(value)
253 try:
254 index = self.item_data.index(value)
255 except ValueError:
256 index = 0
257 self.setCurrentIndex(index)
260 def combo(items, editable=False, parent=None):
261 """Create a readonly (by default) combobox from a list of items"""
262 return ComboBox(editable=editable, items=items, parent=parent)
265 def combo_mapped(data, editable=False, transform=None, parent=None):
266 """Create a readonly (by default) combobox from a list of items"""
267 widget = ComboBox(editable=editable, transform=transform, parent=parent)
268 for k, v in data:
269 widget.add_item(k, v)
270 return widget
273 def textbrowser(text=None):
274 """Create a QTextBrowser for the specified text"""
275 widget = QtWidgets.QTextBrowser()
276 widget.setOpenExternalLinks(True)
277 if text:
278 widget.setText(text)
279 return widget
282 def add_completer(widget, items):
283 """Add simple completion to a widget"""
284 completer = QtWidgets.QCompleter(items, widget)
285 completer.setCaseSensitivity(Qt.CaseInsensitive)
286 completer.setCompletionMode(QtWidgets.QCompleter.InlineCompletion)
287 widget.setCompleter(completer)
290 def prompt(msg, title=None, text='', parent=None):
291 """Presents the user with an input widget and returns the input."""
292 if title is None:
293 title = msg
294 if parent is None:
295 parent = active_window()
296 result = QtWidgets.QInputDialog.getText(
297 parent, title, msg, QtWidgets.QLineEdit.Normal, text
299 return (result[0], result[1])
302 def prompt_n(msg, inputs):
303 """Presents the user with N input widgets and returns the results"""
304 dialog = QtWidgets.QDialog(active_window())
305 dialog.setWindowModality(Qt.WindowModal)
306 dialog.setWindowTitle(msg)
308 long_value = msg
309 for k, v in inputs:
310 if len(k + v) > len(long_value):
311 long_value = k + v
313 metrics = QtGui.QFontMetrics(dialog.font())
314 min_width = min(720, metrics.width(long_value) + 100)
315 dialog.setMinimumWidth(min_width)
317 ok_b = ok_button(msg, enabled=False)
318 close_b = close_button()
320 form_widgets = []
322 def get_values():
323 return [pair[1].text().strip() for pair in form_widgets]
325 for name, value in inputs:
326 lineedit = QtWidgets.QLineEdit()
327 # Enable the OK button only when all fields have been populated
328 # pylint: disable=no-member
329 lineedit.textChanged.connect(
330 lambda x: ok_b.setEnabled(all(get_values())), type=Qt.QueuedConnection
332 if value:
333 lineedit.setText(value)
334 form_widgets.append((name, lineedit))
336 # layouts
337 form_layout = form(defs.no_margin, defs.button_spacing, *form_widgets)
338 button_layout = hbox(defs.no_margin, defs.button_spacing, STRETCH, close_b, ok_b)
339 main_layout = vbox(defs.margin, defs.button_spacing, form_layout, button_layout)
340 dialog.setLayout(main_layout)
342 # connections
343 connect_button(ok_b, dialog.accept)
344 connect_button(close_b, dialog.reject)
346 accepted = dialog.exec_() == QtWidgets.QDialog.Accepted
347 text = get_values()
348 success = accepted and all(text)
349 return (success, text)
352 def standard_item_type_value(value):
353 """Return a custom UserType for use in QTreeWidgetItem.type() overrides"""
354 return custom_item_type_value(QtGui.QStandardItem, value)
357 def graphics_item_type_value(value):
358 """Return a custom UserType for use in QGraphicsItem.type() overrides"""
359 return custom_item_type_value(QtWidgets.QGraphicsItem, value)
362 def custom_item_type_value(cls, value):
363 """Return a custom cls.UserType for use in cls.type() overrides"""
364 user_type = enum_value(cls.UserType)
365 return user_type + value
368 def enum_value(value):
369 """Qt6 has enums with an inner '.value' attribute."""
370 if hasattr(value, 'value'):
371 value = value.value
372 return value
375 class TreeWidgetItem(QtWidgets.QTreeWidgetItem):
376 TYPE = standard_item_type_value(101)
378 def __init__(self, path, icon, deleted):
379 QtWidgets.QTreeWidgetItem.__init__(self)
380 self.path = path
381 self.deleted = deleted
382 self.setIcon(0, icons.from_name(icon))
383 self.setText(0, path)
385 def type(self):
386 return self.TYPE
389 def paths_from_indexes(model, indexes, item_type=TreeWidgetItem.TYPE, item_filter=None):
390 """Return paths from a list of QStandardItemModel indexes"""
391 items = [model.itemFromIndex(i) for i in indexes]
392 return paths_from_items(items, item_type=item_type, item_filter=item_filter)
395 def _true_filter(_value):
396 return True
399 def paths_from_items(items, item_type=TreeWidgetItem.TYPE, item_filter=None):
400 """Return a list of paths from a list of items"""
401 if item_filter is None:
402 item_filter = _true_filter
403 return [i.path for i in items if i.type() == item_type and item_filter(i)]
406 def tree_selection(tree_item, items):
407 """Returns an array of model items that correspond to the selected
408 QTreeWidgetItem children"""
409 selected = []
410 count = min(tree_item.childCount(), len(items))
411 for idx in range(count):
412 if tree_item.child(idx).isSelected():
413 selected.append(items[idx])
415 return selected
418 def tree_selection_items(tree_item):
419 """Returns selected widget items"""
420 selected = []
421 for idx in range(tree_item.childCount()):
422 child = tree_item.child(idx)
423 if child.isSelected():
424 selected.append(child)
426 return selected
429 def selected_item(list_widget, items):
430 """Returns the model item that corresponds to the selected QListWidget
431 row."""
432 widget_items = list_widget.selectedItems()
433 if not widget_items:
434 return None
435 widget_item = widget_items[0]
436 row = list_widget.row(widget_item)
437 if row < len(items):
438 item = items[row]
439 else:
440 item = None
441 return item
444 def selected_items(list_widget, items):
445 """Returns an array of model items that correspond to the selected
446 QListWidget rows."""
447 item_count = len(items)
448 selected = []
449 for widget_item in list_widget.selectedItems():
450 row = list_widget.row(widget_item)
451 if row < item_count:
452 selected.append(items[row])
453 return selected
456 def open_file(title, directory=None):
457 """Creates an Open File dialog and returns a filename."""
458 result = compat.getopenfilename(
459 parent=active_window(), caption=title, basedir=directory
461 return result[0]
464 def open_files(title, directory=None, filters=''):
465 """Creates an Open File dialog and returns a list of filenames."""
466 result = compat.getopenfilenames(
467 parent=active_window(), caption=title, basedir=directory, filters=filters
469 return result[0]
472 def opendir_dialog(caption, path):
473 """Prompts for a directory path"""
474 options = (
475 QtWidgets.QFileDialog.Directory
476 | QtWidgets.QFileDialog.DontResolveSymlinks
477 | QtWidgets.QFileDialog.ReadOnly
478 | QtWidgets.QFileDialog.ShowDirsOnly
480 return compat.getexistingdirectory(
481 parent=active_window(), caption=caption, basedir=path, options=options
485 def save_as(filename, title='Save As...'):
486 """Creates a Save File dialog and returns a filename."""
487 result = compat.getsavefilename(
488 parent=active_window(), caption=title, basedir=filename
490 return result[0]
493 def existing_file(directory, title='Append...'):
494 """Creates a Save File dialog and returns a filename."""
495 result = compat.getopenfilename(
496 parent=active_window(), caption=title, basedir=directory
498 return result[0]
501 def copy_path(filename, absolute=True):
502 """Copy a filename to the clipboard"""
503 if filename is None:
504 return
505 if absolute:
506 filename = core.abspath(filename)
507 set_clipboard(filename)
510 def set_clipboard(text):
511 """Sets the copy/paste buffer to text."""
512 if not text:
513 return
514 clipboard = QtWidgets.QApplication.clipboard()
515 clipboard.setText(text, QtGui.QClipboard.Clipboard)
516 if not utils.is_darwin() and not utils.is_win32():
517 clipboard.setText(text, QtGui.QClipboard.Selection)
518 persist_clipboard()
521 # pylint: disable=line-too-long
522 def persist_clipboard():
523 """Persist the clipboard
525 X11 stores only a reference to the clipboard data.
526 Send a clipboard event to force a copy of the clipboard to occur.
527 This ensures that the clipboard is present after git-cola exits.
528 Otherwise, the reference is destroyed on exit.
530 C.f. https://stackoverflow.com/questions/2007103/how-can-i-disable-clear-of-clipboard-on-exit-of-pyqt4-application
532 """ # noqa
533 clipboard = QtWidgets.QApplication.clipboard()
534 event = QtCore.QEvent(QtCore.QEvent.Clipboard)
535 QtWidgets.QApplication.sendEvent(clipboard, event)
538 def add_action_bool(widget, text, func, checked, *shortcuts):
539 tip = text
540 action = _add_action(widget, text, tip, func, connect_action_bool, *shortcuts)
541 action.setCheckable(True)
542 action.setChecked(checked)
543 return action
546 def add_action(widget, text, func, *shortcuts):
547 """Create a QAction and bind it to the `func` callback and hotkeys"""
548 tip = text
549 return _add_action(widget, text, tip, func, connect_action, *shortcuts)
552 def add_action_with_icon(widget, icon, text, func, *shortcuts):
553 """Create a QAction using a custom icon bound to the `func` callback and hotkeys"""
554 tip = text
555 action = _add_action(widget, text, tip, func, connect_action, *shortcuts)
556 action.setIcon(icon)
557 return action
560 def add_action_with_status_tip(widget, text, tip, func, *shortcuts):
561 return _add_action(widget, text, tip, func, connect_action, *shortcuts)
564 def menu_separator(widget):
565 """Return a QAction whose isSeparator() returns true. Used in context menus"""
566 action = QtWidgets.QAction('', widget)
567 action.setSeparator(True)
568 return action
571 def _add_action(widget, text, tip, func, connect, *shortcuts):
572 action = QtWidgets.QAction(text, widget)
573 if hasattr(action, 'setIconVisibleInMenu'):
574 action.setIconVisibleInMenu(True)
575 if tip:
576 action.setStatusTip(tip)
577 connect(action, func)
578 if shortcuts:
579 action.setShortcuts(shortcuts)
580 if hasattr(Qt, 'WidgetWithChildrenShortcut'):
581 action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
582 widget.addAction(action)
583 return action
586 def set_selected_item(widget, idx):
587 """Sets a the currently selected item to the item at index idx."""
588 if isinstance(widget, QtWidgets.QTreeWidget):
589 item = widget.topLevelItem(idx)
590 if item:
591 item.setSelected(True)
592 widget.setCurrentItem(item)
595 def add_items(widget, items):
596 """Adds items to a widget."""
597 for item in items:
598 if item is None:
599 continue
600 widget.addItem(item)
603 def set_items(widget, items):
604 """Clear the existing widget contents and set the new items."""
605 widget.clear()
606 add_items(widget, items)
609 def create_treeitem(filename, staged=False, deleted=False, untracked=False):
610 """Given a filename, return a TreeWidgetItem for a status widget
612 "staged", "deleted, and "untracked" control which icon is used.
615 icon_name = icons.status(filename, deleted, staged, untracked)
616 icon = icons.name_from_basename(icon_name)
617 return TreeWidgetItem(filename, icon, deleted=deleted)
620 def add_close_action(widget):
621 """Adds close action and shortcuts to a widget."""
622 return add_action(widget, N_('Close...'), widget.close, hotkeys.CLOSE, hotkeys.QUIT)
625 def app():
626 """Return the current application"""
627 return QtWidgets.QApplication.instance()
630 def desktop_size():
631 rect = app().primaryScreen().geometry()
632 return (rect.width(), rect.height())
635 def center_on_screen(widget):
636 """Move widget to the center of the default screen"""
637 width, height = desktop_size()
638 center_x = width // 2
639 center_y = height // 2
640 widget.move(center_x - widget.width() // 2, center_y - widget.height() // 2)
643 def default_size(parent, width, height, use_parent_height=True):
644 """Return the parent's size, or the provided defaults"""
645 if parent is not None:
646 width = parent.width()
647 if use_parent_height:
648 height = parent.height()
649 return (width, height)
652 def default_monospace_font():
653 if utils.is_darwin():
654 family = 'Monaco'
655 else:
656 family = 'Monospace'
657 mfont = QtGui.QFont()
658 mfont.setFamily(family)
659 return mfont
662 def diff_font_str(context):
663 cfg = context.cfg
664 font_str = cfg.get(prefs.FONTDIFF)
665 if not font_str:
666 font_str = default_monospace_font().toString()
667 return font_str
670 def diff_font(context):
671 return font(diff_font_str(context))
674 def font(string):
675 qfont = QtGui.QFont()
676 qfont.fromString(string)
677 return qfont
680 def create_button(
681 text='', layout=None, tooltip=None, icon=None, enabled=True, default=False
683 """Create a button, set its title, and add it to the parent."""
684 button = QtWidgets.QPushButton()
685 button.setCursor(Qt.PointingHandCursor)
686 button.setFocusPolicy(Qt.NoFocus)
687 if text:
688 button.setText(' ' + text)
689 if icon is not None:
690 button.setIcon(icon)
691 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
692 if tooltip is not None:
693 button.setToolTip(tooltip)
694 if layout is not None:
695 layout.addWidget(button)
696 if not enabled:
697 button.setEnabled(False)
698 if default:
699 button.setDefault(True)
700 return button
703 def tool_button():
704 """Create a flat border-less button"""
705 button = QtWidgets.QToolButton()
706 button.setPopupMode(QtWidgets.QToolButton.InstantPopup)
707 button.setCursor(Qt.PointingHandCursor)
708 button.setFocusPolicy(Qt.NoFocus)
709 # Highlight colors
710 palette = QtGui.QPalette()
711 highlight = palette.color(QtGui.QPalette.Highlight)
712 highlight_rgb = rgb_css(highlight)
714 button.setStyleSheet(
716 /* No borders */
717 QToolButton {
718 border: none;
719 background-color: none;
721 /* Hide the menu indicator */
722 QToolButton::menu-indicator {
723 image: none;
725 QToolButton:hover {
726 border: %(border)spx solid %(highlight_rgb)s;
730 'border': defs.border,
731 'highlight_rgb': highlight_rgb,
734 return button
737 def create_action_button(tooltip=None, icon=None, visible=True):
738 """Create a small toolbutton for use in dock title widgets"""
739 button = tool_button()
740 if tooltip is not None:
741 button.setToolTip(tooltip)
742 if icon is not None:
743 button.setIcon(icon)
744 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
745 button.setVisible(visible)
746 return button
749 def ok_button(text, default=True, enabled=True, icon=None):
750 if icon is None:
751 icon = icons.ok()
752 return create_button(text=text, icon=icon, default=default, enabled=enabled)
755 def close_button(text=None, icon=None):
756 text = text or N_('Close')
757 icon = icons.mkicon(icon, icons.close)
758 return create_button(text=text, icon=icon)
761 def edit_button(enabled=True, default=False):
762 return create_button(
763 text=N_('Edit'), icon=icons.edit(), enabled=enabled, default=default
767 def refresh_button(enabled=True, default=False):
768 return create_button(
769 text=N_('Refresh'), icon=icons.sync(), enabled=enabled, default=default
773 def checkbox(text='', tooltip='', checked=None):
774 """Create a checkbox"""
775 return _checkbox(QtWidgets.QCheckBox, text, tooltip, checked)
778 def radio(text='', tooltip='', checked=None):
779 """Create a radio button"""
780 return _checkbox(QtWidgets.QRadioButton, text, tooltip, checked)
783 def _checkbox(cls, text, tooltip, checked):
784 """Create a widget and apply properties"""
785 widget = cls()
786 if text:
787 widget.setText(text)
788 if tooltip:
789 widget.setToolTip(tooltip)
790 if checked is not None:
791 widget.setChecked(checked)
792 return widget
795 class DockTitleBarWidget(QtWidgets.QFrame):
796 def __init__(self, parent, title, stretch=True):
797 QtWidgets.QFrame.__init__(self, parent)
798 self.setAutoFillBackground(True)
799 self.label = qlabel = QtWidgets.QLabel(title, self)
800 qfont = qlabel.font()
801 qfont.setBold(True)
802 qlabel.setFont(qfont)
803 qlabel.setCursor(Qt.OpenHandCursor)
805 self.close_button = create_action_button(
806 tooltip=N_('Close'), icon=icons.close()
809 self.toggle_button = create_action_button(
810 tooltip=N_('Detach'), icon=icons.external()
813 self.corner_layout = hbox(defs.no_margin, defs.spacing)
815 if stretch:
816 separator = STRETCH
817 else:
818 separator = SKIPPED
820 self.main_layout = hbox(
821 defs.small_margin,
822 defs.titlebar_spacing,
823 qlabel,
824 separator,
825 self.corner_layout,
826 self.toggle_button,
827 self.close_button,
829 self.setLayout(self.main_layout)
831 connect_button(self.toggle_button, self.toggle_floating)
832 connect_button(self.close_button, self.toggle_visibility)
834 def toggle_floating(self):
835 self.parent().setFloating(not self.parent().isFloating())
836 self.update_tooltips()
838 def toggle_visibility(self):
839 self.parent().toggleViewAction().trigger()
841 def set_title(self, title):
842 self.label.setText(title)
844 def add_corner_widget(self, widget):
845 self.corner_layout.addWidget(widget)
847 def update_tooltips(self):
848 if self.parent().isFloating():
849 tooltip = N_('Attach')
850 else:
851 tooltip = N_('Detach')
852 self.toggle_button.setToolTip(tooltip)
855 def create_dock(name, title, parent, stretch=True, widget=None, func=None):
856 """Create a dock widget and set it up accordingly."""
857 dock = QtWidgets.QDockWidget(parent)
858 dock.setWindowTitle(title)
859 dock.setObjectName(name)
860 titlebar = DockTitleBarWidget(dock, title, stretch=stretch)
861 dock.setTitleBarWidget(titlebar)
862 dock.setAutoFillBackground(True)
863 if hasattr(parent, 'dockwidgets'):
864 parent.dockwidgets.append(dock)
865 if func:
866 widget = func(dock)
867 if widget:
868 dock.setWidget(widget)
869 return dock
872 def hide_dock(widget):
873 widget.toggleViewAction().setChecked(False)
874 widget.hide()
877 def create_menu(title, parent):
878 """Create a menu and set its title."""
879 qmenu = DebouncingMenu(title, parent)
880 return qmenu
883 class DebouncingMenu(QtWidgets.QMenu):
884 """Menu that debounces mouse release action ie. stops it if occurred
885 right after menu creation.
887 Disables annoying behaviour when RMB is pressed to show menu, cursor is
888 moved accidentally 1px onto newly created menu and released causing to
889 execute menu action
892 threshold_ms = 400
894 def __init__(self, title, parent):
895 QtWidgets.QMenu.__init__(self, title, parent)
896 self.created_at = utils.epoch_millis()
897 if hasattr(self, 'setToolTipsVisible'):
898 self.setToolTipsVisible(True)
900 def mouseReleaseEvent(self, event):
901 threshold = DebouncingMenu.threshold_ms
902 if (utils.epoch_millis() - self.created_at) > threshold:
903 QtWidgets.QMenu.mouseReleaseEvent(self, event)
906 def add_menu(title, parent):
907 """Create a menu and set its title."""
908 menu = create_menu(title, parent)
909 if hasattr(parent, 'addMenu'):
910 parent.addMenu(menu)
911 else:
912 parent.addAction(menu.menuAction())
913 return menu
916 def create_toolbutton(text=None, layout=None, tooltip=None, icon=None):
917 button = tool_button()
918 if icon is not None:
919 button.setIcon(icon)
920 button.setIconSize(QtCore.QSize(defs.default_icon, defs.default_icon))
921 if text is not None:
922 button.setText(' ' + text)
923 button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
924 if tooltip is not None:
925 button.setToolTip(tooltip)
926 if layout is not None:
927 layout.addWidget(button)
928 return button
931 def create_toolbutton_with_callback(callback, text, icon, tooltip, layout=None):
932 """Create a toolbutton that runs the specified callback"""
933 toolbutton = create_toolbutton(text=text, layout=layout, tooltip=tooltip, icon=icon)
934 connect_button(toolbutton, callback)
935 return toolbutton
938 # pylint: disable=line-too-long
939 def mimedata_from_paths(context, paths, include_urls=True):
940 """Return mimedata with a list of absolute path URLs
942 Set `include_urls` to False to prevent URLs from being included
943 in the mimedata. This is useful in some terminals that do not gracefully handle
944 multiple URLs being included in the payload.
946 This allows the mimedata to contain just plain a plain text value that we
947 are able to format ourselves.
949 Older verisons of gnome-terminal expected a utf-16 encoding, but that
950 behavior is no longer needed.
951 """ # noqa
952 abspaths = [core.abspath(path) for path in paths]
953 paths_text = core.list2cmdline(abspaths)
955 # The text/x-moz-list format is always included by Qt, and doing
956 # mimedata.removeFormat('text/x-moz-url') has no effect.
957 # http://www.qtcentre.org/threads/44643-Dragging-text-uri-list-Qt-inserts-garbage
959 # Older versions of gnome-terminal expect utf-16 encoded text, but other terminals,
960 # e.g. terminator, expect utf-8, so use cola.dragencoding to override the default.
961 # NOTE: text/x-moz-url does not seem to be used/needed by modern versions of
962 # gnome-terminal, kitty, and terminator.
963 mimedata = QtCore.QMimeData()
964 mimedata.setText(paths_text)
965 if include_urls:
966 urls = [QtCore.QUrl.fromLocalFile(path) for path in abspaths]
967 encoding = context.cfg.get('cola.dragencoding', 'utf-16')
968 encoded_text = core.encode(paths_text, encoding=encoding)
969 mimedata.setUrls(urls)
970 mimedata.setData('text/x-moz-url', encoded_text)
972 return mimedata
975 def path_mimetypes(include_urls=True):
976 """Return a list of mimetypes that we generate"""
977 mime_types = [
978 'text/plain',
979 'text/plain;charset=utf-8',
981 if include_urls:
982 mime_types.append('text/uri-list')
983 mime_types.append('text/x-moz-url')
984 return mime_types
987 class BlockSignals(object):
988 """Context manager for blocking a signals on a widget"""
990 def __init__(self, *widgets):
991 self.widgets = widgets
992 self.values = []
994 def __enter__(self):
995 """Block Qt signals for all of the captured widgets"""
996 self.values = [widget.blockSignals(True) for widget in self.widgets]
997 return self
999 def __exit__(self, exc_type, exc_val, exc_tb):
1000 """Restore Qt signals when we exit the scope"""
1001 for widget, value in zip(self.widgets, self.values):
1002 widget.blockSignals(value)
1005 class Channel(QtCore.QObject):
1006 finished = Signal(object)
1007 result = Signal(object)
1010 class Task(QtCore.QRunnable):
1011 """Run a task in the background and return the result using a Channel"""
1013 def __init__(self):
1014 QtCore.QRunnable.__init__(self)
1016 self.channel = Channel()
1017 self.result = None
1018 # Python's garbage collector will try to double-free the task
1019 # once it's finished, so disable Qt's auto-deletion as a workaround.
1020 self.setAutoDelete(False)
1022 def run(self):
1023 self.result = self.task()
1024 self.channel.result.emit(self.result)
1025 self.channel.finished.emit(self)
1027 def task(self):
1028 """Perform a long-running task"""
1029 return ()
1031 def connect(self, handler):
1032 self.channel.result.connect(handler, type=Qt.QueuedConnection)
1035 class SimpleTask(Task):
1036 """Run a simple callable as a task"""
1038 def __init__(self, func, *args, **kwargs):
1039 Task.__init__(self)
1041 self.func = func
1042 self.args = args
1043 self.kwargs = kwargs
1045 def task(self):
1046 return self.func(*self.args, **self.kwargs)
1049 class RunTask(QtCore.QObject):
1050 """Runs QRunnable instances and transfers control when they finish"""
1052 def __init__(self, parent=None):
1053 QtCore.QObject.__init__(self, parent)
1054 self.tasks = []
1055 self.task_details = {}
1056 self.threadpool = QtCore.QThreadPool.globalInstance()
1057 self.result_func = None
1059 def start(self, task, progress=None, finish=None, result=None):
1060 """Start the task and register a callback"""
1061 self.result_func = result
1062 if progress is not None:
1063 progress.show()
1064 if hasattr(progress, 'start'):
1065 progress.start()
1067 # prevents garbage collection bugs in certain PyQt4 versions
1068 self.tasks.append(task)
1069 task_id = id(task)
1070 self.task_details[task_id] = (progress, finish, result)
1071 task.channel.finished.connect(self.finish, type=Qt.QueuedConnection)
1072 self.threadpool.start(task)
1074 def finish(self, task):
1075 """The task has finished. Run the finish and result callbacks"""
1076 task_id = id(task)
1077 try:
1078 self.tasks.remove(task)
1079 except ValueError:
1080 pass
1081 try:
1082 progress, finish, result = self.task_details[task_id]
1083 del self.task_details[task_id]
1084 except KeyError:
1085 finish = progress = result = None
1087 if progress is not None:
1088 if hasattr(progress, 'stop'):
1089 progress.stop()
1090 progress.hide()
1092 if result is not None:
1093 result(task.result)
1095 if finish is not None:
1096 finish(task)
1099 # Syntax highlighting
1102 def rgb(red, green, blue):
1103 """Create a QColor from r, g, b arguments"""
1104 color = QtGui.QColor()
1105 color.setRgb(red, green, blue)
1106 return color
1109 def rgba(red, green, blue, alpha=255):
1110 """Create a QColor with alpha from r, g, b, a arguments"""
1111 color = rgb(red, green, blue)
1112 color.setAlpha(alpha)
1113 return color
1116 def rgb_triple(args):
1117 """Create a QColor from an argument with an [r, g, b] triple"""
1118 return rgb(*args)
1121 def rgb_css(color):
1122 """Convert a QColor into an rgb #abcdef CSS string"""
1123 return '#%s' % rgb_hex(color)
1126 def rgb_hex(color):
1127 """Convert a QColor into a hex aabbcc string"""
1128 return '%02x%02x%02x' % (color.red(), color.green(), color.blue())
1131 def clamp_color(value):
1132 """Clamp an integer value between 0 and 255"""
1133 return min(255, max(value, 0))
1136 def css_color(value):
1137 """Convert a #abcdef hex string into a QColor"""
1138 if value.startswith('#'):
1139 value = value[1:]
1140 try:
1141 red = clamp_color(int(value[:2], base=16)) # ab
1142 except ValueError:
1143 red = 255
1144 try:
1145 green = clamp_color(int(value[2:4], base=16)) # cd
1146 except ValueError:
1147 green = 255
1148 try:
1149 blue = clamp_color(int(value[4:6], base=16)) # ef
1150 except ValueError:
1151 blue = 255
1152 return rgb(red, green, blue)
1155 def hsl(hue, saturation, lightness):
1156 """Return a QColor from an hue, saturation and lightness"""
1157 return QtGui.QColor.fromHslF(
1158 utils.clamp(hue, 0.0, 1.0),
1159 utils.clamp(saturation, 0.0, 1.0),
1160 utils.clamp(lightness, 0.0, 1.0),
1164 def hsl_css(hue, saturation, lightness):
1165 """Convert HSL values to a CSS #abcdef color string"""
1166 return rgb_css(hsl(hue, saturation, lightness))
1169 def make_format(foreground=None, background=None, bold=False):
1170 """Create a QTextFormat from the provided foreground, background and bold values"""
1171 fmt = QtGui.QTextCharFormat()
1172 if foreground:
1173 fmt.setForeground(foreground)
1174 if background:
1175 fmt.setBackground(background)
1176 if bold:
1177 fmt.setFontWeight(QtGui.QFont.Bold)
1178 return fmt
1181 class ImageFormats(object):
1182 def __init__(self):
1183 # returns a list of QByteArray objects
1184 formats_qba = QtGui.QImageReader.supportedImageFormats()
1185 # portability: python3 data() returns bytes, python2 returns str
1186 decode = core.decode
1187 formats = [decode(x.data()) for x in formats_qba]
1188 self.extensions = {'.' + fmt for fmt in formats}
1190 def ok(self, filename):
1191 _, ext = os.path.splitext(filename)
1192 return ext.lower() in self.extensions
1195 def set_scrollbar_values(widget, hscroll_value, vscroll_value):
1196 """Set scrollbars to the specified values"""
1197 hscroll = widget.horizontalScrollBar()
1198 if hscroll and hscroll_value is not None:
1199 hscroll.setValue(hscroll_value)
1201 vscroll = widget.verticalScrollBar()
1202 if vscroll and vscroll_value is not None:
1203 vscroll.setValue(vscroll_value)
1206 def get_scrollbar_values(widget):
1207 """Return the current (hscroll, vscroll) scrollbar values for a widget"""
1208 hscroll = widget.horizontalScrollBar()
1209 if hscroll:
1210 hscroll_value = get(hscroll)
1211 else:
1212 hscroll_value = None
1213 vscroll = widget.verticalScrollBar()
1214 if vscroll:
1215 vscroll_value = get(vscroll)
1216 else:
1217 vscroll_value = None
1218 return (hscroll_value, vscroll_value)
1221 def scroll_to_item(widget, item):
1222 """Scroll to an item while retaining the horizontal scroll position"""
1223 hscroll = None
1224 hscrollbar = widget.horizontalScrollBar()
1225 if hscrollbar:
1226 hscroll = get(hscrollbar)
1227 widget.scrollToItem(item)
1228 if hscroll is not None:
1229 hscrollbar.setValue(hscroll)
1232 def select_item(widget, item):
1233 """Scroll to and make a QTreeWidget item selected and current"""
1234 scroll_to_item(widget, item)
1235 widget.setCurrentItem(item)
1236 item.setSelected(True)
1239 def get_selected_values(widget, top_level_idx, values):
1240 """Map the selected items under the top-level item to the values list"""
1241 # Get the top-level item
1242 item = widget.topLevelItem(top_level_idx)
1243 return tree_selection(item, values)
1246 def get_selected_items(widget, idx):
1247 """Return the selected items under the top-level item"""
1248 item = widget.topLevelItem(idx)
1249 return tree_selection_items(item)
1252 def add_menu_actions(menu, menu_actions):
1253 """Add actions to a menu, treating None as a separator"""
1254 current_actions = menu.actions()
1255 if current_actions:
1256 first_action = current_actions[0]
1257 else:
1258 first_action = None
1259 menu.addSeparator()
1261 for action in menu_actions:
1262 if action is None:
1263 action = menu_separator(menu)
1264 menu.insertAction(first_action, action)