CHANGES: document the vendored qtpy update
[git-cola.git] / cola / qtutils.py
blob3ca3391e8c5d3e7b54f1c0b5e6d94078cd32f913
1 """Miscellaneous Qt utility functions."""
2 import os
4 from qtpy import compat
5 from qtpy import QtGui
6 from qtpy import QtCore
7 from qtpy import QtWidgets
8 from qtpy.QtCore import Qt
9 from qtpy.QtCore import Signal
11 from . import core
12 from . import hotkeys
13 from . import icons
14 from . import utils
15 from .i18n import N_
16 from .compat import int_types
17 from .compat import ustr
18 from .models import prefs
19 from .widgets import defs
22 STRETCH = object()
23 SKIPPED = object()
26 def active_window():
27 """Return the active window for the current application"""
28 return QtWidgets.QApplication.activeWindow()
31 def current_palette():
32 """Return the QPalette for the current application"""
33 return QtWidgets.QApplication.instance().palette()
36 def connect_action(action, func):
37 """Connect an action to a function"""
38 action.triggered[bool].connect(lambda x: func(), type=Qt.QueuedConnection)
41 def connect_action_bool(action, func):
42 """Connect a triggered(bool) action to a function"""
43 action.triggered[bool].connect(func, type=Qt.QueuedConnection)
46 def connect_button(button, func):
47 """Connect a button to a function"""
48 # Some versions of Qt send the `bool` argument to the clicked callback,
49 # and some do not. The lambda consumes all callback-provided arguments.
50 button.clicked.connect(lambda *args, **kwargs: func(), type=Qt.QueuedConnection)
53 def connect_checkbox(widget, func):
54 """Connect a checkbox to a function taking bool"""
55 widget.clicked.connect(
56 lambda *args, **kwargs: func(get(checkbox)), type=Qt.QueuedConnection
60 def connect_released(button, func):
61 """Connect a button to a function"""
62 button.released.connect(func, type=Qt.QueuedConnection)
65 def button_action(button, action):
66 """Make a button trigger an action"""
67 connect_button(button, action.trigger)
70 def connect_toggle(toggle, func):
71 """Connect a toggle button to a function"""
72 toggle.toggled.connect(func, type=Qt.QueuedConnection)
75 def disconnect(signal):
76 """Disconnect signal from all slots"""
77 try:
78 signal.disconnect()
79 except TypeError: # allow unconnected slots
80 pass
83 def get(widget, default=None):
84 """Query a widget for its python value"""
85 if hasattr(widget, 'isChecked'):
86 value = widget.isChecked()
87 elif hasattr(widget, 'value'):
88 value = widget.value()
89 elif hasattr(widget, 'text'):
90 value = widget.text()
91 elif hasattr(widget, 'toPlainText'):
92 value = widget.toPlainText()
93 elif hasattr(widget, 'sizes'):
94 value = widget.sizes()
95 elif hasattr(widget, 'date'):
96 value = widget.date().toString(Qt.ISODate)
97 else:
98 value = default
99 return value
102 def hbox(margin, spacing, *items):
103 """Create an HBoxLayout with the specified sizes and items"""
104 return box(QtWidgets.QHBoxLayout, margin, spacing, *items)
107 def vbox(margin, spacing, *items):
108 """Create a VBoxLayout with the specified sizes and items"""
109 return box(QtWidgets.QVBoxLayout, margin, spacing, *items)
112 def buttongroup(*items):
113 """Create a QButtonGroup for the specified items"""
114 group = QtWidgets.QButtonGroup()
115 for i in items:
116 group.addButton(i)
117 return group
120 def set_margin(layout, margin):
121 """Set the content margins for a layout"""
122 layout.setContentsMargins(margin, margin, margin, margin)
125 def box(cls, margin, spacing, *items):
126 """Create a QBoxLayout with the specified sizes and items"""
127 stretch = STRETCH
128 skipped = SKIPPED
129 layout = cls()
130 layout.setSpacing(spacing)
131 set_margin(layout, margin)
133 for i in items:
134 if isinstance(i, QtWidgets.QWidget):
135 layout.addWidget(i)
136 elif isinstance(
139 QtWidgets.QHBoxLayout,
140 QtWidgets.QVBoxLayout,
141 QtWidgets.QFormLayout,
142 QtWidgets.QLayout,
145 layout.addLayout(i)
146 elif i is stretch:
147 layout.addStretch()
148 elif i is skipped:
149 continue
150 elif isinstance(i, int_types):
151 layout.addSpacing(i)
153 return layout
156 def form(margin, spacing, *widgets):
157 """Create a QFormLayout with the specified sizes and items"""
158 layout = QtWidgets.QFormLayout()
159 layout.setSpacing(spacing)
160 layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.ExpandingFieldsGrow)
161 set_margin(layout, margin)
163 for idx, (name, widget) in enumerate(widgets):
164 if isinstance(name, (str, ustr)):
165 layout.addRow(name, widget)
166 else:
167 layout.setWidget(idx, QtWidgets.QFormLayout.LabelRole, name)
168 layout.setWidget(idx, QtWidgets.QFormLayout.FieldRole, widget)
170 return layout
173 def grid(margin, spacing, *widgets):
174 """Create a QGridLayout with the specified sizes and items"""
175 layout = QtWidgets.QGridLayout()
176 layout.setSpacing(spacing)
177 set_margin(layout, margin)
179 for row in widgets:
180 item = row[0]
181 if isinstance(item, QtWidgets.QWidget):
182 layout.addWidget(*row)
183 elif isinstance(item, QtWidgets.QLayoutItem):
184 layout.addItem(*row)
186 return layout
189 def splitter(orientation, *widgets):
190 """Create a spliter over the specified widgets
192 :param orientation: Qt.Horizontal or Qt.Vertical
195 layout = QtWidgets.QSplitter()
196 layout.setOrientation(orientation)
197 layout.setHandleWidth(defs.handle_width)
198 layout.setChildrenCollapsible(True)
200 for idx, widget in enumerate(widgets):
201 layout.addWidget(widget)
202 layout.setStretchFactor(idx, 1)
204 # Workaround for Qt not setting the WA_Hover property for QSplitter
205 # Cf. https://bugreports.qt.io/browse/QTBUG-13768
206 layout.handle(1).setAttribute(Qt.WA_Hover)
208 return layout
211 def label(text=None, align=None, fmt=None, selectable=True):
212 """Create a QLabel with the specified properties"""
213 widget = QtWidgets.QLabel()
214 if align is not None:
215 widget.setAlignment(align)
216 if fmt is not None:
217 widget.setTextFormat(fmt)
218 if selectable:
219 widget.setTextInteractionFlags(Qt.TextBrowserInteraction)
220 widget.setOpenExternalLinks(True)
221 if text:
222 widget.setText(text)
223 return widget
226 class ComboBox(QtWidgets.QComboBox):
227 """Custom read-only combobox with a convenient API"""
229 def __init__(self, items=None, editable=False, parent=None, transform=None):
230 super().__init__(parent)
231 self.setEditable(editable)
232 self.transform = transform
233 self.item_data = []
234 if items:
235 self.addItems(items)
236 self.item_data.extend(items)
238 def set_index(self, idx):
239 idx = utils.clamp(idx, 0, self.count() - 1)
240 self.setCurrentIndex(idx)
242 def add_item(self, text, data):
243 self.addItem(text)
244 self.item_data.append(data)
246 def current_data(self):
247 return self.item_data[self.currentIndex()]
249 def set_value(self, value):
250 if self.transform:
251 value = self.transform(value)
252 try:
253 index = self.item_data.index(value)
254 except ValueError:
255 index = 0
256 self.setCurrentIndex(index)
259 def combo(items, editable=False, tooltip='', parent=None):
260 """Create a readonly (by default) combobox from a list of items"""
261 combobox = ComboBox(editable=editable, items=items, parent=parent)
262 if tooltip:
263 combobox.setToolTip(tooltip)
264 return combobox
267 def combo_mapped(data, editable=False, transform=None, parent=None):
268 """Create a readonly (by default) combobox from a list of items"""
269 widget = ComboBox(editable=editable, transform=transform, parent=parent)
270 for k, v in data:
271 widget.add_item(k, v)
272 return widget
275 def textbrowser(text=None):
276 """Create a QTextBrowser for the specified text"""
277 widget = QtWidgets.QTextBrowser()
278 widget.setOpenExternalLinks(True)
279 if text:
280 widget.setText(text)
281 return widget
284 def link(url, text, palette=None):
285 if palette is None:
286 palette = QtGui.QPalette()
288 color = palette.color(QtGui.QPalette.WindowText)
289 rgb_color = f'rgb({color.red()}, {color.green()}, {color.blue()})'
290 scope = {'rgb': rgb_color, 'text': text, 'url': url}
292 return (
294 <a style="font-style: italic; text-decoration: none; color: %(rgb)s;"
295 href="%(url)s">
296 %(text)s
297 </a>
299 % scope
303 def add_completer(widget, items):
304 """Add simple completion to a widget"""
305 completer = QtWidgets.QCompleter(items, widget)
306 completer.setCaseSensitivity(Qt.CaseInsensitive)
307 completer.setCompletionMode(QtWidgets.QCompleter.InlineCompletion)
308 widget.setCompleter(completer)
311 def prompt(msg, title=None, text='', parent=None):
312 """Presents the user with an input widget and returns the input."""
313 if title is None:
314 title = msg
315 if parent is None:
316 parent = active_window()
317 result = QtWidgets.QInputDialog.getText(
318 parent, title, msg, QtWidgets.QLineEdit.Normal, text
320 return (result[0], result[1])
323 def prompt_n(msg, inputs):
324 """Presents the user with N input widgets and returns the results"""
325 dialog = QtWidgets.QDialog(active_window())
326 dialog.setWindowModality(Qt.WindowModal)
327 dialog.setWindowTitle(msg)
329 long_value = msg
330 for k, v in inputs:
331 if len(k + v) > len(long_value):
332 long_value = k + v
334 metrics = QtGui.QFontMetrics(dialog.font())
335 min_width = min(720, metrics.width(long_value) + 100)
336 dialog.setMinimumWidth(min_width)
338 ok_b = ok_button(msg, enabled=False)
339 close_b = close_button()
341 form_widgets = []
343 def get_values():
344 return [pair[1].text().strip() for pair in form_widgets]
346 for name, value in inputs:
347 lineedit = QtWidgets.QLineEdit()
348 # Enable the OK button only when all fields have been populated
349 # pylint: disable=no-member
350 lineedit.textChanged.connect(
351 lambda x: ok_b.setEnabled(all(get_values())), type=Qt.QueuedConnection
353 if value:
354 lineedit.setText(value)
355 form_widgets.append((name, lineedit))
357 # layouts
358 form_layout = form(defs.no_margin, defs.button_spacing, *form_widgets)
359 button_layout = hbox(defs.no_margin, defs.button_spacing, STRETCH, close_b, ok_b)
360 main_layout = vbox(defs.margin, defs.button_spacing, form_layout, button_layout)
361 dialog.setLayout(main_layout)
363 # connections
364 connect_button(ok_b, dialog.accept)
365 connect_button(close_b, dialog.reject)
367 accepted = dialog.exec_() == QtWidgets.QDialog.Accepted
368 text = get_values()
369 success = accepted and all(text)
370 return (success, text)
373 def standard_item_type_value(value):
374 """Return a custom UserType for use in QTreeWidgetItem.type() overrides"""
375 return custom_item_type_value(QtGui.QStandardItem, value)
378 def graphics_item_type_value(value):
379 """Return a custom UserType for use in QGraphicsItem.type() overrides"""
380 return custom_item_type_value(QtWidgets.QGraphicsItem, value)
383 def custom_item_type_value(cls, value):
384 """Return a custom cls.UserType for use in cls.type() overrides"""
385 user_type = enum_value(cls.UserType)
386 return user_type + value
389 def enum_value(value):
390 """Qt6 has enums with an inner '.value' attribute."""
391 if hasattr(value, 'value'):
392 value = value.value
393 return value
396 class TreeWidgetItem(QtWidgets.QTreeWidgetItem):
397 TYPE = standard_item_type_value(101)
399 def __init__(self, path, icon, deleted):
400 QtWidgets.QTreeWidgetItem.__init__(self)
401 self.path = path
402 self.deleted = deleted
403 self.setIcon(0, icons.from_name(icon))
404 self.setText(0, path)
406 def type(self):
407 return self.TYPE
410 def paths_from_indexes(model, indexes, item_type=TreeWidgetItem.TYPE, item_filter=None):
411 """Return paths from a list of QStandardItemModel indexes"""
412 items = [model.itemFromIndex(i) for i in indexes]
413 return paths_from_items(items, item_type=item_type, item_filter=item_filter)
416 def _true_filter(_value):
417 return True
420 def paths_from_items(items, item_type=TreeWidgetItem.TYPE, item_filter=None):
421 """Return a list of paths from a list of items"""
422 if item_filter is None:
423 item_filter = _true_filter
424 return [i.path for i in items if i.type() == item_type and item_filter(i)]
427 def tree_selection(tree_item, items):
428 """Returns an array of model items that correspond to the selected
429 QTreeWidgetItem children"""
430 selected = []
431 count = min(tree_item.childCount(), len(items))
432 for idx in range(count):
433 if tree_item.child(idx).isSelected():
434 selected.append(items[idx])
436 return selected
439 def tree_selection_items(tree_item):
440 """Returns selected widget items"""
441 selected = []
442 for idx in range(tree_item.childCount()):
443 child = tree_item.child(idx)
444 if child.isSelected():
445 selected.append(child)
447 return selected
450 def selected_item(list_widget, items):
451 """Returns the model item that corresponds to the selected QListWidget
452 row."""
453 widget_items = list_widget.selectedItems()
454 if not widget_items:
455 return None
456 widget_item = widget_items[0]
457 row = list_widget.row(widget_item)
458 if row < len(items):
459 item = items[row]
460 else:
461 item = None
462 return item
465 def selected_items(list_widget, items):
466 """Returns an array of model items that correspond to the selected
467 QListWidget rows."""
468 item_count = len(items)
469 selected = []
470 for widget_item in list_widget.selectedItems():
471 row = list_widget.row(widget_item)
472 if row < item_count:
473 selected.append(items[row])
474 return selected
477 def open_file(title, directory=None):
478 """Creates an Open File dialog and returns a filename."""
479 result = compat.getopenfilename(
480 parent=active_window(), caption=title, basedir=directory
482 return result[0]
485 def open_files(title, directory=None, filters=''):
486 """Creates an Open File dialog and returns a list of filenames."""
487 result = compat.getopenfilenames(
488 parent=active_window(), caption=title, basedir=directory, filters=filters
490 return result[0]
493 def _enum_value(value):
494 """Resolve Qt6 enum values"""
495 if hasattr(value, 'value'):
496 return value.value
497 return value
500 def opendir_dialog(caption, path):
501 """Prompts for a directory path"""
502 options = QtWidgets.QFileDialog.Option(
503 _enum_value(QtWidgets.QFileDialog.Directory)
504 | _enum_value(QtWidgets.QFileDialog.DontResolveSymlinks)
505 | _enum_value(QtWidgets.QFileDialog.ReadOnly)
506 | _enum_value(QtWidgets.QFileDialog.ShowDirsOnly)
508 return compat.getexistingdirectory(
509 parent=active_window(), caption=caption, basedir=path, options=options
513 def save_as(filename, title='Save As...'):
514 """Creates a Save File dialog and returns a filename."""
515 result = compat.getsavefilename(
516 parent=active_window(), caption=title, basedir=filename
518 return result[0]
521 def existing_file(directory, title='Append...'):
522 """Creates a Save File dialog and returns a filename."""
523 result = compat.getopenfilename(
524 parent=active_window(), caption=title, basedir=directory
526 return result[0]
529 def copy_path(filename, absolute=True):
530 """Copy a filename to the clipboard"""
531 if filename is None:
532 return
533 if absolute:
534 filename = core.abspath(filename)
535 set_clipboard(filename)
538 def set_clipboard(text):
539 """Sets the copy/paste buffer to text."""
540 if not text:
541 return
542 clipboard = QtWidgets.QApplication.clipboard()
543 clipboard.setText(text, QtGui.QClipboard.Clipboard)
544 if not utils.is_darwin() and not utils.is_win32():
545 clipboard.setText(text, QtGui.QClipboard.Selection)
546 persist_clipboard()
549 # pylint: disable=line-too-long
550 def persist_clipboard():
551 """Persist the clipboard
553 X11 stores only a reference to the clipboard data.
554 Send a clipboard event to force a copy of the clipboard to occur.
555 This ensures that the clipboard is present after git-cola exits.
556 Otherwise, the reference is destroyed on exit.
558 C.f. https://stackoverflow.com/questions/2007103/how-can-i-disable-clear-of-clipboard-on-exit-of-pyqt4-application
560 """ # noqa
561 clipboard = QtWidgets.QApplication.clipboard()
562 event = QtCore.QEvent(QtCore.QEvent.Clipboard)
563 QtWidgets.QApplication.sendEvent(clipboard, event)
566 def add_action_bool(widget, text, func, checked, *shortcuts):
567 tip = text
568 action = _add_action(widget, text, tip, func, connect_action_bool, *shortcuts)
569 action.setCheckable(True)
570 action.setChecked(checked)
571 return action
574 def add_action(widget, text, func, *shortcuts):
575 """Create a QAction and bind it to the `func` callback and hotkeys"""
576 tip = text
577 return _add_action(widget, text, tip, func, connect_action, *shortcuts)
580 def add_action_with_icon(widget, icon, text, func, *shortcuts):
581 """Create a QAction using a custom icon bound to the `func` callback and hotkeys"""
582 tip = text
583 action = _add_action(widget, text, tip, func, connect_action, *shortcuts)
584 action.setIcon(icon)
585 return action
588 def add_action_with_tooltip(widget, text, tip, func, *shortcuts):
589 """Create an action with a tooltip"""
590 return _add_action(widget, text, tip, func, connect_action, *shortcuts)
593 def menu_separator(widget, text=''):
594 """Return a QAction whose isSeparator() returns true. Used in context menus"""
595 action = QtWidgets.QAction(text, widget)
596 action.setSeparator(True)
597 return action
600 def _add_action(widget, text, tip, func, connect, *shortcuts):
601 action = QtWidgets.QAction(text, widget)
602 if hasattr(action, 'setIconVisibleInMenu'):
603 action.setIconVisibleInMenu(True)
604 if tip:
605 action.setStatusTip(tip)
606 connect(action, func)
607 if shortcuts:
608 action.setShortcuts(shortcuts)
609 if hasattr(Qt, 'WidgetWithChildrenShortcut'):
610 action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
611 widget.addAction(action)
612 return action
615 def set_selected_item(widget, idx):
616 """Sets a the currently selected item to the item at index idx."""
617 if isinstance(widget, QtWidgets.QTreeWidget):
618 item = widget.topLevelItem(idx)
619 if item:
620 item.setSelected(True)
621 widget.setCurrentItem(item)
624 def add_items(widget, items):
625 """Adds items to a widget."""
626 for item in items:
627 if item is None:
628 continue
629 widget.addItem(item)
632 def set_items(widget, items):
633 """Clear the existing widget contents and set the new items."""
634 widget.clear()
635 add_items(widget, items)
638 def create_treeitem(filename, staged=False, deleted=False, untracked=False):
639 """Given a filename, return a TreeWidgetItem for a status widget
641 "staged", "deleted, and "untracked" control which icon is used.
644 icon_name = icons.status(filename, deleted, staged, untracked)
645 icon = icons.name_from_basename(icon_name)
646 return TreeWidgetItem(filename, icon, deleted=deleted)
649 def add_close_action(widget):
650 """Adds close action and shortcuts to a widget."""
651 return add_action(widget, N_('Close...'), widget.close, hotkeys.CLOSE, hotkeys.QUIT)
654 def app():
655 """Return the current application"""
656 return QtWidgets.QApplication.instance()
659 def desktop_size():
660 rect = app().primaryScreen().geometry()
661 return (rect.width(), rect.height())
664 def center_on_screen(widget):
665 """Move widget to the center of the default screen"""
666 width, height = desktop_size()
667 center_x = width // 2
668 center_y = height // 2
669 widget.move(center_x - widget.width() // 2, center_y - widget.height() // 2)
672 def default_size(parent, width, height, use_parent_height=True):
673 """Return the parent's size, or the provided defaults"""
674 if parent is not None:
675 width = parent.width()
676 if use_parent_height:
677 height = parent.height()
678 return (width, height)
681 def default_monospace_font():
682 if utils.is_darwin():
683 family = 'Monaco'
684 elif utils.is_win32():
685 family = 'Courier'
686 else:
687 family = 'Monospace'
688 mfont = QtGui.QFont()
689 mfont.setFamily(family)
690 return mfont
693 def diff_font_str(context):
694 cfg = context.cfg
695 font_str = cfg.get(prefs.FONTDIFF)
696 if not font_str:
697 font_str = default_monospace_font().toString()
698 return font_str
701 def diff_font(context):
702 return font(diff_font_str(context))
705 def font(string):
706 qfont = QtGui.QFont()
707 qfont.fromString(string)
708 return qfont
711 def create_button(
712 text='', layout=None, tooltip=None, icon=None, enabled=True, default=False
714 """Create a button, set its title, and add it to the parent."""
715 button = QtWidgets.QPushButton()
716 button.setCursor(Qt.PointingHandCursor)
717 button.setFocusPolicy(Qt.NoFocus)
718 if text:
719 button.setText(' ' + text)
720 if icon is not None:
721 button.setIcon(icon)
722 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
723 if tooltip is not None:
724 button.setToolTip(tooltip)
725 if layout is not None:
726 layout.addWidget(button)
727 if not enabled:
728 button.setEnabled(False)
729 if default:
730 button.setDefault(True)
731 return button
734 def tool_button():
735 """Create a flat border-less button"""
736 button = QtWidgets.QToolButton()
737 button.setPopupMode(QtWidgets.QToolButton.InstantPopup)
738 button.setCursor(Qt.PointingHandCursor)
739 button.setFocusPolicy(Qt.NoFocus)
740 # Highlight colors
741 palette = QtGui.QPalette()
742 highlight = palette.color(QtGui.QPalette.Highlight)
743 highlight_rgb = rgb_css(highlight)
745 button.setStyleSheet(
747 /* No borders */
748 QToolButton {
749 border: none;
750 background-color: none;
752 /* Hide the menu indicator */
753 QToolButton::menu-indicator {
754 image: none;
756 QToolButton:hover {
757 border: %(border)spx solid %(highlight_rgb)s;
761 'border': defs.border,
762 'highlight_rgb': highlight_rgb,
765 return button
768 def create_action_button(tooltip=None, icon=None, visible=None):
769 """Create a small toolbutton for use in dock title widgets"""
770 button = tool_button()
771 if tooltip is not None:
772 button.setToolTip(tooltip)
773 if icon is not None:
774 button.setIcon(icon)
775 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
776 if visible is not None:
777 button.setVisible(visible)
778 return button
781 def ok_button(text, default=True, enabled=True, icon=None):
782 if icon is None:
783 icon = icons.ok()
784 return create_button(text=text, icon=icon, default=default, enabled=enabled)
787 def close_button(text=None, icon=None):
788 text = text or N_('Close')
789 icon = icons.mkicon(icon, icons.close)
790 return create_button(text=text, icon=icon)
793 def edit_button(enabled=True, default=False):
794 return create_button(
795 text=N_('Edit'), icon=icons.edit(), enabled=enabled, default=default
799 def refresh_button(enabled=True, default=False):
800 return create_button(
801 text=N_('Refresh'), icon=icons.sync(), enabled=enabled, default=default
805 def checkbox(text='', tooltip='', checked=None):
806 """Create a checkbox"""
807 return _checkbox(QtWidgets.QCheckBox, text, tooltip, checked)
810 def radio(text='', tooltip='', checked=None):
811 """Create a radio button"""
812 return _checkbox(QtWidgets.QRadioButton, text, tooltip, checked)
815 def _checkbox(cls, text, tooltip, checked):
816 """Create a widget and apply properties"""
817 widget = cls()
818 if text:
819 widget.setText(text)
820 if tooltip:
821 widget.setToolTip(tooltip)
822 if checked is not None:
823 widget.setChecked(checked)
824 return widget
827 class DockTitleBarWidget(QtWidgets.QFrame):
828 def __init__(self, parent, title, stretch=True):
829 QtWidgets.QFrame.__init__(self, parent)
830 self.setAutoFillBackground(True)
831 self.label = qlabel = QtWidgets.QLabel(title, self)
832 qfont = qlabel.font()
833 qfont.setBold(True)
834 qlabel.setFont(qfont)
835 qlabel.setCursor(Qt.OpenHandCursor)
837 self.close_button = create_action_button(
838 tooltip=N_('Close'), icon=icons.close()
841 self.toggle_button = create_action_button(
842 tooltip=N_('Detach'), icon=icons.external()
845 self.corner_layout = hbox(defs.no_margin, defs.spacing)
846 self.title_layout = hbox(defs.no_margin, defs.button_spacing, qlabel)
848 if stretch:
849 separator = STRETCH
850 else:
851 separator = SKIPPED
853 self.main_layout = hbox(
854 defs.small_margin,
855 defs.titlebar_spacing,
856 self.title_layout,
857 separator,
858 self.corner_layout,
859 self.toggle_button,
860 self.close_button,
862 self.setLayout(self.main_layout)
864 connect_button(self.toggle_button, self.toggle_floating)
865 connect_button(self.close_button, self.toggle_visibility)
867 def toggle_floating(self):
868 self.parent().setFloating(not self.parent().isFloating())
869 self.update_tooltips()
871 def toggle_visibility(self):
872 self.parent().toggleViewAction().trigger()
874 def set_title(self, title):
875 self.label.setText(title)
877 def add_title_widget(self, widget):
878 """Add widgets to the title area"""
879 self.title_layout.addWidget(widget)
881 def add_corner_widget(self, widget):
882 """Add widgets to the corner area"""
883 self.corner_layout.addWidget(widget)
885 def update_tooltips(self):
886 if self.parent().isFloating():
887 tooltip = N_('Attach')
888 else:
889 tooltip = N_('Detach')
890 self.toggle_button.setToolTip(tooltip)
893 def create_dock(name, title, parent, stretch=True, widget=None, func=None):
894 """Create a dock widget and set it up accordingly."""
895 dock = QtWidgets.QDockWidget(parent)
896 dock.setWindowTitle(title)
897 dock.setObjectName(name)
898 titlebar = DockTitleBarWidget(dock, title, stretch=stretch)
899 dock.setTitleBarWidget(titlebar)
900 dock.setAutoFillBackground(True)
901 if hasattr(parent, 'dockwidgets'):
902 parent.dockwidgets.append(dock)
903 if func:
904 widget = func(dock)
905 if widget:
906 dock.setWidget(widget)
907 return dock
910 def hide_dock(widget):
911 widget.toggleViewAction().setChecked(False)
912 widget.hide()
915 def create_menu(title, parent):
916 """Create a menu and set its title."""
917 qmenu = DebouncingMenu(title, parent)
918 return qmenu
921 class DebouncingMenu(QtWidgets.QMenu):
922 """Menu that debounces mouse release action ie. stops it if occurred
923 right after menu creation.
925 Disables annoying behaviour when RMB is pressed to show menu, cursor is
926 moved accidentally 1px onto newly created menu and released causing to
927 execute menu action
930 threshold_ms = 400
932 def __init__(self, title, parent):
933 QtWidgets.QMenu.__init__(self, title, parent)
934 self.created_at = utils.epoch_millis()
935 if hasattr(self, 'setToolTipsVisible'):
936 self.setToolTipsVisible(True)
938 def mouseReleaseEvent(self, event):
939 threshold = DebouncingMenu.threshold_ms
940 if (utils.epoch_millis() - self.created_at) > threshold:
941 QtWidgets.QMenu.mouseReleaseEvent(self, event)
944 def add_menu(title, parent):
945 """Create a menu and set its title."""
946 menu = create_menu(title, parent)
947 if hasattr(parent, 'addMenu'):
948 parent.addMenu(menu)
949 else:
950 parent.addAction(menu.menuAction())
951 return menu
954 def create_toolbutton(text=None, layout=None, tooltip=None, icon=None):
955 button = tool_button()
956 if icon is not None:
957 button.setIcon(icon)
958 button.setIconSize(QtCore.QSize(defs.default_icon, defs.default_icon))
959 if text is not None:
960 button.setText(' ' + text)
961 button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
962 if tooltip is not None:
963 button.setToolTip(tooltip)
964 if layout is not None:
965 layout.addWidget(button)
966 return button
969 def create_toolbutton_with_callback(callback, text, icon, tooltip, layout=None):
970 """Create a toolbutton that runs the specified callback"""
971 toolbutton = create_toolbutton(text=text, layout=layout, tooltip=tooltip, icon=icon)
972 connect_button(toolbutton, callback)
973 return toolbutton
976 # pylint: disable=line-too-long
977 def mimedata_from_paths(context, paths, include_urls=True):
978 """Return mimedata with a list of absolute path URLs
980 Set `include_urls` to False to prevent URLs from being included
981 in the mimedata. This is useful in some terminals that do not gracefully handle
982 multiple URLs being included in the payload.
984 This allows the mimedata to contain just plain a plain text value that we
985 are able to format ourselves.
987 Older verisons of gnome-terminal expected a utf-16 encoding, but that
988 behavior is no longer needed.
989 """ # noqa
990 abspaths = [core.abspath(path) for path in paths]
991 paths_text = core.list2cmdline(abspaths)
993 # The text/x-moz-list format is always included by Qt, and doing
994 # mimedata.removeFormat('text/x-moz-url') has no effect.
995 # http://www.qtcentre.org/threads/44643-Dragging-text-uri-list-Qt-inserts-garbage
997 # Older versions of gnome-terminal expect utf-16 encoded text, but other terminals,
998 # e.g. terminator, expect utf-8, so use cola.dragencoding to override the default.
999 # NOTE: text/x-moz-url does not seem to be used/needed by modern versions of
1000 # gnome-terminal, kitty, and terminator.
1001 mimedata = QtCore.QMimeData()
1002 mimedata.setText(paths_text)
1003 if include_urls:
1004 urls = [QtCore.QUrl.fromLocalFile(path) for path in abspaths]
1005 encoding = context.cfg.get('cola.dragencoding', 'utf-16')
1006 encoded_text = core.encode(paths_text, encoding=encoding)
1007 mimedata.setUrls(urls)
1008 mimedata.setData('text/x-moz-url', encoded_text)
1010 return mimedata
1013 def path_mimetypes(include_urls=True):
1014 """Return a list of mimetypes that we generate"""
1015 mime_types = [
1016 'text/plain',
1017 'text/plain;charset=utf-8',
1019 if include_urls:
1020 mime_types.append('text/uri-list')
1021 mime_types.append('text/x-moz-url')
1022 return mime_types
1025 class BlockSignals:
1026 """Context manager for blocking a signals on a widget"""
1028 def __init__(self, *widgets):
1029 self.widgets = widgets
1030 self.values = []
1032 def __enter__(self):
1033 """Block Qt signals for all of the captured widgets"""
1034 self.values = [widget.blockSignals(True) for widget in self.widgets]
1035 return self
1037 def __exit__(self, exc_type, exc_val, exc_tb):
1038 """Restore Qt signals when we exit the scope"""
1039 for widget, value in zip(self.widgets, self.values):
1040 widget.blockSignals(value)
1043 class Channel(QtCore.QObject):
1044 finished = Signal(object)
1045 result = Signal(object)
1048 class Task(QtCore.QRunnable):
1049 """Run a task in the background and return the result using a Channel"""
1051 def __init__(self):
1052 QtCore.QRunnable.__init__(self)
1054 self.channel = Channel()
1055 self.result = None
1056 # Python's garbage collector will try to double-free the task
1057 # once it's finished, so disable Qt's auto-deletion as a workaround.
1058 self.setAutoDelete(False)
1060 def run(self):
1061 self.result = self.task()
1062 self.channel.result.emit(self.result)
1063 self.channel.finished.emit(self)
1065 def task(self):
1066 """Perform a long-running task"""
1067 return ()
1069 def connect(self, handler):
1070 self.channel.result.connect(handler, type=Qt.QueuedConnection)
1073 class SimpleTask(Task):
1074 """Run a simple callable as a task"""
1076 def __init__(self, func, *args, **kwargs):
1077 Task.__init__(self)
1079 self.func = func
1080 self.args = args
1081 self.kwargs = kwargs
1083 def task(self):
1084 return self.func(*self.args, **self.kwargs)
1087 class RunTask(QtCore.QObject):
1088 """Runs QRunnable instances and transfers control when they finish"""
1090 def __init__(self, parent=None):
1091 QtCore.QObject.__init__(self, parent)
1092 self.tasks = []
1093 self.task_details = {}
1094 self.threadpool = QtCore.QThreadPool.globalInstance()
1095 self.result_func = None
1097 def start(self, task, progress=None, finish=None, result=None):
1098 """Start the task and register a callback"""
1099 self.result_func = result
1100 if progress is not None:
1101 if hasattr(progress, 'start'):
1102 progress.start()
1104 # prevents garbage collection bugs in certain PyQt4 versions
1105 self.tasks.append(task)
1106 task_id = id(task)
1107 self.task_details[task_id] = (progress, finish, result)
1108 task.channel.finished.connect(self.finish, type=Qt.QueuedConnection)
1109 self.threadpool.start(task)
1111 def finish(self, task):
1112 """The task has finished. Run the finish and result callbacks"""
1113 task_id = id(task)
1114 try:
1115 self.tasks.remove(task)
1116 except ValueError:
1117 pass
1118 try:
1119 progress, finish, result = self.task_details[task_id]
1120 del self.task_details[task_id]
1121 except KeyError:
1122 finish = progress = result = None
1124 if progress is not None:
1125 if hasattr(progress, 'stop'):
1126 progress.stop()
1127 progress.hide()
1129 if result is not None:
1130 result(task.result)
1132 if finish is not None:
1133 finish(task)
1136 # Syntax highlighting
1139 def rgb(red, green, blue):
1140 """Create a QColor from r, g, b arguments"""
1141 color = QtGui.QColor()
1142 color.setRgb(red, green, blue)
1143 return color
1146 def rgba(red, green, blue, alpha=255):
1147 """Create a QColor with alpha from r, g, b, a arguments"""
1148 color = rgb(red, green, blue)
1149 color.setAlpha(alpha)
1150 return color
1153 def rgb_triple(args):
1154 """Create a QColor from an argument with an [r, g, b] triple"""
1155 return rgb(*args)
1158 def rgb_css(color):
1159 """Convert a QColor into an rgb #abcdef CSS string"""
1160 return '#%s' % rgb_hex(color)
1163 def rgb_hex(color):
1164 """Convert a QColor into a hex aabbcc string"""
1165 return f'{color.red():02x}{color.green():02x}{color.blue():02x}'
1168 def clamp_color(value):
1169 """Clamp an integer value between 0 and 255"""
1170 return min(255, max(value, 0))
1173 def css_color(value):
1174 """Convert a #abcdef hex string into a QColor"""
1175 if value.startswith('#'):
1176 value = value[1:]
1177 try:
1178 red = clamp_color(int(value[:2], base=16)) # ab
1179 except ValueError:
1180 red = 255
1181 try:
1182 green = clamp_color(int(value[2:4], base=16)) # cd
1183 except ValueError:
1184 green = 255
1185 try:
1186 blue = clamp_color(int(value[4:6], base=16)) # ef
1187 except ValueError:
1188 blue = 255
1189 return rgb(red, green, blue)
1192 def hsl(hue, saturation, lightness):
1193 """Return a QColor from an hue, saturation and lightness"""
1194 return QtGui.QColor.fromHslF(
1195 utils.clamp(hue, 0.0, 1.0),
1196 utils.clamp(saturation, 0.0, 1.0),
1197 utils.clamp(lightness, 0.0, 1.0),
1201 def hsl_css(hue, saturation, lightness):
1202 """Convert HSL values to a CSS #abcdef color string"""
1203 return rgb_css(hsl(hue, saturation, lightness))
1206 def make_format(foreground=None, background=None, bold=False):
1207 """Create a QTextFormat from the provided foreground, background and bold values"""
1208 fmt = QtGui.QTextCharFormat()
1209 if foreground:
1210 fmt.setForeground(foreground)
1211 if background:
1212 fmt.setBackground(background)
1213 if bold:
1214 fmt.setFontWeight(QtGui.QFont.Bold)
1215 return fmt
1218 class ImageFormats:
1219 def __init__(self):
1220 # returns a list of QByteArray objects
1221 formats_qba = QtGui.QImageReader.supportedImageFormats()
1222 # portability: python3 data() returns bytes, python2 returns str
1223 decode = core.decode
1224 formats = [decode(x.data()) for x in formats_qba]
1225 self.extensions = {'.' + fmt for fmt in formats}
1227 def ok(self, filename):
1228 _, ext = os.path.splitext(filename)
1229 return ext.lower() in self.extensions
1232 def set_scrollbar_values(widget, hscroll_value, vscroll_value):
1233 """Set scrollbars to the specified values"""
1234 hscroll = widget.horizontalScrollBar()
1235 if hscroll and hscroll_value is not None:
1236 hscroll.setValue(hscroll_value)
1238 vscroll = widget.verticalScrollBar()
1239 if vscroll and vscroll_value is not None:
1240 vscroll.setValue(vscroll_value)
1243 def get_scrollbar_values(widget):
1244 """Return the current (hscroll, vscroll) scrollbar values for a widget"""
1245 hscroll = widget.horizontalScrollBar()
1246 if hscroll:
1247 hscroll_value = get(hscroll)
1248 else:
1249 hscroll_value = None
1250 vscroll = widget.verticalScrollBar()
1251 if vscroll:
1252 vscroll_value = get(vscroll)
1253 else:
1254 vscroll_value = None
1255 return (hscroll_value, vscroll_value)
1258 def scroll_to_item(widget, item):
1259 """Scroll to an item while retaining the horizontal scroll position"""
1260 hscroll = None
1261 hscrollbar = widget.horizontalScrollBar()
1262 if hscrollbar:
1263 hscroll = get(hscrollbar)
1264 widget.scrollToItem(item)
1265 if hscroll is not None:
1266 hscrollbar.setValue(hscroll)
1269 def select_item(widget, item):
1270 """Scroll to and make a QTreeWidget item selected and current"""
1271 scroll_to_item(widget, item)
1272 widget.setCurrentItem(item)
1273 item.setSelected(True)
1276 def get_selected_values(widget, top_level_idx, values):
1277 """Map the selected items under the top-level item to the values list"""
1278 # Get the top-level item
1279 item = widget.topLevelItem(top_level_idx)
1280 return tree_selection(item, values)
1283 def get_selected_items(widget, idx):
1284 """Return the selected items under the top-level item"""
1285 item = widget.topLevelItem(idx)
1286 return tree_selection_items(item)
1289 def add_menu_actions(menu, menu_actions):
1290 """Add actions to a menu, treating None as a separator"""
1291 current_actions = menu.actions()
1292 if current_actions:
1293 first_action = current_actions[0]
1294 else:
1295 first_action = None
1296 menu.addSeparator()
1298 for action in menu_actions:
1299 if action is None:
1300 action = menu_separator(menu)
1301 menu.insertAction(first_action, action)