diff: make the context menu more consistent when unstaging
[git-cola.git] / cola / qtutils.py
blob74e67a62dd0956edc7b4f628595888a63fe6b3a1
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_status_tip(widget, text, tip, func, *shortcuts):
563 return _add_action(widget, text, tip, func, connect_action, *shortcuts)
566 def menu_separator(widget, text=''):
567 """Return a QAction whose isSeparator() returns true. Used in context menus"""
568 action = QtWidgets.QAction(text, widget)
569 action.setSeparator(True)
570 return action
573 def _add_action(widget, text, tip, func, connect, *shortcuts):
574 action = QtWidgets.QAction(text, widget)
575 if hasattr(action, 'setIconVisibleInMenu'):
576 action.setIconVisibleInMenu(True)
577 if tip:
578 action.setStatusTip(tip)
579 connect(action, func)
580 if shortcuts:
581 action.setShortcuts(shortcuts)
582 if hasattr(Qt, 'WidgetWithChildrenShortcut'):
583 action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
584 widget.addAction(action)
585 return action
588 def set_selected_item(widget, idx):
589 """Sets a the currently selected item to the item at index idx."""
590 if isinstance(widget, QtWidgets.QTreeWidget):
591 item = widget.topLevelItem(idx)
592 if item:
593 item.setSelected(True)
594 widget.setCurrentItem(item)
597 def add_items(widget, items):
598 """Adds items to a widget."""
599 for item in items:
600 if item is None:
601 continue
602 widget.addItem(item)
605 def set_items(widget, items):
606 """Clear the existing widget contents and set the new items."""
607 widget.clear()
608 add_items(widget, items)
611 def create_treeitem(filename, staged=False, deleted=False, untracked=False):
612 """Given a filename, return a TreeWidgetItem for a status widget
614 "staged", "deleted, and "untracked" control which icon is used.
617 icon_name = icons.status(filename, deleted, staged, untracked)
618 icon = icons.name_from_basename(icon_name)
619 return TreeWidgetItem(filename, icon, deleted=deleted)
622 def add_close_action(widget):
623 """Adds close action and shortcuts to a widget."""
624 return add_action(widget, N_('Close...'), widget.close, hotkeys.CLOSE, hotkeys.QUIT)
627 def app():
628 """Return the current application"""
629 return QtWidgets.QApplication.instance()
632 def desktop_size():
633 rect = app().primaryScreen().geometry()
634 return (rect.width(), rect.height())
637 def center_on_screen(widget):
638 """Move widget to the center of the default screen"""
639 width, height = desktop_size()
640 center_x = width // 2
641 center_y = height // 2
642 widget.move(center_x - widget.width() // 2, center_y - widget.height() // 2)
645 def default_size(parent, width, height, use_parent_height=True):
646 """Return the parent's size, or the provided defaults"""
647 if parent is not None:
648 width = parent.width()
649 if use_parent_height:
650 height = parent.height()
651 return (width, height)
654 def default_monospace_font():
655 if utils.is_darwin():
656 family = 'Monaco'
657 else:
658 family = 'Monospace'
659 mfont = QtGui.QFont()
660 mfont.setFamily(family)
661 return mfont
664 def diff_font_str(context):
665 cfg = context.cfg
666 font_str = cfg.get(prefs.FONTDIFF)
667 if not font_str:
668 font_str = default_monospace_font().toString()
669 return font_str
672 def diff_font(context):
673 return font(diff_font_str(context))
676 def font(string):
677 qfont = QtGui.QFont()
678 qfont.fromString(string)
679 return qfont
682 def create_button(
683 text='', layout=None, tooltip=None, icon=None, enabled=True, default=False
685 """Create a button, set its title, and add it to the parent."""
686 button = QtWidgets.QPushButton()
687 button.setCursor(Qt.PointingHandCursor)
688 button.setFocusPolicy(Qt.NoFocus)
689 if text:
690 button.setText(' ' + text)
691 if icon is not None:
692 button.setIcon(icon)
693 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
694 if tooltip is not None:
695 button.setToolTip(tooltip)
696 if layout is not None:
697 layout.addWidget(button)
698 if not enabled:
699 button.setEnabled(False)
700 if default:
701 button.setDefault(True)
702 return button
705 def tool_button():
706 """Create a flat border-less button"""
707 button = QtWidgets.QToolButton()
708 button.setPopupMode(QtWidgets.QToolButton.InstantPopup)
709 button.setCursor(Qt.PointingHandCursor)
710 button.setFocusPolicy(Qt.NoFocus)
711 # Highlight colors
712 palette = QtGui.QPalette()
713 highlight = palette.color(QtGui.QPalette.Highlight)
714 highlight_rgb = rgb_css(highlight)
716 button.setStyleSheet(
718 /* No borders */
719 QToolButton {
720 border: none;
721 background-color: none;
723 /* Hide the menu indicator */
724 QToolButton::menu-indicator {
725 image: none;
727 QToolButton:hover {
728 border: %(border)spx solid %(highlight_rgb)s;
732 'border': defs.border,
733 'highlight_rgb': highlight_rgb,
736 return button
739 def create_action_button(tooltip=None, icon=None, visible=None):
740 """Create a small toolbutton for use in dock title widgets"""
741 button = tool_button()
742 if tooltip is not None:
743 button.setToolTip(tooltip)
744 if icon is not None:
745 button.setIcon(icon)
746 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
747 if visible is not None:
748 button.setVisible(visible)
749 return button
752 def ok_button(text, default=True, enabled=True, icon=None):
753 if icon is None:
754 icon = icons.ok()
755 return create_button(text=text, icon=icon, default=default, enabled=enabled)
758 def close_button(text=None, icon=None):
759 text = text or N_('Close')
760 icon = icons.mkicon(icon, icons.close)
761 return create_button(text=text, icon=icon)
764 def edit_button(enabled=True, default=False):
765 return create_button(
766 text=N_('Edit'), icon=icons.edit(), enabled=enabled, default=default
770 def refresh_button(enabled=True, default=False):
771 return create_button(
772 text=N_('Refresh'), icon=icons.sync(), enabled=enabled, default=default
776 def checkbox(text='', tooltip='', checked=None):
777 """Create a checkbox"""
778 return _checkbox(QtWidgets.QCheckBox, text, tooltip, checked)
781 def radio(text='', tooltip='', checked=None):
782 """Create a radio button"""
783 return _checkbox(QtWidgets.QRadioButton, text, tooltip, checked)
786 def _checkbox(cls, text, tooltip, checked):
787 """Create a widget and apply properties"""
788 widget = cls()
789 if text:
790 widget.setText(text)
791 if tooltip:
792 widget.setToolTip(tooltip)
793 if checked is not None:
794 widget.setChecked(checked)
795 return widget
798 class DockTitleBarWidget(QtWidgets.QFrame):
799 def __init__(self, parent, title, stretch=True):
800 QtWidgets.QFrame.__init__(self, parent)
801 self.setAutoFillBackground(True)
802 self.label = qlabel = QtWidgets.QLabel(title, self)
803 qfont = qlabel.font()
804 qfont.setBold(True)
805 qlabel.setFont(qfont)
806 qlabel.setCursor(Qt.OpenHandCursor)
808 self.close_button = create_action_button(
809 tooltip=N_('Close'), icon=icons.close()
812 self.toggle_button = create_action_button(
813 tooltip=N_('Detach'), icon=icons.external()
816 self.corner_layout = hbox(defs.no_margin, defs.spacing)
818 if stretch:
819 separator = STRETCH
820 else:
821 separator = SKIPPED
823 self.main_layout = hbox(
824 defs.small_margin,
825 defs.titlebar_spacing,
826 qlabel,
827 separator,
828 self.corner_layout,
829 self.toggle_button,
830 self.close_button,
832 self.setLayout(self.main_layout)
834 connect_button(self.toggle_button, self.toggle_floating)
835 connect_button(self.close_button, self.toggle_visibility)
837 def toggle_floating(self):
838 self.parent().setFloating(not self.parent().isFloating())
839 self.update_tooltips()
841 def toggle_visibility(self):
842 self.parent().toggleViewAction().trigger()
844 def set_title(self, title):
845 self.label.setText(title)
847 def add_corner_widget(self, widget):
848 self.corner_layout.addWidget(widget)
850 def update_tooltips(self):
851 if self.parent().isFloating():
852 tooltip = N_('Attach')
853 else:
854 tooltip = N_('Detach')
855 self.toggle_button.setToolTip(tooltip)
858 def create_dock(name, title, parent, stretch=True, widget=None, func=None):
859 """Create a dock widget and set it up accordingly."""
860 dock = QtWidgets.QDockWidget(parent)
861 dock.setWindowTitle(title)
862 dock.setObjectName(name)
863 titlebar = DockTitleBarWidget(dock, title, stretch=stretch)
864 dock.setTitleBarWidget(titlebar)
865 dock.setAutoFillBackground(True)
866 if hasattr(parent, 'dockwidgets'):
867 parent.dockwidgets.append(dock)
868 if func:
869 widget = func(dock)
870 if widget:
871 dock.setWidget(widget)
872 return dock
875 def hide_dock(widget):
876 widget.toggleViewAction().setChecked(False)
877 widget.hide()
880 def create_menu(title, parent):
881 """Create a menu and set its title."""
882 qmenu = DebouncingMenu(title, parent)
883 return qmenu
886 class DebouncingMenu(QtWidgets.QMenu):
887 """Menu that debounces mouse release action ie. stops it if occurred
888 right after menu creation.
890 Disables annoying behaviour when RMB is pressed to show menu, cursor is
891 moved accidentally 1px onto newly created menu and released causing to
892 execute menu action
895 threshold_ms = 400
897 def __init__(self, title, parent):
898 QtWidgets.QMenu.__init__(self, title, parent)
899 self.created_at = utils.epoch_millis()
900 if hasattr(self, 'setToolTipsVisible'):
901 self.setToolTipsVisible(True)
903 def mouseReleaseEvent(self, event):
904 threshold = DebouncingMenu.threshold_ms
905 if (utils.epoch_millis() - self.created_at) > threshold:
906 QtWidgets.QMenu.mouseReleaseEvent(self, event)
909 def add_menu(title, parent):
910 """Create a menu and set its title."""
911 menu = create_menu(title, parent)
912 if hasattr(parent, 'addMenu'):
913 parent.addMenu(menu)
914 else:
915 parent.addAction(menu.menuAction())
916 return menu
919 def create_toolbutton(text=None, layout=None, tooltip=None, icon=None):
920 button = tool_button()
921 if icon is not None:
922 button.setIcon(icon)
923 button.setIconSize(QtCore.QSize(defs.default_icon, defs.default_icon))
924 if text is not None:
925 button.setText(' ' + text)
926 button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
927 if tooltip is not None:
928 button.setToolTip(tooltip)
929 if layout is not None:
930 layout.addWidget(button)
931 return button
934 def create_toolbutton_with_callback(callback, text, icon, tooltip, layout=None):
935 """Create a toolbutton that runs the specified callback"""
936 toolbutton = create_toolbutton(text=text, layout=layout, tooltip=tooltip, icon=icon)
937 connect_button(toolbutton, callback)
938 return toolbutton
941 # pylint: disable=line-too-long
942 def mimedata_from_paths(context, paths, include_urls=True):
943 """Return mimedata with a list of absolute path URLs
945 Set `include_urls` to False to prevent URLs from being included
946 in the mimedata. This is useful in some terminals that do not gracefully handle
947 multiple URLs being included in the payload.
949 This allows the mimedata to contain just plain a plain text value that we
950 are able to format ourselves.
952 Older verisons of gnome-terminal expected a utf-16 encoding, but that
953 behavior is no longer needed.
954 """ # noqa
955 abspaths = [core.abspath(path) for path in paths]
956 paths_text = core.list2cmdline(abspaths)
958 # The text/x-moz-list format is always included by Qt, and doing
959 # mimedata.removeFormat('text/x-moz-url') has no effect.
960 # http://www.qtcentre.org/threads/44643-Dragging-text-uri-list-Qt-inserts-garbage
962 # Older versions of gnome-terminal expect utf-16 encoded text, but other terminals,
963 # e.g. terminator, expect utf-8, so use cola.dragencoding to override the default.
964 # NOTE: text/x-moz-url does not seem to be used/needed by modern versions of
965 # gnome-terminal, kitty, and terminator.
966 mimedata = QtCore.QMimeData()
967 mimedata.setText(paths_text)
968 if include_urls:
969 urls = [QtCore.QUrl.fromLocalFile(path) for path in abspaths]
970 encoding = context.cfg.get('cola.dragencoding', 'utf-16')
971 encoded_text = core.encode(paths_text, encoding=encoding)
972 mimedata.setUrls(urls)
973 mimedata.setData('text/x-moz-url', encoded_text)
975 return mimedata
978 def path_mimetypes(include_urls=True):
979 """Return a list of mimetypes that we generate"""
980 mime_types = [
981 'text/plain',
982 'text/plain;charset=utf-8',
984 if include_urls:
985 mime_types.append('text/uri-list')
986 mime_types.append('text/x-moz-url')
987 return mime_types
990 class BlockSignals:
991 """Context manager for blocking a signals on a widget"""
993 def __init__(self, *widgets):
994 self.widgets = widgets
995 self.values = []
997 def __enter__(self):
998 """Block Qt signals for all of the captured widgets"""
999 self.values = [widget.blockSignals(True) for widget in self.widgets]
1000 return self
1002 def __exit__(self, exc_type, exc_val, exc_tb):
1003 """Restore Qt signals when we exit the scope"""
1004 for widget, value in zip(self.widgets, self.values):
1005 widget.blockSignals(value)
1008 class Channel(QtCore.QObject):
1009 finished = Signal(object)
1010 result = Signal(object)
1013 class Task(QtCore.QRunnable):
1014 """Run a task in the background and return the result using a Channel"""
1016 def __init__(self):
1017 QtCore.QRunnable.__init__(self)
1019 self.channel = Channel()
1020 self.result = None
1021 # Python's garbage collector will try to double-free the task
1022 # once it's finished, so disable Qt's auto-deletion as a workaround.
1023 self.setAutoDelete(False)
1025 def run(self):
1026 self.result = self.task()
1027 self.channel.result.emit(self.result)
1028 self.channel.finished.emit(self)
1030 def task(self):
1031 """Perform a long-running task"""
1032 return ()
1034 def connect(self, handler):
1035 self.channel.result.connect(handler, type=Qt.QueuedConnection)
1038 class SimpleTask(Task):
1039 """Run a simple callable as a task"""
1041 def __init__(self, func, *args, **kwargs):
1042 Task.__init__(self)
1044 self.func = func
1045 self.args = args
1046 self.kwargs = kwargs
1048 def task(self):
1049 return self.func(*self.args, **self.kwargs)
1052 class RunTask(QtCore.QObject):
1053 """Runs QRunnable instances and transfers control when they finish"""
1055 def __init__(self, parent=None):
1056 QtCore.QObject.__init__(self, parent)
1057 self.tasks = []
1058 self.task_details = {}
1059 self.threadpool = QtCore.QThreadPool.globalInstance()
1060 self.result_func = None
1062 def start(self, task, progress=None, finish=None, result=None):
1063 """Start the task and register a callback"""
1064 self.result_func = result
1065 if progress is not None:
1066 if hasattr(progress, 'start'):
1067 progress.start()
1069 # prevents garbage collection bugs in certain PyQt4 versions
1070 self.tasks.append(task)
1071 task_id = id(task)
1072 self.task_details[task_id] = (progress, finish, result)
1073 task.channel.finished.connect(self.finish, type=Qt.QueuedConnection)
1074 self.threadpool.start(task)
1076 def finish(self, task):
1077 """The task has finished. Run the finish and result callbacks"""
1078 task_id = id(task)
1079 try:
1080 self.tasks.remove(task)
1081 except ValueError:
1082 pass
1083 try:
1084 progress, finish, result = self.task_details[task_id]
1085 del self.task_details[task_id]
1086 except KeyError:
1087 finish = progress = result = None
1089 if progress is not None:
1090 if hasattr(progress, 'stop'):
1091 progress.stop()
1092 progress.hide()
1094 if result is not None:
1095 result(task.result)
1097 if finish is not None:
1098 finish(task)
1101 # Syntax highlighting
1104 def rgb(red, green, blue):
1105 """Create a QColor from r, g, b arguments"""
1106 color = QtGui.QColor()
1107 color.setRgb(red, green, blue)
1108 return color
1111 def rgba(red, green, blue, alpha=255):
1112 """Create a QColor with alpha from r, g, b, a arguments"""
1113 color = rgb(red, green, blue)
1114 color.setAlpha(alpha)
1115 return color
1118 def rgb_triple(args):
1119 """Create a QColor from an argument with an [r, g, b] triple"""
1120 return rgb(*args)
1123 def rgb_css(color):
1124 """Convert a QColor into an rgb #abcdef CSS string"""
1125 return '#%s' % rgb_hex(color)
1128 def rgb_hex(color):
1129 """Convert a QColor into a hex aabbcc string"""
1130 return f'{color.red():02x}{color.green():02x}{color.blue():02x}'
1133 def clamp_color(value):
1134 """Clamp an integer value between 0 and 255"""
1135 return min(255, max(value, 0))
1138 def css_color(value):
1139 """Convert a #abcdef hex string into a QColor"""
1140 if value.startswith('#'):
1141 value = value[1:]
1142 try:
1143 red = clamp_color(int(value[:2], base=16)) # ab
1144 except ValueError:
1145 red = 255
1146 try:
1147 green = clamp_color(int(value[2:4], base=16)) # cd
1148 except ValueError:
1149 green = 255
1150 try:
1151 blue = clamp_color(int(value[4:6], base=16)) # ef
1152 except ValueError:
1153 blue = 255
1154 return rgb(red, green, blue)
1157 def hsl(hue, saturation, lightness):
1158 """Return a QColor from an hue, saturation and lightness"""
1159 return QtGui.QColor.fromHslF(
1160 utils.clamp(hue, 0.0, 1.0),
1161 utils.clamp(saturation, 0.0, 1.0),
1162 utils.clamp(lightness, 0.0, 1.0),
1166 def hsl_css(hue, saturation, lightness):
1167 """Convert HSL values to a CSS #abcdef color string"""
1168 return rgb_css(hsl(hue, saturation, lightness))
1171 def make_format(foreground=None, background=None, bold=False):
1172 """Create a QTextFormat from the provided foreground, background and bold values"""
1173 fmt = QtGui.QTextCharFormat()
1174 if foreground:
1175 fmt.setForeground(foreground)
1176 if background:
1177 fmt.setBackground(background)
1178 if bold:
1179 fmt.setFontWeight(QtGui.QFont.Bold)
1180 return fmt
1183 class ImageFormats:
1184 def __init__(self):
1185 # returns a list of QByteArray objects
1186 formats_qba = QtGui.QImageReader.supportedImageFormats()
1187 # portability: python3 data() returns bytes, python2 returns str
1188 decode = core.decode
1189 formats = [decode(x.data()) for x in formats_qba]
1190 self.extensions = {'.' + fmt for fmt in formats}
1192 def ok(self, filename):
1193 _, ext = os.path.splitext(filename)
1194 return ext.lower() in self.extensions
1197 def set_scrollbar_values(widget, hscroll_value, vscroll_value):
1198 """Set scrollbars to the specified values"""
1199 hscroll = widget.horizontalScrollBar()
1200 if hscroll and hscroll_value is not None:
1201 hscroll.setValue(hscroll_value)
1203 vscroll = widget.verticalScrollBar()
1204 if vscroll and vscroll_value is not None:
1205 vscroll.setValue(vscroll_value)
1208 def get_scrollbar_values(widget):
1209 """Return the current (hscroll, vscroll) scrollbar values for a widget"""
1210 hscroll = widget.horizontalScrollBar()
1211 if hscroll:
1212 hscroll_value = get(hscroll)
1213 else:
1214 hscroll_value = None
1215 vscroll = widget.verticalScrollBar()
1216 if vscroll:
1217 vscroll_value = get(vscroll)
1218 else:
1219 vscroll_value = None
1220 return (hscroll_value, vscroll_value)
1223 def scroll_to_item(widget, item):
1224 """Scroll to an item while retaining the horizontal scroll position"""
1225 hscroll = None
1226 hscrollbar = widget.horizontalScrollBar()
1227 if hscrollbar:
1228 hscroll = get(hscrollbar)
1229 widget.scrollToItem(item)
1230 if hscroll is not None:
1231 hscrollbar.setValue(hscroll)
1234 def select_item(widget, item):
1235 """Scroll to and make a QTreeWidget item selected and current"""
1236 scroll_to_item(widget, item)
1237 widget.setCurrentItem(item)
1238 item.setSelected(True)
1241 def get_selected_values(widget, top_level_idx, values):
1242 """Map the selected items under the top-level item to the values list"""
1243 # Get the top-level item
1244 item = widget.topLevelItem(top_level_idx)
1245 return tree_selection(item, values)
1248 def get_selected_items(widget, idx):
1249 """Return the selected items under the top-level item"""
1250 item = widget.topLevelItem(idx)
1251 return tree_selection_items(item)
1254 def add_menu_actions(menu, menu_actions):
1255 """Add actions to a menu, treating None as a separator"""
1256 current_actions = menu.actions()
1257 if current_actions:
1258 first_action = current_actions[0]
1259 else:
1260 first_action = None
1261 menu.addSeparator()
1263 for action in menu_actions:
1264 if action is None:
1265 action = menu_separator(menu)
1266 menu.insertAction(first_action, action)