dag: use argparse.REMAINDER so that double-dash -- is retained
[git-cola.git] / cola / qtutils.py
blob78185948d69c298f0f893b60860414faf95495e5
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 add_completer(widget, items):
285 """Add simple completion to a widget"""
286 completer = QtWidgets.QCompleter(items, widget)
287 completer.setCaseSensitivity(Qt.CaseInsensitive)
288 completer.setCompletionMode(QtWidgets.QCompleter.InlineCompletion)
289 widget.setCompleter(completer)
292 def prompt(msg, title=None, text='', parent=None):
293 """Presents the user with an input widget and returns the input."""
294 if title is None:
295 title = msg
296 if parent is None:
297 parent = active_window()
298 result = QtWidgets.QInputDialog.getText(
299 parent, title, msg, QtWidgets.QLineEdit.Normal, text
301 return (result[0], result[1])
304 def prompt_n(msg, inputs):
305 """Presents the user with N input widgets and returns the results"""
306 dialog = QtWidgets.QDialog(active_window())
307 dialog.setWindowModality(Qt.WindowModal)
308 dialog.setWindowTitle(msg)
310 long_value = msg
311 for k, v in inputs:
312 if len(k + v) > len(long_value):
313 long_value = k + v
315 metrics = QtGui.QFontMetrics(dialog.font())
316 min_width = min(720, metrics.width(long_value) + 100)
317 dialog.setMinimumWidth(min_width)
319 ok_b = ok_button(msg, enabled=False)
320 close_b = close_button()
322 form_widgets = []
324 def get_values():
325 return [pair[1].text().strip() for pair in form_widgets]
327 for name, value in inputs:
328 lineedit = QtWidgets.QLineEdit()
329 # Enable the OK button only when all fields have been populated
330 # pylint: disable=no-member
331 lineedit.textChanged.connect(
332 lambda x: ok_b.setEnabled(all(get_values())), type=Qt.QueuedConnection
334 if value:
335 lineedit.setText(value)
336 form_widgets.append((name, lineedit))
338 # layouts
339 form_layout = form(defs.no_margin, defs.button_spacing, *form_widgets)
340 button_layout = hbox(defs.no_margin, defs.button_spacing, STRETCH, close_b, ok_b)
341 main_layout = vbox(defs.margin, defs.button_spacing, form_layout, button_layout)
342 dialog.setLayout(main_layout)
344 # connections
345 connect_button(ok_b, dialog.accept)
346 connect_button(close_b, dialog.reject)
348 accepted = dialog.exec_() == QtWidgets.QDialog.Accepted
349 text = get_values()
350 success = accepted and all(text)
351 return (success, text)
354 def standard_item_type_value(value):
355 """Return a custom UserType for use in QTreeWidgetItem.type() overrides"""
356 return custom_item_type_value(QtGui.QStandardItem, value)
359 def graphics_item_type_value(value):
360 """Return a custom UserType for use in QGraphicsItem.type() overrides"""
361 return custom_item_type_value(QtWidgets.QGraphicsItem, value)
364 def custom_item_type_value(cls, value):
365 """Return a custom cls.UserType for use in cls.type() overrides"""
366 user_type = enum_value(cls.UserType)
367 return user_type + value
370 def enum_value(value):
371 """Qt6 has enums with an inner '.value' attribute."""
372 if hasattr(value, 'value'):
373 value = value.value
374 return value
377 class TreeWidgetItem(QtWidgets.QTreeWidgetItem):
378 TYPE = standard_item_type_value(101)
380 def __init__(self, path, icon, deleted):
381 QtWidgets.QTreeWidgetItem.__init__(self)
382 self.path = path
383 self.deleted = deleted
384 self.setIcon(0, icons.from_name(icon))
385 self.setText(0, path)
387 def type(self):
388 return self.TYPE
391 def paths_from_indexes(model, indexes, item_type=TreeWidgetItem.TYPE, item_filter=None):
392 """Return paths from a list of QStandardItemModel indexes"""
393 items = [model.itemFromIndex(i) for i in indexes]
394 return paths_from_items(items, item_type=item_type, item_filter=item_filter)
397 def _true_filter(_value):
398 return True
401 def paths_from_items(items, item_type=TreeWidgetItem.TYPE, item_filter=None):
402 """Return a list of paths from a list of items"""
403 if item_filter is None:
404 item_filter = _true_filter
405 return [i.path for i in items if i.type() == item_type and item_filter(i)]
408 def tree_selection(tree_item, items):
409 """Returns an array of model items that correspond to the selected
410 QTreeWidgetItem children"""
411 selected = []
412 count = min(tree_item.childCount(), len(items))
413 for idx in range(count):
414 if tree_item.child(idx).isSelected():
415 selected.append(items[idx])
417 return selected
420 def tree_selection_items(tree_item):
421 """Returns selected widget items"""
422 selected = []
423 for idx in range(tree_item.childCount()):
424 child = tree_item.child(idx)
425 if child.isSelected():
426 selected.append(child)
428 return selected
431 def selected_item(list_widget, items):
432 """Returns the model item that corresponds to the selected QListWidget
433 row."""
434 widget_items = list_widget.selectedItems()
435 if not widget_items:
436 return None
437 widget_item = widget_items[0]
438 row = list_widget.row(widget_item)
439 if row < len(items):
440 item = items[row]
441 else:
442 item = None
443 return item
446 def selected_items(list_widget, items):
447 """Returns an array of model items that correspond to the selected
448 QListWidget rows."""
449 item_count = len(items)
450 selected = []
451 for widget_item in list_widget.selectedItems():
452 row = list_widget.row(widget_item)
453 if row < item_count:
454 selected.append(items[row])
455 return selected
458 def open_file(title, directory=None):
459 """Creates an Open File dialog and returns a filename."""
460 result = compat.getopenfilename(
461 parent=active_window(), caption=title, basedir=directory
463 return result[0]
466 def open_files(title, directory=None, filters=''):
467 """Creates an Open File dialog and returns a list of filenames."""
468 result = compat.getopenfilenames(
469 parent=active_window(), caption=title, basedir=directory, filters=filters
471 return result[0]
474 def opendir_dialog(caption, path):
475 """Prompts for a directory path"""
476 options = (
477 QtWidgets.QFileDialog.Directory
478 | QtWidgets.QFileDialog.DontResolveSymlinks
479 | QtWidgets.QFileDialog.ReadOnly
480 | QtWidgets.QFileDialog.ShowDirsOnly
482 return compat.getexistingdirectory(
483 parent=active_window(), caption=caption, basedir=path, options=options
487 def save_as(filename, title='Save As...'):
488 """Creates a Save File dialog and returns a filename."""
489 result = compat.getsavefilename(
490 parent=active_window(), caption=title, basedir=filename
492 return result[0]
495 def existing_file(directory, title='Append...'):
496 """Creates a Save File dialog and returns a filename."""
497 result = compat.getopenfilename(
498 parent=active_window(), caption=title, basedir=directory
500 return result[0]
503 def copy_path(filename, absolute=True):
504 """Copy a filename to the clipboard"""
505 if filename is None:
506 return
507 if absolute:
508 filename = core.abspath(filename)
509 set_clipboard(filename)
512 def set_clipboard(text):
513 """Sets the copy/paste buffer to text."""
514 if not text:
515 return
516 clipboard = QtWidgets.QApplication.clipboard()
517 clipboard.setText(text, QtGui.QClipboard.Clipboard)
518 if not utils.is_darwin() and not utils.is_win32():
519 clipboard.setText(text, QtGui.QClipboard.Selection)
520 persist_clipboard()
523 # pylint: disable=line-too-long
524 def persist_clipboard():
525 """Persist the clipboard
527 X11 stores only a reference to the clipboard data.
528 Send a clipboard event to force a copy of the clipboard to occur.
529 This ensures that the clipboard is present after git-cola exits.
530 Otherwise, the reference is destroyed on exit.
532 C.f. https://stackoverflow.com/questions/2007103/how-can-i-disable-clear-of-clipboard-on-exit-of-pyqt4-application
534 """ # noqa
535 clipboard = QtWidgets.QApplication.clipboard()
536 event = QtCore.QEvent(QtCore.QEvent.Clipboard)
537 QtWidgets.QApplication.sendEvent(clipboard, event)
540 def add_action_bool(widget, text, func, checked, *shortcuts):
541 tip = text
542 action = _add_action(widget, text, tip, func, connect_action_bool, *shortcuts)
543 action.setCheckable(True)
544 action.setChecked(checked)
545 return action
548 def add_action(widget, text, func, *shortcuts):
549 """Create a QAction and bind it to the `func` callback and hotkeys"""
550 tip = text
551 return _add_action(widget, text, tip, func, connect_action, *shortcuts)
554 def add_action_with_icon(widget, icon, text, func, *shortcuts):
555 """Create a QAction using a custom icon bound to the `func` callback and hotkeys"""
556 tip = text
557 action = _add_action(widget, text, tip, func, connect_action, *shortcuts)
558 action.setIcon(icon)
559 return action
562 def add_action_with_tooltip(widget, text, tip, func, *shortcuts):
563 """Create an action with a tooltip"""
564 return _add_action(widget, text, tip, func, connect_action, *shortcuts)
567 def menu_separator(widget, text=''):
568 """Return a QAction whose isSeparator() returns true. Used in context menus"""
569 action = QtWidgets.QAction(text, widget)
570 action.setSeparator(True)
571 return action
574 def _add_action(widget, text, tip, func, connect, *shortcuts):
575 action = QtWidgets.QAction(text, widget)
576 if hasattr(action, 'setIconVisibleInMenu'):
577 action.setIconVisibleInMenu(True)
578 if tip:
579 action.setStatusTip(tip)
580 connect(action, func)
581 if shortcuts:
582 action.setShortcuts(shortcuts)
583 if hasattr(Qt, 'WidgetWithChildrenShortcut'):
584 action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
585 widget.addAction(action)
586 return action
589 def set_selected_item(widget, idx):
590 """Sets a the currently selected item to the item at index idx."""
591 if isinstance(widget, QtWidgets.QTreeWidget):
592 item = widget.topLevelItem(idx)
593 if item:
594 item.setSelected(True)
595 widget.setCurrentItem(item)
598 def add_items(widget, items):
599 """Adds items to a widget."""
600 for item in items:
601 if item is None:
602 continue
603 widget.addItem(item)
606 def set_items(widget, items):
607 """Clear the existing widget contents and set the new items."""
608 widget.clear()
609 add_items(widget, items)
612 def create_treeitem(filename, staged=False, deleted=False, untracked=False):
613 """Given a filename, return a TreeWidgetItem for a status widget
615 "staged", "deleted, and "untracked" control which icon is used.
618 icon_name = icons.status(filename, deleted, staged, untracked)
619 icon = icons.name_from_basename(icon_name)
620 return TreeWidgetItem(filename, icon, deleted=deleted)
623 def add_close_action(widget):
624 """Adds close action and shortcuts to a widget."""
625 return add_action(widget, N_('Close...'), widget.close, hotkeys.CLOSE, hotkeys.QUIT)
628 def app():
629 """Return the current application"""
630 return QtWidgets.QApplication.instance()
633 def desktop_size():
634 rect = app().primaryScreen().geometry()
635 return (rect.width(), rect.height())
638 def center_on_screen(widget):
639 """Move widget to the center of the default screen"""
640 width, height = desktop_size()
641 center_x = width // 2
642 center_y = height // 2
643 widget.move(center_x - widget.width() // 2, center_y - widget.height() // 2)
646 def default_size(parent, width, height, use_parent_height=True):
647 """Return the parent's size, or the provided defaults"""
648 if parent is not None:
649 width = parent.width()
650 if use_parent_height:
651 height = parent.height()
652 return (width, height)
655 def default_monospace_font():
656 if utils.is_darwin():
657 family = 'Monaco'
658 else:
659 family = 'Monospace'
660 mfont = QtGui.QFont()
661 mfont.setFamily(family)
662 return mfont
665 def diff_font_str(context):
666 cfg = context.cfg
667 font_str = cfg.get(prefs.FONTDIFF)
668 if not font_str:
669 font_str = default_monospace_font().toString()
670 return font_str
673 def diff_font(context):
674 return font(diff_font_str(context))
677 def font(string):
678 qfont = QtGui.QFont()
679 qfont.fromString(string)
680 return qfont
683 def create_button(
684 text='', layout=None, tooltip=None, icon=None, enabled=True, default=False
686 """Create a button, set its title, and add it to the parent."""
687 button = QtWidgets.QPushButton()
688 button.setCursor(Qt.PointingHandCursor)
689 button.setFocusPolicy(Qt.NoFocus)
690 if text:
691 button.setText(' ' + text)
692 if icon is not None:
693 button.setIcon(icon)
694 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
695 if tooltip is not None:
696 button.setToolTip(tooltip)
697 if layout is not None:
698 layout.addWidget(button)
699 if not enabled:
700 button.setEnabled(False)
701 if default:
702 button.setDefault(True)
703 return button
706 def tool_button():
707 """Create a flat border-less button"""
708 button = QtWidgets.QToolButton()
709 button.setPopupMode(QtWidgets.QToolButton.InstantPopup)
710 button.setCursor(Qt.PointingHandCursor)
711 button.setFocusPolicy(Qt.NoFocus)
712 # Highlight colors
713 palette = QtGui.QPalette()
714 highlight = palette.color(QtGui.QPalette.Highlight)
715 highlight_rgb = rgb_css(highlight)
717 button.setStyleSheet(
719 /* No borders */
720 QToolButton {
721 border: none;
722 background-color: none;
724 /* Hide the menu indicator */
725 QToolButton::menu-indicator {
726 image: none;
728 QToolButton:hover {
729 border: %(border)spx solid %(highlight_rgb)s;
733 'border': defs.border,
734 'highlight_rgb': highlight_rgb,
737 return button
740 def create_action_button(tooltip=None, icon=None, visible=None):
741 """Create a small toolbutton for use in dock title widgets"""
742 button = tool_button()
743 if tooltip is not None:
744 button.setToolTip(tooltip)
745 if icon is not None:
746 button.setIcon(icon)
747 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
748 if visible is not None:
749 button.setVisible(visible)
750 return button
753 def ok_button(text, default=True, enabled=True, icon=None):
754 if icon is None:
755 icon = icons.ok()
756 return create_button(text=text, icon=icon, default=default, enabled=enabled)
759 def close_button(text=None, icon=None):
760 text = text or N_('Close')
761 icon = icons.mkicon(icon, icons.close)
762 return create_button(text=text, icon=icon)
765 def edit_button(enabled=True, default=False):
766 return create_button(
767 text=N_('Edit'), icon=icons.edit(), enabled=enabled, default=default
771 def refresh_button(enabled=True, default=False):
772 return create_button(
773 text=N_('Refresh'), icon=icons.sync(), enabled=enabled, default=default
777 def checkbox(text='', tooltip='', checked=None):
778 """Create a checkbox"""
779 return _checkbox(QtWidgets.QCheckBox, text, tooltip, checked)
782 def radio(text='', tooltip='', checked=None):
783 """Create a radio button"""
784 return _checkbox(QtWidgets.QRadioButton, text, tooltip, checked)
787 def _checkbox(cls, text, tooltip, checked):
788 """Create a widget and apply properties"""
789 widget = cls()
790 if text:
791 widget.setText(text)
792 if tooltip:
793 widget.setToolTip(tooltip)
794 if checked is not None:
795 widget.setChecked(checked)
796 return widget
799 class DockTitleBarWidget(QtWidgets.QFrame):
800 def __init__(self, parent, title, stretch=True):
801 QtWidgets.QFrame.__init__(self, parent)
802 self.setAutoFillBackground(True)
803 self.label = qlabel = QtWidgets.QLabel(title, self)
804 qfont = qlabel.font()
805 qfont.setBold(True)
806 qlabel.setFont(qfont)
807 qlabel.setCursor(Qt.OpenHandCursor)
809 self.close_button = create_action_button(
810 tooltip=N_('Close'), icon=icons.close()
813 self.toggle_button = create_action_button(
814 tooltip=N_('Detach'), icon=icons.external()
817 self.corner_layout = hbox(defs.no_margin, defs.spacing)
819 if stretch:
820 separator = STRETCH
821 else:
822 separator = SKIPPED
824 self.main_layout = hbox(
825 defs.small_margin,
826 defs.titlebar_spacing,
827 qlabel,
828 separator,
829 self.corner_layout,
830 self.toggle_button,
831 self.close_button,
833 self.setLayout(self.main_layout)
835 connect_button(self.toggle_button, self.toggle_floating)
836 connect_button(self.close_button, self.toggle_visibility)
838 def toggle_floating(self):
839 self.parent().setFloating(not self.parent().isFloating())
840 self.update_tooltips()
842 def toggle_visibility(self):
843 self.parent().toggleViewAction().trigger()
845 def set_title(self, title):
846 self.label.setText(title)
848 def add_corner_widget(self, widget):
849 self.corner_layout.addWidget(widget)
851 def update_tooltips(self):
852 if self.parent().isFloating():
853 tooltip = N_('Attach')
854 else:
855 tooltip = N_('Detach')
856 self.toggle_button.setToolTip(tooltip)
859 def create_dock(name, title, parent, stretch=True, widget=None, func=None):
860 """Create a dock widget and set it up accordingly."""
861 dock = QtWidgets.QDockWidget(parent)
862 dock.setWindowTitle(title)
863 dock.setObjectName(name)
864 titlebar = DockTitleBarWidget(dock, title, stretch=stretch)
865 dock.setTitleBarWidget(titlebar)
866 dock.setAutoFillBackground(True)
867 if hasattr(parent, 'dockwidgets'):
868 parent.dockwidgets.append(dock)
869 if func:
870 widget = func(dock)
871 if widget:
872 dock.setWidget(widget)
873 return dock
876 def hide_dock(widget):
877 widget.toggleViewAction().setChecked(False)
878 widget.hide()
881 def create_menu(title, parent):
882 """Create a menu and set its title."""
883 qmenu = DebouncingMenu(title, parent)
884 return qmenu
887 class DebouncingMenu(QtWidgets.QMenu):
888 """Menu that debounces mouse release action ie. stops it if occurred
889 right after menu creation.
891 Disables annoying behaviour when RMB is pressed to show menu, cursor is
892 moved accidentally 1px onto newly created menu and released causing to
893 execute menu action
896 threshold_ms = 400
898 def __init__(self, title, parent):
899 QtWidgets.QMenu.__init__(self, title, parent)
900 self.created_at = utils.epoch_millis()
901 if hasattr(self, 'setToolTipsVisible'):
902 self.setToolTipsVisible(True)
904 def mouseReleaseEvent(self, event):
905 threshold = DebouncingMenu.threshold_ms
906 if (utils.epoch_millis() - self.created_at) > threshold:
907 QtWidgets.QMenu.mouseReleaseEvent(self, event)
910 def add_menu(title, parent):
911 """Create a menu and set its title."""
912 menu = create_menu(title, parent)
913 if hasattr(parent, 'addMenu'):
914 parent.addMenu(menu)
915 else:
916 parent.addAction(menu.menuAction())
917 return menu
920 def create_toolbutton(text=None, layout=None, tooltip=None, icon=None):
921 button = tool_button()
922 if icon is not None:
923 button.setIcon(icon)
924 button.setIconSize(QtCore.QSize(defs.default_icon, defs.default_icon))
925 if text is not None:
926 button.setText(' ' + text)
927 button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
928 if tooltip is not None:
929 button.setToolTip(tooltip)
930 if layout is not None:
931 layout.addWidget(button)
932 return button
935 def create_toolbutton_with_callback(callback, text, icon, tooltip, layout=None):
936 """Create a toolbutton that runs the specified callback"""
937 toolbutton = create_toolbutton(text=text, layout=layout, tooltip=tooltip, icon=icon)
938 connect_button(toolbutton, callback)
939 return toolbutton
942 # pylint: disable=line-too-long
943 def mimedata_from_paths(context, paths, include_urls=True):
944 """Return mimedata with a list of absolute path URLs
946 Set `include_urls` to False to prevent URLs from being included
947 in the mimedata. This is useful in some terminals that do not gracefully handle
948 multiple URLs being included in the payload.
950 This allows the mimedata to contain just plain a plain text value that we
951 are able to format ourselves.
953 Older verisons of gnome-terminal expected a utf-16 encoding, but that
954 behavior is no longer needed.
955 """ # noqa
956 abspaths = [core.abspath(path) for path in paths]
957 paths_text = core.list2cmdline(abspaths)
959 # The text/x-moz-list format is always included by Qt, and doing
960 # mimedata.removeFormat('text/x-moz-url') has no effect.
961 # http://www.qtcentre.org/threads/44643-Dragging-text-uri-list-Qt-inserts-garbage
963 # Older versions of gnome-terminal expect utf-16 encoded text, but other terminals,
964 # e.g. terminator, expect utf-8, so use cola.dragencoding to override the default.
965 # NOTE: text/x-moz-url does not seem to be used/needed by modern versions of
966 # gnome-terminal, kitty, and terminator.
967 mimedata = QtCore.QMimeData()
968 mimedata.setText(paths_text)
969 if include_urls:
970 urls = [QtCore.QUrl.fromLocalFile(path) for path in abspaths]
971 encoding = context.cfg.get('cola.dragencoding', 'utf-16')
972 encoded_text = core.encode(paths_text, encoding=encoding)
973 mimedata.setUrls(urls)
974 mimedata.setData('text/x-moz-url', encoded_text)
976 return mimedata
979 def path_mimetypes(include_urls=True):
980 """Return a list of mimetypes that we generate"""
981 mime_types = [
982 'text/plain',
983 'text/plain;charset=utf-8',
985 if include_urls:
986 mime_types.append('text/uri-list')
987 mime_types.append('text/x-moz-url')
988 return mime_types
991 class BlockSignals:
992 """Context manager for blocking a signals on a widget"""
994 def __init__(self, *widgets):
995 self.widgets = widgets
996 self.values = []
998 def __enter__(self):
999 """Block Qt signals for all of the captured widgets"""
1000 self.values = [widget.blockSignals(True) for widget in self.widgets]
1001 return self
1003 def __exit__(self, exc_type, exc_val, exc_tb):
1004 """Restore Qt signals when we exit the scope"""
1005 for widget, value in zip(self.widgets, self.values):
1006 widget.blockSignals(value)
1009 class Channel(QtCore.QObject):
1010 finished = Signal(object)
1011 result = Signal(object)
1014 class Task(QtCore.QRunnable):
1015 """Run a task in the background and return the result using a Channel"""
1017 def __init__(self):
1018 QtCore.QRunnable.__init__(self)
1020 self.channel = Channel()
1021 self.result = None
1022 # Python's garbage collector will try to double-free the task
1023 # once it's finished, so disable Qt's auto-deletion as a workaround.
1024 self.setAutoDelete(False)
1026 def run(self):
1027 self.result = self.task()
1028 self.channel.result.emit(self.result)
1029 self.channel.finished.emit(self)
1031 def task(self):
1032 """Perform a long-running task"""
1033 return ()
1035 def connect(self, handler):
1036 self.channel.result.connect(handler, type=Qt.QueuedConnection)
1039 class SimpleTask(Task):
1040 """Run a simple callable as a task"""
1042 def __init__(self, func, *args, **kwargs):
1043 Task.__init__(self)
1045 self.func = func
1046 self.args = args
1047 self.kwargs = kwargs
1049 def task(self):
1050 return self.func(*self.args, **self.kwargs)
1053 class RunTask(QtCore.QObject):
1054 """Runs QRunnable instances and transfers control when they finish"""
1056 def __init__(self, parent=None):
1057 QtCore.QObject.__init__(self, parent)
1058 self.tasks = []
1059 self.task_details = {}
1060 self.threadpool = QtCore.QThreadPool.globalInstance()
1061 self.result_func = None
1063 def start(self, task, progress=None, finish=None, result=None):
1064 """Start the task and register a callback"""
1065 self.result_func = result
1066 if progress is not None:
1067 if hasattr(progress, 'start'):
1068 progress.start()
1070 # prevents garbage collection bugs in certain PyQt4 versions
1071 self.tasks.append(task)
1072 task_id = id(task)
1073 self.task_details[task_id] = (progress, finish, result)
1074 task.channel.finished.connect(self.finish, type=Qt.QueuedConnection)
1075 self.threadpool.start(task)
1077 def finish(self, task):
1078 """The task has finished. Run the finish and result callbacks"""
1079 task_id = id(task)
1080 try:
1081 self.tasks.remove(task)
1082 except ValueError:
1083 pass
1084 try:
1085 progress, finish, result = self.task_details[task_id]
1086 del self.task_details[task_id]
1087 except KeyError:
1088 finish = progress = result = None
1090 if progress is not None:
1091 if hasattr(progress, 'stop'):
1092 progress.stop()
1093 progress.hide()
1095 if result is not None:
1096 result(task.result)
1098 if finish is not None:
1099 finish(task)
1102 # Syntax highlighting
1105 def rgb(red, green, blue):
1106 """Create a QColor from r, g, b arguments"""
1107 color = QtGui.QColor()
1108 color.setRgb(red, green, blue)
1109 return color
1112 def rgba(red, green, blue, alpha=255):
1113 """Create a QColor with alpha from r, g, b, a arguments"""
1114 color = rgb(red, green, blue)
1115 color.setAlpha(alpha)
1116 return color
1119 def rgb_triple(args):
1120 """Create a QColor from an argument with an [r, g, b] triple"""
1121 return rgb(*args)
1124 def rgb_css(color):
1125 """Convert a QColor into an rgb #abcdef CSS string"""
1126 return '#%s' % rgb_hex(color)
1129 def rgb_hex(color):
1130 """Convert a QColor into a hex aabbcc string"""
1131 return f'{color.red():02x}{color.green():02x}{color.blue():02x}'
1134 def clamp_color(value):
1135 """Clamp an integer value between 0 and 255"""
1136 return min(255, max(value, 0))
1139 def css_color(value):
1140 """Convert a #abcdef hex string into a QColor"""
1141 if value.startswith('#'):
1142 value = value[1:]
1143 try:
1144 red = clamp_color(int(value[:2], base=16)) # ab
1145 except ValueError:
1146 red = 255
1147 try:
1148 green = clamp_color(int(value[2:4], base=16)) # cd
1149 except ValueError:
1150 green = 255
1151 try:
1152 blue = clamp_color(int(value[4:6], base=16)) # ef
1153 except ValueError:
1154 blue = 255
1155 return rgb(red, green, blue)
1158 def hsl(hue, saturation, lightness):
1159 """Return a QColor from an hue, saturation and lightness"""
1160 return QtGui.QColor.fromHslF(
1161 utils.clamp(hue, 0.0, 1.0),
1162 utils.clamp(saturation, 0.0, 1.0),
1163 utils.clamp(lightness, 0.0, 1.0),
1167 def hsl_css(hue, saturation, lightness):
1168 """Convert HSL values to a CSS #abcdef color string"""
1169 return rgb_css(hsl(hue, saturation, lightness))
1172 def make_format(foreground=None, background=None, bold=False):
1173 """Create a QTextFormat from the provided foreground, background and bold values"""
1174 fmt = QtGui.QTextCharFormat()
1175 if foreground:
1176 fmt.setForeground(foreground)
1177 if background:
1178 fmt.setBackground(background)
1179 if bold:
1180 fmt.setFontWeight(QtGui.QFont.Bold)
1181 return fmt
1184 class ImageFormats:
1185 def __init__(self):
1186 # returns a list of QByteArray objects
1187 formats_qba = QtGui.QImageReader.supportedImageFormats()
1188 # portability: python3 data() returns bytes, python2 returns str
1189 decode = core.decode
1190 formats = [decode(x.data()) for x in formats_qba]
1191 self.extensions = {'.' + fmt for fmt in formats}
1193 def ok(self, filename):
1194 _, ext = os.path.splitext(filename)
1195 return ext.lower() in self.extensions
1198 def set_scrollbar_values(widget, hscroll_value, vscroll_value):
1199 """Set scrollbars to the specified values"""
1200 hscroll = widget.horizontalScrollBar()
1201 if hscroll and hscroll_value is not None:
1202 hscroll.setValue(hscroll_value)
1204 vscroll = widget.verticalScrollBar()
1205 if vscroll and vscroll_value is not None:
1206 vscroll.setValue(vscroll_value)
1209 def get_scrollbar_values(widget):
1210 """Return the current (hscroll, vscroll) scrollbar values for a widget"""
1211 hscroll = widget.horizontalScrollBar()
1212 if hscroll:
1213 hscroll_value = get(hscroll)
1214 else:
1215 hscroll_value = None
1216 vscroll = widget.verticalScrollBar()
1217 if vscroll:
1218 vscroll_value = get(vscroll)
1219 else:
1220 vscroll_value = None
1221 return (hscroll_value, vscroll_value)
1224 def scroll_to_item(widget, item):
1225 """Scroll to an item while retaining the horizontal scroll position"""
1226 hscroll = None
1227 hscrollbar = widget.horizontalScrollBar()
1228 if hscrollbar:
1229 hscroll = get(hscrollbar)
1230 widget.scrollToItem(item)
1231 if hscroll is not None:
1232 hscrollbar.setValue(hscroll)
1235 def select_item(widget, item):
1236 """Scroll to and make a QTreeWidget item selected and current"""
1237 scroll_to_item(widget, item)
1238 widget.setCurrentItem(item)
1239 item.setSelected(True)
1242 def get_selected_values(widget, top_level_idx, values):
1243 """Map the selected items under the top-level item to the values list"""
1244 # Get the top-level item
1245 item = widget.topLevelItem(top_level_idx)
1246 return tree_selection(item, values)
1249 def get_selected_items(widget, idx):
1250 """Return the selected items under the top-level item"""
1251 item = widget.topLevelItem(idx)
1252 return tree_selection_items(item)
1255 def add_menu_actions(menu, menu_actions):
1256 """Add actions to a menu, treating None as a separator"""
1257 current_actions = menu.actions()
1258 if current_actions:
1259 first_action = current_actions[0]
1260 else:
1261 first_action = None
1262 menu.addSeparator()
1264 for action in menu_actions:
1265 if action is None:
1266 action = menu_separator(menu)
1267 menu.insertAction(first_action, action)