fetch: add support for the traditional FETCH_HEAD behavior
[git-cola.git] / cola / qtutils.py
blob2e1f50a6a1f5b262a37cd0f54d89203c8737c275
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 splitter 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 combo box 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) combo box 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) combo box 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 min_width = min(720, text_width(dialog.font(), long_value) + 100)
335 dialog.setMinimumWidth(min_width)
337 ok_b = ok_button(msg, enabled=False)
338 close_b = close_button()
340 form_widgets = []
342 def get_values():
343 return [pair[1].text().strip() for pair in form_widgets]
345 for name, value in inputs:
346 lineedit = QtWidgets.QLineEdit()
347 # Enable the OK button only when all fields have been populated
348 lineedit.textChanged.connect(
349 lambda x: ok_b.setEnabled(all(get_values())), type=Qt.QueuedConnection
351 if value:
352 lineedit.setText(value)
353 form_widgets.append((name, lineedit))
355 # layouts
356 form_layout = form(defs.no_margin, defs.button_spacing, *form_widgets)
357 button_layout = hbox(defs.no_margin, defs.button_spacing, STRETCH, close_b, ok_b)
358 main_layout = vbox(defs.margin, defs.button_spacing, form_layout, button_layout)
359 dialog.setLayout(main_layout)
361 # connections
362 connect_button(ok_b, dialog.accept)
363 connect_button(close_b, dialog.reject)
365 accepted = dialog.exec_() == QtWidgets.QDialog.Accepted
366 text = get_values()
367 success = accepted and all(text)
368 return (success, text)
371 def standard_item_type_value(value):
372 """Return a custom UserType for use in QTreeWidgetItem.type() overrides"""
373 return custom_item_type_value(QtGui.QStandardItem, value)
376 def graphics_item_type_value(value):
377 """Return a custom UserType for use in QGraphicsItem.type() overrides"""
378 return custom_item_type_value(QtWidgets.QGraphicsItem, value)
381 def custom_item_type_value(cls, value):
382 """Return a custom cls.UserType for use in cls.type() overrides"""
383 user_type = enum_value(cls.UserType)
384 return user_type + value
387 def enum_value(value):
388 """Qt6 has enums with an inner '.value' attribute."""
389 if hasattr(value, 'value'):
390 value = value.value
391 return value
394 class TreeWidgetItem(QtWidgets.QTreeWidgetItem):
395 TYPE = standard_item_type_value(101)
397 def __init__(self, path, icon, deleted):
398 QtWidgets.QTreeWidgetItem.__init__(self)
399 self.path = path
400 self.deleted = deleted
401 self.setIcon(0, icons.from_name(icon))
402 self.setText(0, path)
404 def type(self):
405 return self.TYPE
408 def paths_from_indexes(model, indexes, item_type=TreeWidgetItem.TYPE, item_filter=None):
409 """Return paths from a list of QStandardItemModel indexes"""
410 items = [model.itemFromIndex(i) for i in indexes]
411 return paths_from_items(items, item_type=item_type, item_filter=item_filter)
414 def _true_filter(_value):
415 return True
418 def paths_from_items(items, item_type=TreeWidgetItem.TYPE, item_filter=None):
419 """Return a list of paths from a list of items"""
420 if item_filter is None:
421 item_filter = _true_filter
422 return [i.path for i in items if i.type() == item_type and item_filter(i)]
425 def tree_selection(tree_item, items):
426 """Returns an array of model items that correspond to the selected
427 QTreeWidgetItem children"""
428 selected = []
429 count = min(tree_item.childCount(), len(items))
430 for idx in range(count):
431 if tree_item.child(idx).isSelected():
432 selected.append(items[idx])
434 return selected
437 def tree_selection_items(tree_item):
438 """Returns selected widget items"""
439 selected = []
440 for idx in range(tree_item.childCount()):
441 child = tree_item.child(idx)
442 if child.isSelected():
443 selected.append(child)
445 return selected
448 def selected_item(list_widget, items):
449 """Returns the model item that corresponds to the selected QListWidget
450 row."""
451 widget_items = list_widget.selectedItems()
452 if not widget_items:
453 return None
454 widget_item = widget_items[0]
455 row = list_widget.row(widget_item)
456 if row < len(items):
457 item = items[row]
458 else:
459 item = None
460 return item
463 def selected_items(list_widget, items):
464 """Returns an array of model items that correspond to the selected
465 QListWidget rows."""
466 item_count = len(items)
467 selected = []
468 for widget_item in list_widget.selectedItems():
469 row = list_widget.row(widget_item)
470 if row < item_count:
471 selected.append(items[row])
472 return selected
475 def open_file(title, directory=None):
476 """Creates an Open File dialog and returns a filename."""
477 result = compat.getopenfilename(
478 parent=active_window(), caption=title, basedir=directory
480 return result[0]
483 def open_files(title, directory=None, filters=''):
484 """Creates an Open File dialog and returns a list of filenames."""
485 result = compat.getopenfilenames(
486 parent=active_window(), caption=title, basedir=directory, filters=filters
488 return result[0]
491 def _enum_value(value):
492 """Resolve Qt6 enum values"""
493 if hasattr(value, 'value'):
494 return value.value
495 return value
498 def opendir_dialog(caption, path):
499 """Prompts for a directory path"""
500 options = QtWidgets.QFileDialog.Option(
501 _enum_value(QtWidgets.QFileDialog.Directory)
502 | _enum_value(QtWidgets.QFileDialog.DontResolveSymlinks)
503 | _enum_value(QtWidgets.QFileDialog.ReadOnly)
504 | _enum_value(QtWidgets.QFileDialog.ShowDirsOnly)
506 return compat.getexistingdirectory(
507 parent=active_window(), caption=caption, basedir=path, options=options
511 def save_as(filename, title='Save As...'):
512 """Creates a Save File dialog and returns a filename."""
513 result = compat.getsavefilename(
514 parent=active_window(), caption=title, basedir=filename
516 return result[0]
519 def existing_file(directory, title='Append...'):
520 """Creates a Save File dialog and returns a filename."""
521 result = compat.getopenfilename(
522 parent=active_window(), caption=title, basedir=directory
524 return result[0]
527 def copy_path(filename, absolute=True):
528 """Copy a filename to the clipboard"""
529 if filename is None:
530 return
531 if absolute:
532 filename = core.abspath(filename)
533 set_clipboard(filename)
536 def set_clipboard(text):
537 """Sets the copy/paste buffer to text."""
538 if not text:
539 return
540 clipboard = QtWidgets.QApplication.clipboard()
541 clipboard.setText(text, QtGui.QClipboard.Clipboard)
542 if not utils.is_darwin() and not utils.is_win32():
543 clipboard.setText(text, QtGui.QClipboard.Selection)
544 persist_clipboard()
547 def persist_clipboard():
548 """Persist the clipboard
550 X11 stores only a reference to the clipboard data.
551 Send a clipboard event to force a copy of the clipboard to occur.
552 This ensures that the clipboard is present after git-cola exits.
553 Otherwise, the reference is destroyed on exit.
555 C.f. https://stackoverflow.com/questions/2007103/how-can-i-disable-clear-of-clipboard-on-exit-of-pyqt4-application
557 """ # noqa
558 clipboard = QtWidgets.QApplication.clipboard()
559 event = QtCore.QEvent(QtCore.QEvent.Clipboard)
560 QtWidgets.QApplication.sendEvent(clipboard, event)
563 def add_action_bool(widget, text, func, checked, *shortcuts):
564 tip = text
565 action = _add_action(widget, text, tip, func, connect_action_bool, *shortcuts)
566 action.setCheckable(True)
567 action.setChecked(checked)
568 return action
571 def add_action(widget, text, func, *shortcuts):
572 """Create a QAction and bind it to the `func` callback and hotkeys"""
573 tip = text
574 return _add_action(widget, text, tip, func, connect_action, *shortcuts)
577 def add_action_with_icon(widget, icon, text, func, *shortcuts):
578 """Create a QAction using a custom icon bound to the `func` callback and hotkeys"""
579 tip = text
580 action = _add_action(widget, text, tip, func, connect_action, *shortcuts)
581 action.setIcon(icon)
582 return action
585 def add_action_with_tooltip(widget, text, tip, func, *shortcuts):
586 """Create an action with a tooltip"""
587 return _add_action(widget, text, tip, func, connect_action, *shortcuts)
590 def menu_separator(widget, text=''):
591 """Return a QAction whose isSeparator() returns true. Used in context menus"""
592 action = QtWidgets.QAction(text, widget)
593 action.setSeparator(True)
594 return action
597 def _add_action(widget, text, tip, func, connect, *shortcuts):
598 action = QtWidgets.QAction(text, widget)
599 if hasattr(action, 'setIconVisibleInMenu'):
600 action.setIconVisibleInMenu(True)
601 if tip:
602 action.setStatusTip(tip)
603 connect(action, func)
604 if shortcuts:
605 action.setShortcuts(shortcuts)
606 if hasattr(Qt, 'WidgetWithChildrenShortcut'):
607 action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
608 widget.addAction(action)
609 return action
612 def set_selected_item(widget, idx):
613 """Sets the currently selected item to the item at index idx."""
614 if isinstance(widget, QtWidgets.QTreeWidget):
615 item = widget.topLevelItem(idx)
616 if item:
617 item.setSelected(True)
618 widget.setCurrentItem(item)
621 def add_items(widget, items):
622 """Adds items to a widget."""
623 for item in items:
624 if item is None:
625 continue
626 widget.addItem(item)
629 def set_items(widget, items):
630 """Clear the existing widget contents and set the new items."""
631 widget.clear()
632 add_items(widget, items)
635 def create_treeitem(filename, staged=False, deleted=False, untracked=False):
636 """Given a filename, return a TreeWidgetItem for a status widget
638 "staged", "deleted, and "untracked" control which icon is used.
641 icon_name = icons.status(filename, deleted, staged, untracked)
642 icon = icons.name_from_basename(icon_name)
643 return TreeWidgetItem(filename, icon, deleted=deleted)
646 def add_close_action(widget):
647 """Adds close action and shortcuts to a widget."""
648 return add_action(widget, N_('Close...'), widget.close, hotkeys.CLOSE, hotkeys.QUIT)
651 def app():
652 """Return the current application"""
653 return QtWidgets.QApplication.instance()
656 def desktop_size():
657 rect = app().primaryScreen().geometry()
658 return (rect.width(), rect.height())
661 def center_on_screen(widget):
662 """Move widget to the center of the default screen"""
663 width, height = desktop_size()
664 center_x = width // 2
665 center_y = height // 2
666 widget.move(center_x - widget.width() // 2, center_y - widget.height() // 2)
669 def default_size(parent, width, height, use_parent_height=True):
670 """Return the parent's size, or the provided defaults"""
671 if parent is not None:
672 width = parent.width()
673 if use_parent_height:
674 height = parent.height()
675 return (width, height)
678 def default_monospace_font():
679 if utils.is_darwin():
680 family = 'Monaco'
681 elif utils.is_win32():
682 family = 'Courier'
683 else:
684 family = 'Monospace'
685 mfont = QtGui.QFont()
686 mfont.setFamily(family)
687 return mfont
690 def diff_font_str(context):
691 cfg = context.cfg
692 font_str = cfg.get(prefs.FONTDIFF)
693 if not font_str:
694 font_str = default_monospace_font().toString()
695 return font_str
698 def diff_font(context):
699 return font_from_string(diff_font_str(context))
702 def font_from_string(string):
703 qfont = QtGui.QFont()
704 qfont.fromString(string)
705 return qfont
708 def create_button(
709 text='', layout=None, tooltip=None, icon=None, enabled=True, default=False
711 """Create a button, set its title, and add it to the parent."""
712 button = QtWidgets.QPushButton()
713 button.setCursor(Qt.PointingHandCursor)
714 button.setFocusPolicy(Qt.NoFocus)
715 if text:
716 button.setText(' ' + text)
717 if icon is not None:
718 button.setIcon(icon)
719 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
720 if tooltip is not None:
721 button.setToolTip(tooltip)
722 if layout is not None:
723 layout.addWidget(button)
724 if not enabled:
725 button.setEnabled(False)
726 if default:
727 button.setDefault(True)
728 return button
731 def tool_button():
732 """Create a flat border-less button"""
733 button = QtWidgets.QToolButton()
734 button.setPopupMode(QtWidgets.QToolButton.InstantPopup)
735 button.setCursor(Qt.PointingHandCursor)
736 button.setFocusPolicy(Qt.NoFocus)
737 # Highlight colors
738 palette = QtGui.QPalette()
739 highlight = palette.color(QtGui.QPalette.Highlight)
740 highlight_rgb = rgb_css(highlight)
742 button.setStyleSheet(
744 /* No borders */
745 QToolButton {
746 border: none;
747 background-color: none;
749 /* Hide the menu indicator */
750 QToolButton::menu-indicator {
751 image: none;
753 QToolButton:hover {
754 border: %(border)spx solid %(highlight_rgb)s;
758 'border': defs.border,
759 'highlight_rgb': highlight_rgb,
762 return button
765 def create_action_button(tooltip=None, icon=None, visible=None):
766 """Create a small tool button for use in dock title widgets"""
767 button = tool_button()
768 if tooltip is not None:
769 button.setToolTip(tooltip)
770 if icon is not None:
771 button.setIcon(icon)
772 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
773 if visible is not None:
774 button.setVisible(visible)
775 return button
778 def ok_button(text, default=True, enabled=True, icon=None):
779 if icon is None:
780 icon = icons.ok()
781 return create_button(text=text, icon=icon, default=default, enabled=enabled)
784 def close_button(text=None, icon=None):
785 text = text or N_('Close')
786 icon = icons.mkicon(icon, icons.close)
787 return create_button(text=text, icon=icon)
790 def edit_button(enabled=True, default=False):
791 return create_button(
792 text=N_('Edit'), icon=icons.edit(), enabled=enabled, default=default
796 def refresh_button(enabled=True, default=False):
797 return create_button(
798 text=N_('Refresh'), icon=icons.sync(), enabled=enabled, default=default
802 def checkbox(text='', tooltip='', checked=None):
803 """Create a checkbox"""
804 return _checkbox(QtWidgets.QCheckBox, text, tooltip, checked)
807 def radio(text='', tooltip='', checked=None):
808 """Create a radio button"""
809 return _checkbox(QtWidgets.QRadioButton, text, tooltip, checked)
812 def _checkbox(cls, text, tooltip, checked):
813 """Create a widget and apply properties"""
814 widget = cls()
815 if text:
816 widget.setText(text)
817 if tooltip:
818 widget.setToolTip(tooltip)
819 if checked is not None:
820 widget.setChecked(checked)
821 return widget
824 class DockTitleBarWidget(QtWidgets.QFrame):
825 def __init__(self, parent, title, stretch=True):
826 QtWidgets.QFrame.__init__(self, parent)
827 self.setAutoFillBackground(True)
828 self.label = qlabel = QtWidgets.QLabel(title, self)
829 qfont = qlabel.font()
830 qfont.setBold(True)
831 qlabel.setFont(qfont)
832 qlabel.setCursor(Qt.OpenHandCursor)
834 self.close_button = create_action_button(
835 tooltip=N_('Close'), icon=icons.close()
838 self.toggle_button = create_action_button(
839 tooltip=N_('Detach'), icon=icons.external()
842 self.corner_layout = hbox(defs.no_margin, defs.spacing)
843 self.title_layout = hbox(defs.no_margin, defs.button_spacing, qlabel)
845 if stretch:
846 separator = STRETCH
847 else:
848 separator = SKIPPED
850 self.main_layout = hbox(
851 defs.small_margin,
852 defs.titlebar_spacing,
853 self.title_layout,
854 separator,
855 self.corner_layout,
856 self.toggle_button,
857 self.close_button,
859 self.setLayout(self.main_layout)
861 connect_button(self.toggle_button, self.toggle_floating)
862 connect_button(self.close_button, self.toggle_visibility)
864 def toggle_floating(self):
865 self.parent().setFloating(not self.parent().isFloating())
866 self.update_tooltips()
868 def toggle_visibility(self):
869 self.parent().toggleViewAction().trigger()
871 def set_title(self, title):
872 self.label.setText(title)
874 def add_title_widget(self, widget):
875 """Add widgets to the title area"""
876 self.title_layout.addWidget(widget)
878 def add_corner_widget(self, widget):
879 """Add widgets to the corner area"""
880 self.corner_layout.addWidget(widget)
882 def update_tooltips(self):
883 if self.parent().isFloating():
884 tooltip = N_('Attach')
885 else:
886 tooltip = N_('Detach')
887 self.toggle_button.setToolTip(tooltip)
890 def create_dock(name, title, parent, stretch=True, widget=None, func=None):
891 """Create a dock widget and set it up accordingly."""
892 dock = QtWidgets.QDockWidget(parent)
893 dock.setWindowTitle(title)
894 dock.setObjectName(name)
895 titlebar = DockTitleBarWidget(dock, title, stretch=stretch)
896 dock.setTitleBarWidget(titlebar)
897 dock.setAutoFillBackground(True)
898 if hasattr(parent, 'dockwidgets'):
899 parent.dockwidgets.append(dock)
900 if func:
901 widget = func(dock)
902 if widget:
903 dock.setWidget(widget)
904 return dock
907 def hide_dock(widget):
908 widget.toggleViewAction().setChecked(False)
909 widget.hide()
912 def create_menu(title, parent):
913 """Create a menu and set its title."""
914 qmenu = DebouncingMenu(title, parent)
915 return qmenu
918 class DebouncingMenu(QtWidgets.QMenu):
919 """Menu that debounces mouse release action i.e. stops it if occurred
920 right after menu creation.
922 Disables annoying behaviour when RMB is pressed to show menu, cursor is
923 moved accidentally 1 px onto newly created menu and released causing to
924 execute menu action
927 threshold_ms = 400
929 def __init__(self, title, parent):
930 QtWidgets.QMenu.__init__(self, title, parent)
931 self.created_at = utils.epoch_millis()
932 if hasattr(self, 'setToolTipsVisible'):
933 self.setToolTipsVisible(True)
935 def mouseReleaseEvent(self, event):
936 threshold = DebouncingMenu.threshold_ms
937 if (utils.epoch_millis() - self.created_at) > threshold:
938 QtWidgets.QMenu.mouseReleaseEvent(self, event)
941 def add_menu(title, parent):
942 """Create a menu and set its title."""
943 menu = create_menu(title, parent)
944 if hasattr(parent, 'addMenu'):
945 parent.addMenu(menu)
946 else:
947 parent.addAction(menu.menuAction())
948 return menu
951 def create_toolbutton(text=None, layout=None, tooltip=None, icon=None):
952 button = tool_button()
953 if icon is not None:
954 button.setIcon(icon)
955 button.setIconSize(QtCore.QSize(defs.default_icon, defs.default_icon))
956 if text is not None:
957 button.setText(' ' + text)
958 button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
959 if tooltip is not None:
960 button.setToolTip(tooltip)
961 if layout is not None:
962 layout.addWidget(button)
963 return button
966 def create_toolbutton_with_callback(callback, text, icon, tooltip, layout=None):
967 """Create a tool button that runs the specified callback"""
968 toolbutton = create_toolbutton(text=text, layout=layout, tooltip=tooltip, icon=icon)
969 connect_button(toolbutton, callback)
970 return toolbutton
973 def mimedata_from_paths(context, paths, include_urls=True):
974 """Return mime data with a list of absolute path URLs
976 Set `include_urls` to False to prevent URLs from being included
977 in the mime data. This is useful in some terminals that do not gracefully handle
978 multiple URLs being included in the payload.
980 This allows the mime data to contain just plain a plain text value that we
981 are able to format ourselves.
983 Older versions of gnome-terminal expected a UTF-16 encoding, but that
984 behavior is no longer needed.
985 """ # noqa
986 abspaths = [core.abspath(path) for path in paths]
987 paths_text = core.list2cmdline(abspaths)
989 # The text/x-moz-list format is always included by Qt, and doing
990 # mimedata.removeFormat('text/x-moz-url') has no effect.
991 # http://www.qtcentre.org/threads/44643-Dragging-text-uri-list-Qt-inserts-garbage
993 # Older versions of gnome-terminal expect UTF-16 encoded text, but other terminals,
994 # e.g. terminator, expect UTF-8, so use cola.dragencoding to override the default.
995 # NOTE: text/x-moz-url does not seem to be used/needed by modern versions of
996 # gnome-terminal, kitty, and terminator.
997 mimedata = QtCore.QMimeData()
998 mimedata.setText(paths_text)
999 if include_urls:
1000 urls = [QtCore.QUrl.fromLocalFile(path) for path in abspaths]
1001 encoding = context.cfg.get('cola.dragencoding', 'utf-16')
1002 encoded_text = core.encode(paths_text, encoding=encoding)
1003 mimedata.setUrls(urls)
1004 mimedata.setData('text/x-moz-url', encoded_text)
1006 return mimedata
1009 def path_mimetypes(include_urls=True):
1010 """Return a list of mime types that we generate"""
1011 mime_types = [
1012 'text/plain',
1013 'text/plain;charset=utf-8',
1015 if include_urls:
1016 mime_types.append('text/uri-list')
1017 mime_types.append('text/x-moz-url')
1018 return mime_types
1021 class BlockSignals:
1022 """Context manager for blocking a signals on a widget"""
1024 def __init__(self, *widgets):
1025 self.widgets = widgets
1026 self.values = []
1028 def __enter__(self):
1029 """Block Qt signals for all of the captured widgets"""
1030 self.values = [widget.blockSignals(True) for widget in self.widgets]
1031 return self
1033 def __exit__(self, exc_type, exc_val, exc_tb):
1034 """Restore Qt signals when we exit the scope"""
1035 for widget, value in zip(self.widgets, self.values):
1036 widget.blockSignals(value)
1039 class Channel(QtCore.QObject):
1040 finished = Signal(object)
1041 result = Signal(object)
1044 class Task(QtCore.QRunnable):
1045 """Run a task in the background and return the result using a Channel"""
1047 def __init__(self):
1048 QtCore.QRunnable.__init__(self)
1050 self.channel = Channel()
1051 self.result = None
1052 # Python's garbage collector will try to double-free the task
1053 # once it's finished so disable the Qt auto-deletion.
1054 self.setAutoDelete(False)
1056 def run(self):
1057 self.result = self.task()
1058 self.channel.result.emit(self.result)
1059 self.channel.finished.emit(self)
1061 def task(self):
1062 """Perform a long-running task"""
1063 return ()
1065 def connect(self, handler):
1066 self.channel.result.connect(handler, type=Qt.QueuedConnection)
1069 class SimpleTask(Task):
1070 """Run a simple callable as a task"""
1072 def __init__(self, func, *args, **kwargs):
1073 Task.__init__(self)
1075 self.func = func
1076 self.args = args
1077 self.kwargs = kwargs
1079 def task(self):
1080 return self.func(*self.args, **self.kwargs)
1083 class RunTask(QtCore.QObject):
1084 """Runs QRunnable instances and transfers control when they finish"""
1086 def __init__(self, parent=None):
1087 QtCore.QObject.__init__(self, parent)
1088 self.tasks = []
1089 self.task_details = {}
1090 self.threadpool = QtCore.QThreadPool.globalInstance()
1091 self.result_func = None
1093 def start(self, task, progress=None, finish=None, result=None):
1094 """Start the task and register a callback"""
1095 self.result_func = result
1096 if progress is not None:
1097 if hasattr(progress, 'start'):
1098 progress.start()
1100 # prevents garbage collection bugs in certain PyQt4 versions
1101 self.tasks.append(task)
1102 task_id = id(task)
1103 self.task_details[task_id] = (progress, finish, result)
1104 task.channel.finished.connect(self.finish, type=Qt.QueuedConnection)
1105 self.threadpool.start(task)
1107 def finish(self, task):
1108 """The task has finished. Run the finish and result callbacks"""
1109 task_id = id(task)
1110 try:
1111 self.tasks.remove(task)
1112 except ValueError:
1113 pass
1114 try:
1115 progress, finish, result = self.task_details[task_id]
1116 del self.task_details[task_id]
1117 except KeyError:
1118 finish = progress = result = None
1120 if progress is not None:
1121 if hasattr(progress, 'stop'):
1122 progress.stop()
1123 progress.hide()
1125 if result is not None:
1126 result(task.result)
1128 if finish is not None:
1129 finish(task)
1131 def wait(self):
1132 """Wait until all tasks have finished processing"""
1133 self.threadpool.waitForDone()
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)
1304 def fontmetrics_width(metrics, text):
1305 """Get the width in pixels of specified text
1307 Calls QFontMetrics.horizontalAdvance() when available.
1308 QFontMetricswidth() is deprecated. Qt 5.11 added horizontalAdvance().
1310 if hasattr(metrics, 'horizontalAdvance'):
1311 return metrics.horizontalAdvance(text)
1312 return metrics.width(text)
1315 def text_width(font, text):
1316 """Get the width in pixels for the QFont and text"""
1317 metrics = QtGui.QFontMetrics(font)
1318 return fontmetrics_width(metrics, text)
1321 def text_size(font, text):
1322 """Return the width in pixels for the specified text
1324 :param font_or_widget: The QFont or widget providing the font to use.
1325 :param text: The text to measure.
1327 metrics = QtGui.QFontMetrics(font)
1328 return (fontmetrics_width(metrics, text), metrics.height())