1 """Miscellaneous Qt utility functions."""
2 from __future__
import absolute_import
, division
, print_function
, unicode_literals
5 from qtpy
import compat
7 from qtpy
import QtCore
8 from qtpy
import QtWidgets
9 from qtpy
.QtCore
import Qt
10 from qtpy
.QtCore
import Signal
17 from .compat
import int_types
18 from .compat
import ustr
19 from .models
import prefs
20 from .widgets
import defs
28 """Return the active window for the current application"""
29 return QtWidgets
.QApplication
.activeWindow()
32 def current_palette():
33 """Return the QPalette for the current application"""
34 return QtWidgets
.QApplication
.instance().palette()
37 def connect_action(action
, func
):
38 """Connect an action to a function"""
39 action
.triggered
[bool].connect(lambda x
: func(), type=Qt
.QueuedConnection
)
42 def connect_action_bool(action
, func
):
43 """Connect a triggered(bool) action to a function"""
44 action
.triggered
[bool].connect(func
, type=Qt
.QueuedConnection
)
47 def connect_button(button
, func
):
48 """Connect a button to a function"""
49 # Some versions of Qt send the `bool` argument to the clicked callback,
50 # and some do not. The lambda consumes all callback-provided arguments.
51 button
.clicked
.connect(lambda *args
, **kwargs
: func(), type=Qt
.QueuedConnection
)
54 def connect_checkbox(widget
, func
):
55 """Connect a checkbox to a function taking bool"""
56 widget
.clicked
.connect(
57 lambda *args
, **kwargs
: func(get(checkbox
)), type=Qt
.QueuedConnection
61 def connect_released(button
, func
):
62 """Connect a button to a function"""
63 button
.released
.connect(func
, type=Qt
.QueuedConnection
)
66 def button_action(button
, action
):
67 """Make a button trigger an action"""
68 connect_button(button
, action
.trigger
)
71 def connect_toggle(toggle
, func
):
72 """Connect a toggle button to a function"""
73 toggle
.toggled
.connect(func
, type=Qt
.QueuedConnection
)
76 def disconnect(signal
):
77 """Disconnect signal from all slots"""
80 except TypeError: # allow unconnected slots
84 def get(widget
, default
=None):
85 """Query a widget for its python value"""
86 if hasattr(widget
, 'isChecked'):
87 value
= widget
.isChecked()
88 elif hasattr(widget
, 'value'):
89 value
= widget
.value()
90 elif hasattr(widget
, 'text'):
92 elif hasattr(widget
, 'toPlainText'):
93 value
= widget
.toPlainText()
94 elif hasattr(widget
, 'sizes'):
95 value
= widget
.sizes()
96 elif hasattr(widget
, 'date'):
97 value
= widget
.date().toString(Qt
.ISODate
)
103 def hbox(margin
, spacing
, *items
):
104 """Create an HBoxLayout with the specified sizes and items"""
105 return box(QtWidgets
.QHBoxLayout
, margin
, spacing
, *items
)
108 def vbox(margin
, spacing
, *items
):
109 """Create a VBoxLayout with the specified sizes and items"""
110 return box(QtWidgets
.QVBoxLayout
, margin
, spacing
, *items
)
113 def buttongroup(*items
):
114 """Create a QButtonGroup for the specified items"""
115 group
= QtWidgets
.QButtonGroup()
121 def set_margin(layout
, margin
):
122 """Set the content margins for a layout"""
123 layout
.setContentsMargins(margin
, margin
, margin
, margin
)
126 def box(cls
, margin
, spacing
, *items
):
127 """Create a QBoxLayout with the specified sizes and items"""
131 layout
.setSpacing(spacing
)
132 set_margin(layout
, margin
)
135 if isinstance(i
, QtWidgets
.QWidget
):
140 QtWidgets
.QHBoxLayout
,
141 QtWidgets
.QVBoxLayout
,
142 QtWidgets
.QFormLayout
,
151 elif isinstance(i
, int_types
):
157 def form(margin
, spacing
, *widgets
):
158 """Create a QFormLayout with the specified sizes and items"""
159 layout
= QtWidgets
.QFormLayout()
160 layout
.setSpacing(spacing
)
161 layout
.setFieldGrowthPolicy(QtWidgets
.QFormLayout
.ExpandingFieldsGrow
)
162 set_margin(layout
, margin
)
164 for idx
, (name
, widget
) in enumerate(widgets
):
165 if isinstance(name
, (str, ustr
)):
166 layout
.addRow(name
, widget
)
168 layout
.setWidget(idx
, QtWidgets
.QFormLayout
.LabelRole
, name
)
169 layout
.setWidget(idx
, QtWidgets
.QFormLayout
.FieldRole
, widget
)
174 def grid(margin
, spacing
, *widgets
):
175 """Create a QGridLayout with the specified sizes and items"""
176 layout
= QtWidgets
.QGridLayout()
177 layout
.setSpacing(spacing
)
178 set_margin(layout
, margin
)
182 if isinstance(item
, QtWidgets
.QWidget
):
183 layout
.addWidget(*row
)
184 elif isinstance(item
, QtWidgets
.QLayoutItem
):
190 def splitter(orientation
, *widgets
):
191 """Create a spliter over the specified widgets
193 :param orientation: Qt.Horizontal or Qt.Vertical
196 layout
= QtWidgets
.QSplitter()
197 layout
.setOrientation(orientation
)
198 layout
.setHandleWidth(defs
.handle_width
)
199 layout
.setChildrenCollapsible(True)
201 for idx
, widget
in enumerate(widgets
):
202 layout
.addWidget(widget
)
203 layout
.setStretchFactor(idx
, 1)
205 # Workaround for Qt not setting the WA_Hover property for QSplitter
206 # Cf. https://bugreports.qt.io/browse/QTBUG-13768
207 layout
.handle(1).setAttribute(Qt
.WA_Hover
)
212 def label(text
=None, align
=None, fmt
=None, selectable
=True):
213 """Create a QLabel with the specified properties"""
214 widget
= QtWidgets
.QLabel()
215 if align
is not None:
216 widget
.setAlignment(align
)
218 widget
.setTextFormat(fmt
)
220 widget
.setTextInteractionFlags(Qt
.TextBrowserInteraction
)
221 widget
.setOpenExternalLinks(True)
227 class ComboBox(QtWidgets
.QComboBox
):
228 """Custom read-only combobox with a convenient API"""
230 def __init__(self
, items
=None, editable
=False, parent
=None, transform
=None):
231 super(ComboBox
, self
).__init
__(parent
)
232 self
.setEditable(editable
)
233 self
.transform
= transform
237 self
.item_data
.extend(items
)
239 def set_index(self
, idx
):
240 idx
= utils
.clamp(idx
, 0, self
.count() - 1)
241 self
.setCurrentIndex(idx
)
243 def add_item(self
, text
, data
):
245 self
.item_data
.append(data
)
247 def current_data(self
):
248 return self
.item_data
[self
.currentIndex()]
250 def set_value(self
, value
):
252 value
= self
.transform(value
)
254 index
= self
.item_data
.index(value
)
257 self
.setCurrentIndex(index
)
260 def combo(items
, editable
=False, parent
=None):
261 """Create a readonly (by default) combobox from a list of items"""
262 return ComboBox(editable
=editable
, items
=items
, parent
=parent
)
265 def combo_mapped(data
, editable
=False, transform
=None, parent
=None):
266 """Create a readonly (by default) combobox from a list of items"""
267 widget
= ComboBox(editable
=editable
, transform
=transform
, parent
=parent
)
269 widget
.add_item(k
, v
)
273 def textbrowser(text
=None):
274 """Create a QTextBrowser for the specified text"""
275 widget
= QtWidgets
.QTextBrowser()
276 widget
.setOpenExternalLinks(True)
282 def add_completer(widget
, items
):
283 """Add simple completion to a widget"""
284 completer
= QtWidgets
.QCompleter(items
, widget
)
285 completer
.setCaseSensitivity(Qt
.CaseInsensitive
)
286 completer
.setCompletionMode(QtWidgets
.QCompleter
.InlineCompletion
)
287 widget
.setCompleter(completer
)
290 def prompt(msg
, title
=None, text
='', parent
=None):
291 """Presents the user with an input widget and returns the input."""
295 parent
= active_window()
296 result
= QtWidgets
.QInputDialog
.getText(
297 parent
, title
, msg
, QtWidgets
.QLineEdit
.Normal
, text
299 return (result
[0], result
[1])
302 def prompt_n(msg
, inputs
):
303 """Presents the user with N input widgets and returns the results"""
304 dialog
= QtWidgets
.QDialog(active_window())
305 dialog
.setWindowModality(Qt
.WindowModal
)
306 dialog
.setWindowTitle(msg
)
310 if len(k
+ v
) > len(long_value
):
313 metrics
= QtGui
.QFontMetrics(dialog
.font())
314 min_width
= min(720, metrics
.width(long_value
) + 100)
315 dialog
.setMinimumWidth(min_width
)
317 ok_b
= ok_button(msg
, enabled
=False)
318 close_b
= close_button()
323 return [pair
[1].text().strip() for pair
in form_widgets
]
325 for name
, value
in inputs
:
326 lineedit
= QtWidgets
.QLineEdit()
327 # Enable the OK button only when all fields have been populated
328 # pylint: disable=no-member
329 lineedit
.textChanged
.connect(
330 lambda x
: ok_b
.setEnabled(all(get_values())), type=Qt
.QueuedConnection
333 lineedit
.setText(value
)
334 form_widgets
.append((name
, lineedit
))
337 form_layout
= form(defs
.no_margin
, defs
.button_spacing
, *form_widgets
)
338 button_layout
= hbox(defs
.no_margin
, defs
.button_spacing
, STRETCH
, close_b
, ok_b
)
339 main_layout
= vbox(defs
.margin
, defs
.button_spacing
, form_layout
, button_layout
)
340 dialog
.setLayout(main_layout
)
343 connect_button(ok_b
, dialog
.accept
)
344 connect_button(close_b
, dialog
.reject
)
346 accepted
= dialog
.exec_() == QtWidgets
.QDialog
.Accepted
348 success
= accepted
and all(text
)
349 return (success
, text
)
352 def standard_item_type_value(value
):
353 """Return a custom UserType for use in QTreeWidgetItem.type() overrides"""
354 return custom_item_type_value(QtGui
.QStandardItem
, value
)
357 def graphics_item_type_value(value
):
358 """Return a custom UserType for use in QGraphicsItem.type() overrides"""
359 return custom_item_type_value(QtWidgets
.QGraphicsItem
, value
)
362 def custom_item_type_value(cls
, value
):
363 """Return a custom cls.UserType for use in cls.type() overrides"""
364 user_type
= enum_value(cls
.UserType
)
365 return user_type
+ value
368 def enum_value(value
):
369 """Qt6 has enums with an inner '.value' attribute."""
370 if hasattr(value
, 'value'):
375 class TreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
376 TYPE
= standard_item_type_value(101)
378 def __init__(self
, path
, icon
, deleted
):
379 QtWidgets
.QTreeWidgetItem
.__init
__(self
)
381 self
.deleted
= deleted
382 self
.setIcon(0, icons
.from_name(icon
))
383 self
.setText(0, path
)
389 def paths_from_indexes(model
, indexes
, item_type
=TreeWidgetItem
.TYPE
, item_filter
=None):
390 """Return paths from a list of QStandardItemModel indexes"""
391 items
= [model
.itemFromIndex(i
) for i
in indexes
]
392 return paths_from_items(items
, item_type
=item_type
, item_filter
=item_filter
)
395 def _true_filter(_value
):
399 def paths_from_items(items
, item_type
=TreeWidgetItem
.TYPE
, item_filter
=None):
400 """Return a list of paths from a list of items"""
401 if item_filter
is None:
402 item_filter
= _true_filter
403 return [i
.path
for i
in items
if i
.type() == item_type
and item_filter(i
)]
406 def tree_selection(tree_item
, items
):
407 """Returns an array of model items that correspond to the selected
408 QTreeWidgetItem children"""
410 count
= min(tree_item
.childCount(), len(items
))
411 for idx
in range(count
):
412 if tree_item
.child(idx
).isSelected():
413 selected
.append(items
[idx
])
418 def tree_selection_items(tree_item
):
419 """Returns selected widget items"""
421 for idx
in range(tree_item
.childCount()):
422 child
= tree_item
.child(idx
)
423 if child
.isSelected():
424 selected
.append(child
)
429 def selected_item(list_widget
, items
):
430 """Returns the model item that corresponds to the selected QListWidget
432 widget_items
= list_widget
.selectedItems()
435 widget_item
= widget_items
[0]
436 row
= list_widget
.row(widget_item
)
444 def selected_items(list_widget
, items
):
445 """Returns an array of model items that correspond to the selected
447 item_count
= len(items
)
449 for widget_item
in list_widget
.selectedItems():
450 row
= list_widget
.row(widget_item
)
452 selected
.append(items
[row
])
456 def open_file(title
, directory
=None):
457 """Creates an Open File dialog and returns a filename."""
458 result
= compat
.getopenfilename(
459 parent
=active_window(), caption
=title
, basedir
=directory
464 def open_files(title
, directory
=None, filters
=''):
465 """Creates an Open File dialog and returns a list of filenames."""
466 result
= compat
.getopenfilenames(
467 parent
=active_window(), caption
=title
, basedir
=directory
, filters
=filters
472 def opendir_dialog(caption
, path
):
473 """Prompts for a directory path"""
475 QtWidgets
.QFileDialog
.Directory
476 | QtWidgets
.QFileDialog
.DontResolveSymlinks
477 | QtWidgets
.QFileDialog
.ReadOnly
478 | QtWidgets
.QFileDialog
.ShowDirsOnly
480 return compat
.getexistingdirectory(
481 parent
=active_window(), caption
=caption
, basedir
=path
, options
=options
485 def save_as(filename
, title
='Save As...'):
486 """Creates a Save File dialog and returns a filename."""
487 result
= compat
.getsavefilename(
488 parent
=active_window(), caption
=title
, basedir
=filename
493 def existing_file(directory
, title
='Append...'):
494 """Creates a Save File dialog and returns a filename."""
495 result
= compat
.getopenfilename(
496 parent
=active_window(), caption
=title
, basedir
=directory
501 def copy_path(filename
, absolute
=True):
502 """Copy a filename to the clipboard"""
506 filename
= core
.abspath(filename
)
507 set_clipboard(filename
)
510 def set_clipboard(text
):
511 """Sets the copy/paste buffer to text."""
514 clipboard
= QtWidgets
.QApplication
.clipboard()
515 clipboard
.setText(text
, QtGui
.QClipboard
.Clipboard
)
516 if not utils
.is_darwin() and not utils
.is_win32():
517 clipboard
.setText(text
, QtGui
.QClipboard
.Selection
)
521 # pylint: disable=line-too-long
522 def persist_clipboard():
523 """Persist the clipboard
525 X11 stores only a reference to the clipboard data.
526 Send a clipboard event to force a copy of the clipboard to occur.
527 This ensures that the clipboard is present after git-cola exits.
528 Otherwise, the reference is destroyed on exit.
530 C.f. https://stackoverflow.com/questions/2007103/how-can-i-disable-clear-of-clipboard-on-exit-of-pyqt4-application
533 clipboard
= QtWidgets
.QApplication
.clipboard()
534 event
= QtCore
.QEvent(QtCore
.QEvent
.Clipboard
)
535 QtWidgets
.QApplication
.sendEvent(clipboard
, event
)
538 def add_action_bool(widget
, text
, func
, checked
, *shortcuts
):
540 action
= _add_action(widget
, text
, tip
, func
, connect_action_bool
, *shortcuts
)
541 action
.setCheckable(True)
542 action
.setChecked(checked
)
546 def add_action(widget
, text
, func
, *shortcuts
):
547 """Create a QAction and bind it to the `func` callback and hotkeys"""
549 return _add_action(widget
, text
, tip
, func
, connect_action
, *shortcuts
)
552 def add_action_with_icon(widget
, icon
, text
, func
, *shortcuts
):
553 """Create a QAction using a custom icon bound to the `func` callback and hotkeys"""
555 action
= _add_action(widget
, text
, tip
, func
, connect_action
, *shortcuts
)
560 def add_action_with_status_tip(widget
, text
, tip
, func
, *shortcuts
):
561 return _add_action(widget
, text
, tip
, func
, connect_action
, *shortcuts
)
564 def menu_separator(widget
):
565 """Return a QAction whose isSeparator() returns true. Used in context menus"""
566 action
= QtWidgets
.QAction('', widget
)
567 action
.setSeparator(True)
571 def _add_action(widget
, text
, tip
, func
, connect
, *shortcuts
):
572 action
= QtWidgets
.QAction(text
, widget
)
573 if hasattr(action
, 'setIconVisibleInMenu'):
574 action
.setIconVisibleInMenu(True)
576 action
.setStatusTip(tip
)
577 connect(action
, func
)
579 action
.setShortcuts(shortcuts
)
580 if hasattr(Qt
, 'WidgetWithChildrenShortcut'):
581 action
.setShortcutContext(Qt
.WidgetWithChildrenShortcut
)
582 widget
.addAction(action
)
586 def set_selected_item(widget
, idx
):
587 """Sets a the currently selected item to the item at index idx."""
588 if isinstance(widget
, QtWidgets
.QTreeWidget
):
589 item
= widget
.topLevelItem(idx
)
591 item
.setSelected(True)
592 widget
.setCurrentItem(item
)
595 def add_items(widget
, items
):
596 """Adds items to a widget."""
603 def set_items(widget
, items
):
604 """Clear the existing widget contents and set the new items."""
606 add_items(widget
, items
)
609 def create_treeitem(filename
, staged
=False, deleted
=False, untracked
=False):
610 """Given a filename, return a TreeWidgetItem for a status widget
612 "staged", "deleted, and "untracked" control which icon is used.
615 icon_name
= icons
.status(filename
, deleted
, staged
, untracked
)
616 icon
= icons
.name_from_basename(icon_name
)
617 return TreeWidgetItem(filename
, icon
, deleted
=deleted
)
620 def add_close_action(widget
):
621 """Adds close action and shortcuts to a widget."""
622 return add_action(widget
, N_('Close...'), widget
.close
, hotkeys
.CLOSE
, hotkeys
.QUIT
)
626 """Return the current application"""
627 return QtWidgets
.QApplication
.instance()
631 rect
= app().primaryScreen().geometry()
632 return (rect
.width(), rect
.height())
635 def center_on_screen(widget
):
636 """Move widget to the center of the default screen"""
637 width
, height
= desktop_size()
638 center_x
= width
// 2
639 center_y
= height
// 2
640 widget
.move(center_x
- widget
.width() // 2, center_y
- widget
.height() // 2)
643 def default_size(parent
, width
, height
, use_parent_height
=True):
644 """Return the parent's size, or the provided defaults"""
645 if parent
is not None:
646 width
= parent
.width()
647 if use_parent_height
:
648 height
= parent
.height()
649 return (width
, height
)
652 def default_monospace_font():
653 if utils
.is_darwin():
657 mfont
= QtGui
.QFont()
658 mfont
.setFamily(family
)
662 def diff_font_str(context
):
664 font_str
= cfg
.get(prefs
.FONTDIFF
)
666 font_str
= default_monospace_font().toString()
670 def diff_font(context
):
671 return font(diff_font_str(context
))
675 qfont
= QtGui
.QFont()
676 qfont
.fromString(string
)
681 text
='', layout
=None, tooltip
=None, icon
=None, enabled
=True, default
=False
683 """Create a button, set its title, and add it to the parent."""
684 button
= QtWidgets
.QPushButton()
685 button
.setCursor(Qt
.PointingHandCursor
)
686 button
.setFocusPolicy(Qt
.NoFocus
)
688 button
.setText(' ' + text
)
691 button
.setIconSize(QtCore
.QSize(defs
.small_icon
, defs
.small_icon
))
692 if tooltip
is not None:
693 button
.setToolTip(tooltip
)
694 if layout
is not None:
695 layout
.addWidget(button
)
697 button
.setEnabled(False)
699 button
.setDefault(True)
704 """Create a flat border-less button"""
705 button
= QtWidgets
.QToolButton()
706 button
.setPopupMode(QtWidgets
.QToolButton
.InstantPopup
)
707 button
.setCursor(Qt
.PointingHandCursor
)
708 button
.setFocusPolicy(Qt
.NoFocus
)
710 palette
= QtGui
.QPalette()
711 highlight
= palette
.color(QtGui
.QPalette
.Highlight
)
712 highlight_rgb
= rgb_css(highlight
)
714 button
.setStyleSheet(
719 background-color: none;
721 /* Hide the menu indicator */
722 QToolButton::menu-indicator {
726 border: %(border)spx solid %(highlight_rgb)s;
730 'border': defs
.border
,
731 'highlight_rgb': highlight_rgb
,
737 def create_action_button(tooltip
=None, icon
=None, visible
=True):
738 """Create a small toolbutton for use in dock title widgets"""
739 button
= tool_button()
740 if tooltip
is not None:
741 button
.setToolTip(tooltip
)
744 button
.setIconSize(QtCore
.QSize(defs
.small_icon
, defs
.small_icon
))
745 button
.setVisible(visible
)
749 def ok_button(text
, default
=True, enabled
=True, icon
=None):
752 return create_button(text
=text
, icon
=icon
, default
=default
, enabled
=enabled
)
755 def close_button(text
=None, icon
=None):
756 text
= text
or N_('Close')
757 icon
= icons
.mkicon(icon
, icons
.close
)
758 return create_button(text
=text
, icon
=icon
)
761 def edit_button(enabled
=True, default
=False):
762 return create_button(
763 text
=N_('Edit'), icon
=icons
.edit(), enabled
=enabled
, default
=default
767 def refresh_button(enabled
=True, default
=False):
768 return create_button(
769 text
=N_('Refresh'), icon
=icons
.sync(), enabled
=enabled
, default
=default
773 def checkbox(text
='', tooltip
='', checked
=None):
774 """Create a checkbox"""
775 return _checkbox(QtWidgets
.QCheckBox
, text
, tooltip
, checked
)
778 def radio(text
='', tooltip
='', checked
=None):
779 """Create a radio button"""
780 return _checkbox(QtWidgets
.QRadioButton
, text
, tooltip
, checked
)
783 def _checkbox(cls
, text
, tooltip
, checked
):
784 """Create a widget and apply properties"""
789 widget
.setToolTip(tooltip
)
790 if checked
is not None:
791 widget
.setChecked(checked
)
795 class DockTitleBarWidget(QtWidgets
.QFrame
):
796 def __init__(self
, parent
, title
, stretch
=True):
797 QtWidgets
.QFrame
.__init
__(self
, parent
)
798 self
.setAutoFillBackground(True)
799 self
.label
= qlabel
= QtWidgets
.QLabel(title
, self
)
800 qfont
= qlabel
.font()
802 qlabel
.setFont(qfont
)
803 qlabel
.setCursor(Qt
.OpenHandCursor
)
805 self
.close_button
= create_action_button(
806 tooltip
=N_('Close'), icon
=icons
.close()
809 self
.toggle_button
= create_action_button(
810 tooltip
=N_('Detach'), icon
=icons
.external()
813 self
.corner_layout
= hbox(defs
.no_margin
, defs
.spacing
)
820 self
.main_layout
= hbox(
822 defs
.titlebar_spacing
,
829 self
.setLayout(self
.main_layout
)
831 connect_button(self
.toggle_button
, self
.toggle_floating
)
832 connect_button(self
.close_button
, self
.toggle_visibility
)
834 def toggle_floating(self
):
835 self
.parent().setFloating(not self
.parent().isFloating())
836 self
.update_tooltips()
838 def toggle_visibility(self
):
839 self
.parent().toggleViewAction().trigger()
841 def set_title(self
, title
):
842 self
.label
.setText(title
)
844 def add_corner_widget(self
, widget
):
845 self
.corner_layout
.addWidget(widget
)
847 def update_tooltips(self
):
848 if self
.parent().isFloating():
849 tooltip
= N_('Attach')
851 tooltip
= N_('Detach')
852 self
.toggle_button
.setToolTip(tooltip
)
855 def create_dock(name
, title
, parent
, stretch
=True, widget
=None, func
=None):
856 """Create a dock widget and set it up accordingly."""
857 dock
= QtWidgets
.QDockWidget(parent
)
858 dock
.setWindowTitle(title
)
859 dock
.setObjectName(name
)
860 titlebar
= DockTitleBarWidget(dock
, title
, stretch
=stretch
)
861 dock
.setTitleBarWidget(titlebar
)
862 dock
.setAutoFillBackground(True)
863 if hasattr(parent
, 'dockwidgets'):
864 parent
.dockwidgets
.append(dock
)
868 dock
.setWidget(widget
)
872 def hide_dock(widget
):
873 widget
.toggleViewAction().setChecked(False)
877 def create_menu(title
, parent
):
878 """Create a menu and set its title."""
879 qmenu
= DebouncingMenu(title
, parent
)
883 class DebouncingMenu(QtWidgets
.QMenu
):
884 """Menu that debounces mouse release action ie. stops it if occurred
885 right after menu creation.
887 Disables annoying behaviour when RMB is pressed to show menu, cursor is
888 moved accidentally 1px onto newly created menu and released causing to
894 def __init__(self
, title
, parent
):
895 QtWidgets
.QMenu
.__init
__(self
, title
, parent
)
896 self
.created_at
= utils
.epoch_millis()
897 if hasattr(self
, 'setToolTipsVisible'):
898 self
.setToolTipsVisible(True)
900 def mouseReleaseEvent(self
, event
):
901 threshold
= DebouncingMenu
.threshold_ms
902 if (utils
.epoch_millis() - self
.created_at
) > threshold
:
903 QtWidgets
.QMenu
.mouseReleaseEvent(self
, event
)
906 def add_menu(title
, parent
):
907 """Create a menu and set its title."""
908 menu
= create_menu(title
, parent
)
909 if hasattr(parent
, 'addMenu'):
912 parent
.addAction(menu
.menuAction())
916 def create_toolbutton(text
=None, layout
=None, tooltip
=None, icon
=None):
917 button
= tool_button()
920 button
.setIconSize(QtCore
.QSize(defs
.default_icon
, defs
.default_icon
))
922 button
.setText(' ' + text
)
923 button
.setToolButtonStyle(Qt
.ToolButtonTextBesideIcon
)
924 if tooltip
is not None:
925 button
.setToolTip(tooltip
)
926 if layout
is not None:
927 layout
.addWidget(button
)
931 def create_toolbutton_with_callback(callback
, text
, icon
, tooltip
, layout
=None):
932 """Create a toolbutton that runs the specified callback"""
933 toolbutton
= create_toolbutton(text
=text
, layout
=layout
, tooltip
=tooltip
, icon
=icon
)
934 connect_button(toolbutton
, callback
)
938 # pylint: disable=line-too-long
939 def mimedata_from_paths(context
, paths
, include_urls
=True):
940 """Return mimedata with a list of absolute path URLs
942 Set `include_urls` to False to prevent URLs from being included
943 in the mimedata. This is useful in some terminals that do not gracefully handle
944 multiple URLs being included in the payload.
946 This allows the mimedata to contain just plain a plain text value that we
947 are able to format ourselves.
949 Older verisons of gnome-terminal expected a utf-16 encoding, but that
950 behavior is no longer needed.
952 abspaths
= [core
.abspath(path
) for path
in paths
]
953 paths_text
= core
.list2cmdline(abspaths
)
955 # The text/x-moz-list format is always included by Qt, and doing
956 # mimedata.removeFormat('text/x-moz-url') has no effect.
957 # http://www.qtcentre.org/threads/44643-Dragging-text-uri-list-Qt-inserts-garbage
959 # Older versions of gnome-terminal expect utf-16 encoded text, but other terminals,
960 # e.g. terminator, expect utf-8, so use cola.dragencoding to override the default.
961 # NOTE: text/x-moz-url does not seem to be used/needed by modern versions of
962 # gnome-terminal, kitty, and terminator.
963 mimedata
= QtCore
.QMimeData()
964 mimedata
.setText(paths_text
)
966 urls
= [QtCore
.QUrl
.fromLocalFile(path
) for path
in abspaths
]
967 encoding
= context
.cfg
.get('cola.dragencoding', 'utf-16')
968 encoded_text
= core
.encode(paths_text
, encoding
=encoding
)
969 mimedata
.setUrls(urls
)
970 mimedata
.setData('text/x-moz-url', encoded_text
)
975 def path_mimetypes(include_urls
=True):
976 """Return a list of mimetypes that we generate"""
979 'text/plain;charset=utf-8',
982 mime_types
.append('text/uri-list')
983 mime_types
.append('text/x-moz-url')
987 class BlockSignals(object):
988 """Context manager for blocking a signals on a widget"""
990 def __init__(self
, *widgets
):
991 self
.widgets
= widgets
995 """Block Qt signals for all of the captured widgets"""
996 self
.values
= [widget
.blockSignals(True) for widget
in self
.widgets
]
999 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1000 """Restore Qt signals when we exit the scope"""
1001 for widget
, value
in zip(self
.widgets
, self
.values
):
1002 widget
.blockSignals(value
)
1005 class Channel(QtCore
.QObject
):
1006 finished
= Signal(object)
1007 result
= Signal(object)
1010 class Task(QtCore
.QRunnable
):
1011 """Run a task in the background and return the result using a Channel"""
1014 QtCore
.QRunnable
.__init
__(self
)
1016 self
.channel
= Channel()
1018 # Python's garbage collector will try to double-free the task
1019 # once it's finished, so disable Qt's auto-deletion as a workaround.
1020 self
.setAutoDelete(False)
1023 self
.result
= self
.task()
1024 self
.channel
.result
.emit(self
.result
)
1025 self
.channel
.finished
.emit(self
)
1028 """Perform a long-running task"""
1031 def connect(self
, handler
):
1032 self
.channel
.result
.connect(handler
, type=Qt
.QueuedConnection
)
1035 class SimpleTask(Task
):
1036 """Run a simple callable as a task"""
1038 def __init__(self
, func
, *args
, **kwargs
):
1043 self
.kwargs
= kwargs
1046 return self
.func(*self
.args
, **self
.kwargs
)
1049 class RunTask(QtCore
.QObject
):
1050 """Runs QRunnable instances and transfers control when they finish"""
1052 def __init__(self
, parent
=None):
1053 QtCore
.QObject
.__init
__(self
, parent
)
1055 self
.task_details
= {}
1056 self
.threadpool
= QtCore
.QThreadPool
.globalInstance()
1057 self
.result_func
= None
1059 def start(self
, task
, progress
=None, finish
=None, result
=None):
1060 """Start the task and register a callback"""
1061 self
.result_func
= result
1062 if progress
is not None:
1064 if hasattr(progress
, 'start'):
1067 # prevents garbage collection bugs in certain PyQt4 versions
1068 self
.tasks
.append(task
)
1070 self
.task_details
[task_id
] = (progress
, finish
, result
)
1071 task
.channel
.finished
.connect(self
.finish
, type=Qt
.QueuedConnection
)
1072 self
.threadpool
.start(task
)
1074 def finish(self
, task
):
1075 """The task has finished. Run the finish and result callbacks"""
1078 self
.tasks
.remove(task
)
1082 progress
, finish
, result
= self
.task_details
[task_id
]
1083 del self
.task_details
[task_id
]
1085 finish
= progress
= result
= None
1087 if progress
is not None:
1088 if hasattr(progress
, 'stop'):
1092 if result
is not None:
1095 if finish
is not None:
1099 # Syntax highlighting
1102 def rgb(red
, green
, blue
):
1103 """Create a QColor from r, g, b arguments"""
1104 color
= QtGui
.QColor()
1105 color
.setRgb(red
, green
, blue
)
1109 def rgba(red
, green
, blue
, alpha
=255):
1110 """Create a QColor with alpha from r, g, b, a arguments"""
1111 color
= rgb(red
, green
, blue
)
1112 color
.setAlpha(alpha
)
1116 def rgb_triple(args
):
1117 """Create a QColor from an argument with an [r, g, b] triple"""
1122 """Convert a QColor into an rgb #abcdef CSS string"""
1123 return '#%s' % rgb_hex(color
)
1127 """Convert a QColor into a hex aabbcc string"""
1128 return '%02x%02x%02x' % (color
.red(), color
.green(), color
.blue())
1131 def clamp_color(value
):
1132 """Clamp an integer value between 0 and 255"""
1133 return min(255, max(value
, 0))
1136 def css_color(value
):
1137 """Convert a #abcdef hex string into a QColor"""
1138 if value
.startswith('#'):
1141 red
= clamp_color(int(value
[:2], base
=16)) # ab
1145 green
= clamp_color(int(value
[2:4], base
=16)) # cd
1149 blue
= clamp_color(int(value
[4:6], base
=16)) # ef
1152 return rgb(red
, green
, blue
)
1155 def hsl(hue
, saturation
, lightness
):
1156 """Return a QColor from an hue, saturation and lightness"""
1157 return QtGui
.QColor
.fromHslF(
1158 utils
.clamp(hue
, 0.0, 1.0),
1159 utils
.clamp(saturation
, 0.0, 1.0),
1160 utils
.clamp(lightness
, 0.0, 1.0),
1164 def hsl_css(hue
, saturation
, lightness
):
1165 """Convert HSL values to a CSS #abcdef color string"""
1166 return rgb_css(hsl(hue
, saturation
, lightness
))
1169 def make_format(foreground
=None, background
=None, bold
=False):
1170 """Create a QTextFormat from the provided foreground, background and bold values"""
1171 fmt
= QtGui
.QTextCharFormat()
1173 fmt
.setForeground(foreground
)
1175 fmt
.setBackground(background
)
1177 fmt
.setFontWeight(QtGui
.QFont
.Bold
)
1181 class ImageFormats(object):
1183 # returns a list of QByteArray objects
1184 formats_qba
= QtGui
.QImageReader
.supportedImageFormats()
1185 # portability: python3 data() returns bytes, python2 returns str
1186 decode
= core
.decode
1187 formats
= [decode(x
.data()) for x
in formats_qba
]
1188 self
.extensions
= {'.' + fmt
for fmt
in formats
}
1190 def ok(self
, filename
):
1191 _
, ext
= os
.path
.splitext(filename
)
1192 return ext
.lower() in self
.extensions
1195 def set_scrollbar_values(widget
, hscroll_value
, vscroll_value
):
1196 """Set scrollbars to the specified values"""
1197 hscroll
= widget
.horizontalScrollBar()
1198 if hscroll
and hscroll_value
is not None:
1199 hscroll
.setValue(hscroll_value
)
1201 vscroll
= widget
.verticalScrollBar()
1202 if vscroll
and vscroll_value
is not None:
1203 vscroll
.setValue(vscroll_value
)
1206 def get_scrollbar_values(widget
):
1207 """Return the current (hscroll, vscroll) scrollbar values for a widget"""
1208 hscroll
= widget
.horizontalScrollBar()
1210 hscroll_value
= get(hscroll
)
1212 hscroll_value
= None
1213 vscroll
= widget
.verticalScrollBar()
1215 vscroll_value
= get(vscroll
)
1217 vscroll_value
= None
1218 return (hscroll_value
, vscroll_value
)
1221 def scroll_to_item(widget
, item
):
1222 """Scroll to an item while retaining the horizontal scroll position"""
1224 hscrollbar
= widget
.horizontalScrollBar()
1226 hscroll
= get(hscrollbar
)
1227 widget
.scrollToItem(item
)
1228 if hscroll
is not None:
1229 hscrollbar
.setValue(hscroll
)
1232 def select_item(widget
, item
):
1233 """Scroll to and make a QTreeWidget item selected and current"""
1234 scroll_to_item(widget
, item
)
1235 widget
.setCurrentItem(item
)
1236 item
.setSelected(True)
1239 def get_selected_values(widget
, top_level_idx
, values
):
1240 """Map the selected items under the top-level item to the values list"""
1241 # Get the top-level item
1242 item
= widget
.topLevelItem(top_level_idx
)
1243 return tree_selection(item
, values
)
1246 def get_selected_items(widget
, idx
):
1247 """Return the selected items under the top-level item"""
1248 item
= widget
.topLevelItem(idx
)
1249 return tree_selection_items(item
)
1252 def add_menu_actions(menu
, menu_actions
):
1253 """Add actions to a menu, treating None as a separator"""
1254 current_actions
= menu
.actions()
1256 first_action
= current_actions
[0]
1261 for action
in menu_actions
:
1263 action
= menu_separator(menu
)
1264 menu
.insertAction(first_action
, action
)