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
, fn
):
38 """Connect an action to a function"""
39 action
.triggered
[bool].connect(lambda x
: fn(), type=Qt
.QueuedConnection
)
42 def connect_action_bool(action
, fn
):
43 """Connect a triggered(bool) action to a function"""
44 action
.triggered
[bool].connect(fn
, type=Qt
.QueuedConnection
)
47 def connect_button(button
, fn
):
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
: fn(), type=Qt
.QueuedConnection
)
54 def connect_checkbox(widget
, fn
):
55 """Connect a checkbox to a function taking bool"""
56 widget
.clicked
.connect(
57 lambda *args
, **kwargs
: fn(get(checkbox
)), type=Qt
.QueuedConnection
61 def connect_released(button
, fn
):
62 """Connect a button to a function"""
63 button
.released
.connect(fn
, 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
, fn
):
72 """Connect a toggle button to a function"""
73 toggle
.toggled
.connect(fn
, 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 ok
= accepted
and all(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
):
377 TYPE
= standard_item_type_value(101)
379 def __init__(self
, path
, icon
, deleted
):
380 QtWidgets
.QTreeWidgetItem
.__init
__(self
)
382 self
.deleted
= deleted
383 self
.setIcon(0, icons
.from_name(icon
))
384 self
.setText(0, path
)
390 def paths_from_indexes(model
, indexes
, item_type
=TreeWidgetItem
.TYPE
, item_filter
=None):
391 """Return paths from a list of QStandardItemModel indexes"""
392 items
= [model
.itemFromIndex(i
) for i
in indexes
]
393 return paths_from_items(items
, item_type
=item_type
, item_filter
=item_filter
)
396 def _true_filter(_x
):
400 def paths_from_items(items
, item_type
=TreeWidgetItem
.TYPE
, item_filter
=None):
401 """Return a list of paths from a list of items"""
402 if item_filter
is None:
403 item_filter
= _true_filter
404 return [i
.path
for i
in items
if i
.type() == item_type
and item_filter(i
)]
407 def tree_selection(tree_item
, items
):
408 """Returns an array of model items that correspond to the selected
409 QTreeWidgetItem children"""
411 count
= min(tree_item
.childCount(), len(items
))
412 for idx
in range(count
):
413 if tree_item
.child(idx
).isSelected():
414 selected
.append(items
[idx
])
419 def tree_selection_items(tree_item
):
420 """Returns selected widget items"""
422 for idx
in range(tree_item
.childCount()):
423 child
= tree_item
.child(idx
)
424 if child
.isSelected():
425 selected
.append(child
)
430 def selected_item(list_widget
, items
):
431 """Returns the model item that corresponds to the selected QListWidget
433 widget_items
= list_widget
.selectedItems()
436 widget_item
= widget_items
[0]
437 row
= list_widget
.row(widget_item
)
445 def selected_items(list_widget
, items
):
446 """Returns an array of model items that correspond to the selected
448 item_count
= len(items
)
450 for widget_item
in list_widget
.selectedItems():
451 row
= list_widget
.row(widget_item
)
453 selected
.append(items
[row
])
457 def open_file(title
, directory
=None):
458 """Creates an Open File dialog and returns a filename."""
459 result
= compat
.getopenfilename(
460 parent
=active_window(), caption
=title
, basedir
=directory
465 def open_files(title
, directory
=None, filters
=''):
466 """Creates an Open File dialog and returns a list of filenames."""
467 result
= compat
.getopenfilenames(
468 parent
=active_window(), caption
=title
, basedir
=directory
, filters
=filters
473 def opendir_dialog(caption
, path
):
474 """Prompts for a directory path"""
476 QtWidgets
.QFileDialog
.Directory
477 | QtWidgets
.QFileDialog
.DontResolveSymlinks
478 | QtWidgets
.QFileDialog
.ReadOnly
479 | QtWidgets
.QFileDialog
.ShowDirsOnly
481 return compat
.getexistingdirectory(
482 parent
=active_window(), caption
=caption
, basedir
=path
, options
=options
486 def save_as(filename
, title
='Save As...'):
487 """Creates a Save File dialog and returns a filename."""
488 result
= compat
.getsavefilename(
489 parent
=active_window(), caption
=title
, basedir
=filename
494 def copy_path(filename
, absolute
=True):
495 """Copy a filename to the clipboard"""
499 filename
= core
.abspath(filename
)
500 set_clipboard(filename
)
503 def set_clipboard(text
):
504 """Sets the copy/paste buffer to text."""
507 clipboard
= QtWidgets
.QApplication
.clipboard()
508 clipboard
.setText(text
, QtGui
.QClipboard
.Clipboard
)
509 if not utils
.is_darwin() and not utils
.is_win32():
510 clipboard
.setText(text
, QtGui
.QClipboard
.Selection
)
514 # pylint: disable=line-too-long
515 def persist_clipboard():
516 """Persist the clipboard
518 X11 stores only a reference to the clipboard data.
519 Send a clipboard event to force a copy of the clipboard to occur.
520 This ensures that the clipboard is present after git-cola exits.
521 Otherwise, the reference is destroyed on exit.
523 C.f. https://stackoverflow.com/questions/2007103/how-can-i-disable-clear-of-clipboard-on-exit-of-pyqt4-application
526 clipboard
= QtWidgets
.QApplication
.clipboard()
527 event
= QtCore
.QEvent(QtCore
.QEvent
.Clipboard
)
528 QtWidgets
.QApplication
.sendEvent(clipboard
, event
)
531 def add_action_bool(widget
, text
, fn
, checked
, *shortcuts
):
533 action
= _add_action(widget
, text
, tip
, fn
, connect_action_bool
, *shortcuts
)
534 action
.setCheckable(True)
535 action
.setChecked(checked
)
539 def add_action(widget
, text
, func
, *shortcuts
):
540 """Create a QAction and bind it to the `func` callback and hotkeys"""
542 return _add_action(widget
, text
, tip
, func
, connect_action
, *shortcuts
)
545 def add_action_with_icon(widget
, icon
, text
, func
, *shortcuts
):
546 """Create a QAction using a custom icon bound to the `func` callback and hotkeys"""
548 action
= _add_action(widget
, text
, tip
, func
, connect_action
, *shortcuts
)
553 def add_action_with_status_tip(widget
, text
, tip
, func
, *shortcuts
):
554 return _add_action(widget
, text
, tip
, func
, connect_action
, *shortcuts
)
557 def menu_separator(widget
):
558 """Return a QAction whose isSeparator() returns true. Used in context menus"""
559 action
= QtWidgets
.QAction('', widget
)
560 action
.setSeparator(True)
564 def _add_action(widget
, text
, tip
, fn
, connect
, *shortcuts
):
565 action
= QtWidgets
.QAction(text
, widget
)
566 if hasattr(action
, 'setIconVisibleInMenu'):
567 action
.setIconVisibleInMenu(True)
569 action
.setStatusTip(tip
)
572 action
.setShortcuts(shortcuts
)
573 if hasattr(Qt
, 'WidgetWithChildrenShortcut'):
574 action
.setShortcutContext(Qt
.WidgetWithChildrenShortcut
)
575 widget
.addAction(action
)
579 def set_selected_item(widget
, idx
):
580 """Sets a the currently selected item to the item at index idx."""
581 if isinstance(widget
, QtWidgets
.QTreeWidget
):
582 item
= widget
.topLevelItem(idx
)
584 item
.setSelected(True)
585 widget
.setCurrentItem(item
)
588 def add_items(widget
, items
):
589 """Adds items to a widget."""
596 def set_items(widget
, items
):
597 """Clear the existing widget contents and set the new items."""
599 add_items(widget
, items
)
602 def create_treeitem(filename
, staged
=False, deleted
=False, untracked
=False):
603 """Given a filename, return a TreeWidgetItem for a status widget
605 "staged", "deleted, and "untracked" control which icon is used.
608 icon_name
= icons
.status(filename
, deleted
, staged
, untracked
)
609 icon
= icons
.name_from_basename(icon_name
)
610 return TreeWidgetItem(filename
, icon
, deleted
=deleted
)
613 def add_close_action(widget
):
614 """Adds close action and shortcuts to a widget."""
615 return add_action(widget
, N_('Close...'), widget
.close
, hotkeys
.CLOSE
, hotkeys
.QUIT
)
619 """Return the current application"""
620 return QtWidgets
.QApplication
.instance()
624 """Return the desktop"""
625 return app().desktop()
630 rect
= desk
.screenGeometry(QtGui
.QCursor().pos())
631 return (rect
.width(), rect
.height())
634 def center_on_screen(widget
):
635 """Move widget to the center of the default screen"""
636 width
, height
= desktop_size()
639 widget
.move(cx
- widget
.width() // 2, cy
- widget
.height() // 2)
642 def default_size(parent
, width
, height
, use_parent_height
=True):
643 """Return the parent's size, or the provided defaults"""
644 if parent
is not None:
645 width
= parent
.width()
646 if use_parent_height
:
647 height
= parent
.height()
648 return (width
, height
)
651 def default_monospace_font():
652 if utils
.is_darwin():
656 mfont
= QtGui
.QFont()
657 mfont
.setFamily(family
)
661 def diff_font_str(context
):
663 font_str
= cfg
.get(prefs
.FONTDIFF
)
665 font_str
= default_monospace_font().toString()
669 def diff_font(context
):
670 return font(diff_font_str(context
))
674 qfont
= QtGui
.QFont()
675 qfont
.fromString(string
)
680 text
='', layout
=None, tooltip
=None, icon
=None, enabled
=True, default
=False
682 """Create a button, set its title, and add it to the parent."""
683 button
= QtWidgets
.QPushButton()
684 button
.setCursor(Qt
.PointingHandCursor
)
685 button
.setFocusPolicy(Qt
.NoFocus
)
687 button
.setText(' ' + text
)
690 button
.setIconSize(QtCore
.QSize(defs
.small_icon
, defs
.small_icon
))
691 if tooltip
is not None:
692 button
.setToolTip(tooltip
)
693 if layout
is not None:
694 layout
.addWidget(button
)
696 button
.setEnabled(False)
698 button
.setDefault(True)
703 """Create a flat border-less button"""
704 button
= QtWidgets
.QToolButton()
705 button
.setPopupMode(QtWidgets
.QToolButton
.InstantPopup
)
706 button
.setCursor(Qt
.PointingHandCursor
)
707 button
.setFocusPolicy(Qt
.NoFocus
)
709 palette
= QtGui
.QPalette()
710 highlight
= palette
.color(QtGui
.QPalette
.Highlight
)
711 highlight_rgb
= rgb_css(highlight
)
713 button
.setStyleSheet(
718 background-color: none;
720 /* Hide the menu indicator */
721 QToolButton::menu-indicator {
725 border: %(border)spx solid %(highlight_rgb)s;
728 % dict(border
=defs
.border
, highlight_rgb
=highlight_rgb
)
733 def create_action_button(tooltip
=None, icon
=None, visible
=True):
734 """Create a small toolbutton for use in dock title widgets"""
735 button
= tool_button()
736 if tooltip
is not None:
737 button
.setToolTip(tooltip
)
740 button
.setIconSize(QtCore
.QSize(defs
.small_icon
, defs
.small_icon
))
741 button
.setVisible(visible
)
745 def ok_button(text
, default
=True, enabled
=True, icon
=None):
748 return create_button(text
=text
, icon
=icon
, default
=default
, enabled
=enabled
)
751 def close_button(text
=None, icon
=None):
752 text
= text
or N_('Close')
753 icon
= icons
.mkicon(icon
, icons
.close
)
754 return create_button(text
=text
, icon
=icon
)
757 def edit_button(enabled
=True, default
=False):
758 return create_button(
759 text
=N_('Edit'), icon
=icons
.edit(), enabled
=enabled
, default
=default
763 def refresh_button(enabled
=True, default
=False):
764 return create_button(
765 text
=N_('Refresh'), icon
=icons
.sync(), enabled
=enabled
, default
=default
769 def checkbox(text
='', tooltip
='', checked
=None):
770 """Create a checkbox"""
771 return _checkbox(QtWidgets
.QCheckBox
, text
, tooltip
, checked
)
774 def radio(text
='', tooltip
='', checked
=None):
775 """Create a radio button"""
776 return _checkbox(QtWidgets
.QRadioButton
, text
, tooltip
, checked
)
779 def _checkbox(cls
, text
, tooltip
, checked
):
780 """Create a widget and apply properties"""
785 widget
.setToolTip(tooltip
)
786 if checked
is not None:
787 widget
.setChecked(checked
)
791 class DockTitleBarWidget(QtWidgets
.QFrame
):
792 def __init__(self
, parent
, title
, stretch
=True):
793 QtWidgets
.QFrame
.__init
__(self
, parent
)
794 self
.setAutoFillBackground(True)
795 self
.label
= qlabel
= QtWidgets
.QLabel(title
, self
)
796 qfont
= qlabel
.font()
798 qlabel
.setFont(qfont
)
799 qlabel
.setCursor(Qt
.OpenHandCursor
)
801 self
.close_button
= create_action_button(
802 tooltip
=N_('Close'), icon
=icons
.close()
805 self
.toggle_button
= create_action_button(
806 tooltip
=N_('Detach'), icon
=icons
.external()
809 self
.corner_layout
= hbox(defs
.no_margin
, defs
.spacing
)
816 self
.main_layout
= hbox(
818 defs
.titlebar_spacing
,
825 self
.setLayout(self
.main_layout
)
827 connect_button(self
.toggle_button
, self
.toggle_floating
)
828 connect_button(self
.close_button
, self
.toggle_visibility
)
830 def toggle_floating(self
):
831 self
.parent().setFloating(not self
.parent().isFloating())
832 self
.update_tooltips()
834 def toggle_visibility(self
):
835 self
.parent().toggleViewAction().trigger()
837 def set_title(self
, title
):
838 self
.label
.setText(title
)
840 def add_corner_widget(self
, widget
):
841 self
.corner_layout
.addWidget(widget
)
843 def update_tooltips(self
):
844 if self
.parent().isFloating():
845 tooltip
= N_('Attach')
847 tooltip
= N_('Detach')
848 self
.toggle_button
.setToolTip(tooltip
)
851 def create_dock(name
, title
, parent
, stretch
=True, widget
=None, fn
=None):
852 """Create a dock widget and set it up accordingly."""
853 dock
= QtWidgets
.QDockWidget(parent
)
854 dock
.setWindowTitle(title
)
855 dock
.setObjectName(name
)
856 titlebar
= DockTitleBarWidget(dock
, title
, stretch
=stretch
)
857 dock
.setTitleBarWidget(titlebar
)
858 dock
.setAutoFillBackground(True)
859 if hasattr(parent
, 'dockwidgets'):
860 parent
.dockwidgets
.append(dock
)
863 assert isinstance(widget
, QtWidgets
.QFrame
), "Docked widget has to be a QFrame"
865 dock
.setWidget(widget
)
869 def hide_dock(widget
):
870 widget
.toggleViewAction().setChecked(False)
874 def create_menu(title
, parent
):
875 """Create a menu and set its title."""
876 qmenu
= DebouncingMenu(title
, parent
)
880 class DebouncingMenu(QtWidgets
.QMenu
):
881 """Menu that debounces mouse release action ie. stops it if occurred
882 right after menu creation.
884 Disables annoying behaviour when RMB is pressed to show menu, cursor is
885 moved accidentally 1px onto newly created menu and released causing to
891 def __init__(self
, title
, parent
):
892 QtWidgets
.QMenu
.__init
__(self
, title
, parent
)
893 self
.created_at
= utils
.epoch_millis()
894 if hasattr(self
, 'setToolTipsVisible'):
895 self
.setToolTipsVisible(True)
897 def mouseReleaseEvent(self
, event
):
898 threshold
= DebouncingMenu
.threshold_ms
899 if (utils
.epoch_millis() - self
.created_at
) > threshold
:
900 QtWidgets
.QMenu
.mouseReleaseEvent(self
, event
)
903 def add_menu(title
, parent
):
904 """Create a menu and set its title."""
905 menu
= create_menu(title
, parent
)
906 if hasattr(parent
, 'addMenu'):
909 parent
.addAction(menu
.menuAction())
913 def create_toolbutton(text
=None, layout
=None, tooltip
=None, icon
=None):
914 button
= tool_button()
917 button
.setIconSize(QtCore
.QSize(defs
.default_icon
, defs
.default_icon
))
919 button
.setText(' ' + text
)
920 button
.setToolButtonStyle(Qt
.ToolButtonTextBesideIcon
)
921 if tooltip
is not None:
922 button
.setToolTip(tooltip
)
923 if layout
is not None:
924 layout
.addWidget(button
)
928 def create_toolbutton_with_callback(callback
, text
, icon
, tooltip
, layout
=None):
929 """Create a toolbutton that runs the specified callback"""
930 toolbutton
= create_toolbutton(text
=text
, layout
=layout
, tooltip
=tooltip
, icon
=icon
)
931 connect_button(toolbutton
, callback
)
935 # pylint: disable=line-too-long
936 def mimedata_from_paths(context
, paths
, include_urls
=True):
937 """Return mimedata with a list of absolute path URLs
939 Set `include_urls` to False to prevent URLs from being included
940 in the mimedata. This is useful in some terminals that do not gracefully handle
941 multiple URLs being included in the payload.
943 This allows the mimedata to contain just plain a plain text value that we
944 are able to format ourselves.
946 Older verisons of gnome-terminal expected a utf-16 encoding, but that
947 behavior is no longer needed.
949 abspaths
= [core
.abspath(path
) for path
in paths
]
950 paths_text
= core
.list2cmdline(abspaths
)
952 # The text/x-moz-list format is always included by Qt, and doing
953 # mimedata.removeFormat('text/x-moz-url') has no effect.
954 # http://www.qtcentre.org/threads/44643-Dragging-text-uri-list-Qt-inserts-garbage
956 # Older versions of gnome-terminal expect utf-16 encoded text, but other terminals,
957 # e.g. terminator, expect utf-8, so use cola.dragencoding to override the default.
958 # NOTE: text/x-moz-url does not seem to be used/needed by modern versions of
959 # gnome-terminal, kitty, and terminator.
960 mimedata
= QtCore
.QMimeData()
961 mimedata
.setText(paths_text
)
963 urls
= [QtCore
.QUrl
.fromLocalFile(path
) for path
in abspaths
]
964 encoding
= context
.cfg
.get('cola.dragencoding', 'utf-16')
965 encoded_text
= core
.encode(paths_text
, encoding
=encoding
)
966 mimedata
.setUrls(urls
)
967 mimedata
.setData('text/x-moz-url', encoded_text
)
972 def path_mimetypes(include_urls
=True):
973 """Return a list of mimetypes that we generate"""
976 'text/plain;charset=utf-8',
979 mime_types
.append('text/uri-list')
980 mime_types
.append('text/x-moz-url')
984 class BlockSignals(object):
985 """Context manager for blocking a signals on a widget"""
987 def __init__(self
, *widgets
):
988 self
.widgets
= widgets
992 """Block Qt signals for all of the captured widgets"""
993 self
.values
= [widget
.blockSignals(True) for widget
in self
.widgets
]
996 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
997 """Restore Qt signals when we exit the scope"""
998 for (widget
, value
) in zip(self
.widgets
, self
.values
):
999 widget
.blockSignals(value
)
1002 class Channel(QtCore
.QObject
):
1003 finished
= Signal(object)
1004 result
= Signal(object)
1007 class Task(QtCore
.QRunnable
):
1008 """Disable auto-deletion to avoid gc issues
1010 Python's garbage collector will try to double-free the task
1011 once it's finished, so disable Qt's auto-deletion as a workaround.
1016 QtCore
.QRunnable
.__init
__(self
)
1018 self
.channel
= Channel()
1020 self
.setAutoDelete(False)
1023 self
.result
= self
.task()
1024 self
.channel
.result
.emit(self
.result
)
1025 self
.channel
.finished
.emit(self
)
1027 # pylint: disable=no-self-use
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
, fn
, *args
, **kwargs
):
1043 self
.kwargs
= kwargs
1046 return self
.fn(*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_fn
= None
1059 def start(self
, task
, progress
=None, finish
=None, result
=None):
1060 """Start the task and register a callback"""
1061 self
.result_fn
= result
1062 if progress
is not None:
1064 # prevents garbage collection bugs in certain PyQt4 versions
1065 self
.tasks
.append(task
)
1067 self
.task_details
[task_id
] = (progress
, finish
, result
)
1068 task
.channel
.finished
.connect(self
.finish
, type=Qt
.QueuedConnection
)
1069 self
.threadpool
.start(task
)
1071 def finish(self
, task
):
1074 self
.tasks
.remove(task
)
1078 progress
, finish
, result
= self
.task_details
[task_id
]
1079 del self
.task_details
[task_id
]
1081 finish
= progress
= result
= None
1083 if progress
is not None:
1086 if result
is not None:
1089 if finish
is not None:
1093 # Syntax highlighting
1096 """Create a QColor from r, g, b arguments"""
1097 color
= QtGui
.QColor()
1098 color
.setRgb(r
, g
, b
)
1102 def rgba(r
, g
, b
, a
=255):
1103 """Create a QColor with alpha from r, g, b, a arguments"""
1104 color
= rgb(r
, g
, b
)
1110 """Create a QColor from a list of [r, g, b] arguments"""
1115 """Convert a QColor into an rgb #abcdef CSS string"""
1116 return '#%s' % rgb_hex(color
)
1120 """Convert a QColor into a hex aabbcc string"""
1121 return '%02x%02x%02x' % (color
.red(), color
.green(), color
.blue())
1124 def clamp_color(value
):
1125 """Clamp an integer value between 0 and 255"""
1126 return min(255, max(value
, 0))
1129 def css_color(value
):
1130 """Convert a #abcdef hex string into a QColor"""
1131 if value
.startswith('#'):
1134 r
= clamp_color(int(value
[:2], base
=16)) # ab
1138 g
= clamp_color(int(value
[2:4], base
=16)) # cd
1142 b
= clamp_color(int(value
[4:6], base
=16)) # ef
1148 def hsl(h
, s
, light
):
1149 return QtGui
.QColor
.fromHslF(
1150 utils
.clamp(h
, 0.0, 1.0), utils
.clamp(s
, 0.0, 1.0), utils
.clamp(light
, 0.0, 1.0)
1154 def hsl_css(h
, s
, light
):
1155 """Convert HSL values to a CSS #abcdef color string"""
1156 return rgb_css(hsl(h
, s
, light
))
1159 def make_format(fg
=None, bg
=None, bold
=False):
1160 fmt
= QtGui
.QTextCharFormat()
1162 fmt
.setForeground(fg
)
1164 fmt
.setBackground(bg
)
1166 fmt
.setFontWeight(QtGui
.QFont
.Bold
)
1170 class ImageFormats(object):
1172 # returns a list of QByteArray objects
1173 formats_qba
= QtGui
.QImageReader
.supportedImageFormats()
1174 # portability: python3 data() returns bytes, python2 returns str
1175 decode
= core
.decode
1176 formats
= [decode(x
.data()) for x
in formats_qba
]
1177 self
.extensions
= {'.' + fmt
for fmt
in formats
}
1179 def ok(self
, filename
):
1180 _
, ext
= os
.path
.splitext(filename
)
1181 return ext
.lower() in self
.extensions
1184 def set_scrollbar_values(widget
, hscroll_value
, vscroll_value
):
1185 """Set scrollbars to the specified values"""
1186 hscroll
= widget
.horizontalScrollBar()
1187 if hscroll
and hscroll_value
is not None:
1188 hscroll
.setValue(hscroll_value
)
1190 vscroll
= widget
.verticalScrollBar()
1191 if vscroll
and vscroll_value
is not None:
1192 vscroll
.setValue(vscroll_value
)
1195 def get_scrollbar_values(widget
):
1196 """Return the current (hscroll, vscroll) scrollbar values for a widget"""
1197 hscroll
= widget
.horizontalScrollBar()
1199 hscroll_value
= get(hscroll
)
1201 hscroll_value
= None
1202 vscroll
= widget
.verticalScrollBar()
1204 vscroll_value
= get(vscroll
)
1206 vscroll_value
= None
1207 return (hscroll_value
, vscroll_value
)
1210 def scroll_to_item(widget
, item
):
1211 """Scroll to an item while retaining the horizontal scroll position"""
1213 hscrollbar
= widget
.horizontalScrollBar()
1215 hscroll
= get(hscrollbar
)
1216 widget
.scrollToItem(item
)
1217 if hscroll
is not None:
1218 hscrollbar
.setValue(hscroll
)
1221 def select_item(widget
, item
):
1222 """Scroll to and make a QTreeWidget item selected and current"""
1223 scroll_to_item(widget
, item
)
1224 widget
.setCurrentItem(item
)
1225 item
.setSelected(True)
1228 def get_selected_values(widget
, top_level_idx
, values
):
1229 """Map the selected items under the top-level item to the values list"""
1230 # Get the top-level item
1231 item
= widget
.topLevelItem(top_level_idx
)
1232 return tree_selection(item
, values
)
1235 def get_selected_items(widget
, idx
):
1236 """Return the selected items under the top-level item"""
1237 item
= widget
.topLevelItem(idx
)
1238 return tree_selection_items(item
)
1241 def add_menu_actions(menu
, menu_actions
):
1242 """Add actions to a menu, treating None as a separator"""
1243 current_actions
= menu
.actions()
1245 first_action
= current_actions
[0]
1250 for action
in menu_actions
:
1252 action
= menu_separator(menu
)
1253 menu
.insertAction(first_action
, action
)