completion: display completions when Ctrl-Space is pressed
[git-cola.git] / cola / qtutils.py
blobdfe7c1de812728df519d02084d1288992ad20f16
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)
847 if stretch:
848 separator = STRETCH
849 else:
850 separator = SKIPPED
852 self.main_layout = hbox(
853 defs.small_margin,
854 defs.titlebar_spacing,
855 qlabel,
856 separator,
857 self.corner_layout,
858 self.toggle_button,
859 self.close_button,
861 self.setLayout(self.main_layout)
863 connect_button(self.toggle_button, self.toggle_floating)
864 connect_button(self.close_button, self.toggle_visibility)
866 def toggle_floating(self):
867 self.parent().setFloating(not self.parent().isFloating())
868 self.update_tooltips()
870 def toggle_visibility(self):
871 self.parent().toggleViewAction().trigger()
873 def set_title(self, title):
874 self.label.setText(title)
876 def add_corner_widget(self, widget):
877 self.corner_layout.addWidget(widget)
879 def update_tooltips(self):
880 if self.parent().isFloating():
881 tooltip = N_('Attach')
882 else:
883 tooltip = N_('Detach')
884 self.toggle_button.setToolTip(tooltip)
887 def create_dock(name, title, parent, stretch=True, widget=None, func=None):
888 """Create a dock widget and set it up accordingly."""
889 dock = QtWidgets.QDockWidget(parent)
890 dock.setWindowTitle(title)
891 dock.setObjectName(name)
892 titlebar = DockTitleBarWidget(dock, title, stretch=stretch)
893 dock.setTitleBarWidget(titlebar)
894 dock.setAutoFillBackground(True)
895 if hasattr(parent, 'dockwidgets'):
896 parent.dockwidgets.append(dock)
897 if func:
898 widget = func(dock)
899 if widget:
900 dock.setWidget(widget)
901 return dock
904 def hide_dock(widget):
905 widget.toggleViewAction().setChecked(False)
906 widget.hide()
909 def create_menu(title, parent):
910 """Create a menu and set its title."""
911 qmenu = DebouncingMenu(title, parent)
912 return qmenu
915 class DebouncingMenu(QtWidgets.QMenu):
916 """Menu that debounces mouse release action ie. stops it if occurred
917 right after menu creation.
919 Disables annoying behaviour when RMB is pressed to show menu, cursor is
920 moved accidentally 1px onto newly created menu and released causing to
921 execute menu action
924 threshold_ms = 400
926 def __init__(self, title, parent):
927 QtWidgets.QMenu.__init__(self, title, parent)
928 self.created_at = utils.epoch_millis()
929 if hasattr(self, 'setToolTipsVisible'):
930 self.setToolTipsVisible(True)
932 def mouseReleaseEvent(self, event):
933 threshold = DebouncingMenu.threshold_ms
934 if (utils.epoch_millis() - self.created_at) > threshold:
935 QtWidgets.QMenu.mouseReleaseEvent(self, event)
938 def add_menu(title, parent):
939 """Create a menu and set its title."""
940 menu = create_menu(title, parent)
941 if hasattr(parent, 'addMenu'):
942 parent.addMenu(menu)
943 else:
944 parent.addAction(menu.menuAction())
945 return menu
948 def create_toolbutton(text=None, layout=None, tooltip=None, icon=None):
949 button = tool_button()
950 if icon is not None:
951 button.setIcon(icon)
952 button.setIconSize(QtCore.QSize(defs.default_icon, defs.default_icon))
953 if text is not None:
954 button.setText(' ' + text)
955 button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
956 if tooltip is not None:
957 button.setToolTip(tooltip)
958 if layout is not None:
959 layout.addWidget(button)
960 return button
963 def create_toolbutton_with_callback(callback, text, icon, tooltip, layout=None):
964 """Create a toolbutton that runs the specified callback"""
965 toolbutton = create_toolbutton(text=text, layout=layout, tooltip=tooltip, icon=icon)
966 connect_button(toolbutton, callback)
967 return toolbutton
970 # pylint: disable=line-too-long
971 def mimedata_from_paths(context, paths, include_urls=True):
972 """Return mimedata with a list of absolute path URLs
974 Set `include_urls` to False to prevent URLs from being included
975 in the mimedata. This is useful in some terminals that do not gracefully handle
976 multiple URLs being included in the payload.
978 This allows the mimedata to contain just plain a plain text value that we
979 are able to format ourselves.
981 Older verisons of gnome-terminal expected a utf-16 encoding, but that
982 behavior is no longer needed.
983 """ # noqa
984 abspaths = [core.abspath(path) for path in paths]
985 paths_text = core.list2cmdline(abspaths)
987 # The text/x-moz-list format is always included by Qt, and doing
988 # mimedata.removeFormat('text/x-moz-url') has no effect.
989 # http://www.qtcentre.org/threads/44643-Dragging-text-uri-list-Qt-inserts-garbage
991 # Older versions of gnome-terminal expect utf-16 encoded text, but other terminals,
992 # e.g. terminator, expect utf-8, so use cola.dragencoding to override the default.
993 # NOTE: text/x-moz-url does not seem to be used/needed by modern versions of
994 # gnome-terminal, kitty, and terminator.
995 mimedata = QtCore.QMimeData()
996 mimedata.setText(paths_text)
997 if include_urls:
998 urls = [QtCore.QUrl.fromLocalFile(path) for path in abspaths]
999 encoding = context.cfg.get('cola.dragencoding', 'utf-16')
1000 encoded_text = core.encode(paths_text, encoding=encoding)
1001 mimedata.setUrls(urls)
1002 mimedata.setData('text/x-moz-url', encoded_text)
1004 return mimedata
1007 def path_mimetypes(include_urls=True):
1008 """Return a list of mimetypes that we generate"""
1009 mime_types = [
1010 'text/plain',
1011 'text/plain;charset=utf-8',
1013 if include_urls:
1014 mime_types.append('text/uri-list')
1015 mime_types.append('text/x-moz-url')
1016 return mime_types
1019 class BlockSignals:
1020 """Context manager for blocking a signals on a widget"""
1022 def __init__(self, *widgets):
1023 self.widgets = widgets
1024 self.values = []
1026 def __enter__(self):
1027 """Block Qt signals for all of the captured widgets"""
1028 self.values = [widget.blockSignals(True) for widget in self.widgets]
1029 return self
1031 def __exit__(self, exc_type, exc_val, exc_tb):
1032 """Restore Qt signals when we exit the scope"""
1033 for widget, value in zip(self.widgets, self.values):
1034 widget.blockSignals(value)
1037 class Channel(QtCore.QObject):
1038 finished = Signal(object)
1039 result = Signal(object)
1042 class Task(QtCore.QRunnable):
1043 """Run a task in the background and return the result using a Channel"""
1045 def __init__(self):
1046 QtCore.QRunnable.__init__(self)
1048 self.channel = Channel()
1049 self.result = None
1050 # Python's garbage collector will try to double-free the task
1051 # once it's finished, so disable Qt's auto-deletion as a workaround.
1052 self.setAutoDelete(False)
1054 def run(self):
1055 self.result = self.task()
1056 self.channel.result.emit(self.result)
1057 self.channel.finished.emit(self)
1059 def task(self):
1060 """Perform a long-running task"""
1061 return ()
1063 def connect(self, handler):
1064 self.channel.result.connect(handler, type=Qt.QueuedConnection)
1067 class SimpleTask(Task):
1068 """Run a simple callable as a task"""
1070 def __init__(self, func, *args, **kwargs):
1071 Task.__init__(self)
1073 self.func = func
1074 self.args = args
1075 self.kwargs = kwargs
1077 def task(self):
1078 return self.func(*self.args, **self.kwargs)
1081 class RunTask(QtCore.QObject):
1082 """Runs QRunnable instances and transfers control when they finish"""
1084 def __init__(self, parent=None):
1085 QtCore.QObject.__init__(self, parent)
1086 self.tasks = []
1087 self.task_details = {}
1088 self.threadpool = QtCore.QThreadPool.globalInstance()
1089 self.result_func = None
1091 def start(self, task, progress=None, finish=None, result=None):
1092 """Start the task and register a callback"""
1093 self.result_func = result
1094 if progress is not None:
1095 if hasattr(progress, 'start'):
1096 progress.start()
1098 # prevents garbage collection bugs in certain PyQt4 versions
1099 self.tasks.append(task)
1100 task_id = id(task)
1101 self.task_details[task_id] = (progress, finish, result)
1102 task.channel.finished.connect(self.finish, type=Qt.QueuedConnection)
1103 self.threadpool.start(task)
1105 def finish(self, task):
1106 """The task has finished. Run the finish and result callbacks"""
1107 task_id = id(task)
1108 try:
1109 self.tasks.remove(task)
1110 except ValueError:
1111 pass
1112 try:
1113 progress, finish, result = self.task_details[task_id]
1114 del self.task_details[task_id]
1115 except KeyError:
1116 finish = progress = result = None
1118 if progress is not None:
1119 if hasattr(progress, 'stop'):
1120 progress.stop()
1121 progress.hide()
1123 if result is not None:
1124 result(task.result)
1126 if finish is not None:
1127 finish(task)
1130 # Syntax highlighting
1133 def rgb(red, green, blue):
1134 """Create a QColor from r, g, b arguments"""
1135 color = QtGui.QColor()
1136 color.setRgb(red, green, blue)
1137 return color
1140 def rgba(red, green, blue, alpha=255):
1141 """Create a QColor with alpha from r, g, b, a arguments"""
1142 color = rgb(red, green, blue)
1143 color.setAlpha(alpha)
1144 return color
1147 def rgb_triple(args):
1148 """Create a QColor from an argument with an [r, g, b] triple"""
1149 return rgb(*args)
1152 def rgb_css(color):
1153 """Convert a QColor into an rgb #abcdef CSS string"""
1154 return '#%s' % rgb_hex(color)
1157 def rgb_hex(color):
1158 """Convert a QColor into a hex aabbcc string"""
1159 return f'{color.red():02x}{color.green():02x}{color.blue():02x}'
1162 def clamp_color(value):
1163 """Clamp an integer value between 0 and 255"""
1164 return min(255, max(value, 0))
1167 def css_color(value):
1168 """Convert a #abcdef hex string into a QColor"""
1169 if value.startswith('#'):
1170 value = value[1:]
1171 try:
1172 red = clamp_color(int(value[:2], base=16)) # ab
1173 except ValueError:
1174 red = 255
1175 try:
1176 green = clamp_color(int(value[2:4], base=16)) # cd
1177 except ValueError:
1178 green = 255
1179 try:
1180 blue = clamp_color(int(value[4:6], base=16)) # ef
1181 except ValueError:
1182 blue = 255
1183 return rgb(red, green, blue)
1186 def hsl(hue, saturation, lightness):
1187 """Return a QColor from an hue, saturation and lightness"""
1188 return QtGui.QColor.fromHslF(
1189 utils.clamp(hue, 0.0, 1.0),
1190 utils.clamp(saturation, 0.0, 1.0),
1191 utils.clamp(lightness, 0.0, 1.0),
1195 def hsl_css(hue, saturation, lightness):
1196 """Convert HSL values to a CSS #abcdef color string"""
1197 return rgb_css(hsl(hue, saturation, lightness))
1200 def make_format(foreground=None, background=None, bold=False):
1201 """Create a QTextFormat from the provided foreground, background and bold values"""
1202 fmt = QtGui.QTextCharFormat()
1203 if foreground:
1204 fmt.setForeground(foreground)
1205 if background:
1206 fmt.setBackground(background)
1207 if bold:
1208 fmt.setFontWeight(QtGui.QFont.Bold)
1209 return fmt
1212 class ImageFormats:
1213 def __init__(self):
1214 # returns a list of QByteArray objects
1215 formats_qba = QtGui.QImageReader.supportedImageFormats()
1216 # portability: python3 data() returns bytes, python2 returns str
1217 decode = core.decode
1218 formats = [decode(x.data()) for x in formats_qba]
1219 self.extensions = {'.' + fmt for fmt in formats}
1221 def ok(self, filename):
1222 _, ext = os.path.splitext(filename)
1223 return ext.lower() in self.extensions
1226 def set_scrollbar_values(widget, hscroll_value, vscroll_value):
1227 """Set scrollbars to the specified values"""
1228 hscroll = widget.horizontalScrollBar()
1229 if hscroll and hscroll_value is not None:
1230 hscroll.setValue(hscroll_value)
1232 vscroll = widget.verticalScrollBar()
1233 if vscroll and vscroll_value is not None:
1234 vscroll.setValue(vscroll_value)
1237 def get_scrollbar_values(widget):
1238 """Return the current (hscroll, vscroll) scrollbar values for a widget"""
1239 hscroll = widget.horizontalScrollBar()
1240 if hscroll:
1241 hscroll_value = get(hscroll)
1242 else:
1243 hscroll_value = None
1244 vscroll = widget.verticalScrollBar()
1245 if vscroll:
1246 vscroll_value = get(vscroll)
1247 else:
1248 vscroll_value = None
1249 return (hscroll_value, vscroll_value)
1252 def scroll_to_item(widget, item):
1253 """Scroll to an item while retaining the horizontal scroll position"""
1254 hscroll = None
1255 hscrollbar = widget.horizontalScrollBar()
1256 if hscrollbar:
1257 hscroll = get(hscrollbar)
1258 widget.scrollToItem(item)
1259 if hscroll is not None:
1260 hscrollbar.setValue(hscroll)
1263 def select_item(widget, item):
1264 """Scroll to and make a QTreeWidget item selected and current"""
1265 scroll_to_item(widget, item)
1266 widget.setCurrentItem(item)
1267 item.setSelected(True)
1270 def get_selected_values(widget, top_level_idx, values):
1271 """Map the selected items under the top-level item to the values list"""
1272 # Get the top-level item
1273 item = widget.topLevelItem(top_level_idx)
1274 return tree_selection(item, values)
1277 def get_selected_items(widget, idx):
1278 """Return the selected items under the top-level item"""
1279 item = widget.topLevelItem(idx)
1280 return tree_selection_items(item)
1283 def add_menu_actions(menu, menu_actions):
1284 """Add actions to a menu, treating None as a separator"""
1285 current_actions = menu.actions()
1286 if current_actions:
1287 first_action = current_actions[0]
1288 else:
1289 first_action = None
1290 menu.addSeparator()
1292 for action in menu_actions:
1293 if action is None:
1294 action = menu_separator(menu)
1295 menu.insertAction(first_action, action)