1 """Miscellaneous Qt utility functions."""
4 from qtpy
import compat
6 from qtpy
import QtCore
7 from qtpy
import QtWidgets
8 from qtpy
.QtCore
import Qt
9 from qtpy
.QtCore
import Signal
16 from .compat
import int_types
17 from .compat
import ustr
18 from .models
import prefs
19 from .widgets
import defs
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"""
79 except TypeError: # allow unconnected slots
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'):
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
)
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()
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"""
130 layout
.setSpacing(spacing
)
131 set_margin(layout
, margin
)
134 if isinstance(i
, QtWidgets
.QWidget
):
139 QtWidgets
.QHBoxLayout
,
140 QtWidgets
.QVBoxLayout
,
141 QtWidgets
.QFormLayout
,
150 elif isinstance(i
, int_types
):
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
)
167 layout
.setWidget(idx
, QtWidgets
.QFormLayout
.LabelRole
, name
)
168 layout
.setWidget(idx
, QtWidgets
.QFormLayout
.FieldRole
, widget
)
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
)
181 if isinstance(item
, QtWidgets
.QWidget
):
182 layout
.addWidget(*row
)
183 elif isinstance(item
, QtWidgets
.QLayoutItem
):
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
)
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
)
217 widget
.setTextFormat(fmt
)
219 widget
.setTextInteractionFlags(Qt
.TextBrowserInteraction
)
220 widget
.setOpenExternalLinks(True)
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
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
):
244 self
.item_data
.append(data
)
246 def current_data(self
):
247 return self
.item_data
[self
.currentIndex()]
249 def set_value(self
, value
):
251 value
= self
.transform(value
)
253 index
= self
.item_data
.index(value
)
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
)
263 combobox
.setToolTip(tooltip
)
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
)
271 widget
.add_item(k
, v
)
275 def textbrowser(text
=None):
276 """Create a QTextBrowser for the specified text"""
277 widget
= QtWidgets
.QTextBrowser()
278 widget
.setOpenExternalLinks(True)
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."""
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
)
312 if len(k
+ v
) > len(long_value
):
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()
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
335 lineedit
.setText(value
)
336 form_widgets
.append((name
, lineedit
))
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
)
345 connect_button(ok_b
, dialog
.accept
)
346 connect_button(close_b
, dialog
.reject
)
348 accepted
= dialog
.exec_() == QtWidgets
.QDialog
.Accepted
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'):
377 class TreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
378 TYPE
= standard_item_type_value(101)
380 def __init__(self
, path
, icon
, deleted
):
381 QtWidgets
.QTreeWidgetItem
.__init
__(self
)
383 self
.deleted
= deleted
384 self
.setIcon(0, icons
.from_name(icon
))
385 self
.setText(0, path
)
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
):
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"""
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
])
420 def tree_selection_items(tree_item
):
421 """Returns selected widget items"""
423 for idx
in range(tree_item
.childCount()):
424 child
= tree_item
.child(idx
)
425 if child
.isSelected():
426 selected
.append(child
)
431 def selected_item(list_widget
, items
):
432 """Returns the model item that corresponds to the selected QListWidget
434 widget_items
= list_widget
.selectedItems()
437 widget_item
= widget_items
[0]
438 row
= list_widget
.row(widget_item
)
446 def selected_items(list_widget
, items
):
447 """Returns an array of model items that correspond to the selected
449 item_count
= len(items
)
451 for widget_item
in list_widget
.selectedItems():
452 row
= list_widget
.row(widget_item
)
454 selected
.append(items
[row
])
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
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
474 def opendir_dialog(caption
, path
):
475 """Prompts for a directory path"""
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
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
503 def copy_path(filename
, absolute
=True):
504 """Copy a filename to the clipboard"""
508 filename
= core
.abspath(filename
)
509 set_clipboard(filename
)
512 def set_clipboard(text
):
513 """Sets the copy/paste buffer to text."""
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
)
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
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
):
542 action
= _add_action(widget
, text
, tip
, func
, connect_action_bool
, *shortcuts
)
543 action
.setCheckable(True)
544 action
.setChecked(checked
)
548 def add_action(widget
, text
, func
, *shortcuts
):
549 """Create a QAction and bind it to the `func` callback and hotkeys"""
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"""
557 action
= _add_action(widget
, text
, tip
, func
, connect_action
, *shortcuts
)
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
):
567 """Return a QAction whose isSeparator() returns true. Used in context menus"""
568 action
= QtWidgets
.QAction('', widget
)
569 action
.setSeparator(True)
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)
578 action
.setStatusTip(tip
)
579 connect(action
, func
)
581 action
.setShortcuts(shortcuts
)
582 if hasattr(Qt
, 'WidgetWithChildrenShortcut'):
583 action
.setShortcutContext(Qt
.WidgetWithChildrenShortcut
)
584 widget
.addAction(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
)
593 item
.setSelected(True)
594 widget
.setCurrentItem(item
)
597 def add_items(widget
, items
):
598 """Adds items to a widget."""
605 def set_items(widget
, items
):
606 """Clear the existing widget contents and set the new items."""
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
)
628 """Return the current application"""
629 return QtWidgets
.QApplication
.instance()
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():
659 mfont
= QtGui
.QFont()
660 mfont
.setFamily(family
)
664 def diff_font_str(context
):
666 font_str
= cfg
.get(prefs
.FONTDIFF
)
668 font_str
= default_monospace_font().toString()
672 def diff_font(context
):
673 return font(diff_font_str(context
))
677 qfont
= QtGui
.QFont()
678 qfont
.fromString(string
)
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
)
690 button
.setText(' ' + text
)
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
)
699 button
.setEnabled(False)
701 button
.setDefault(True)
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
)
712 palette
= QtGui
.QPalette()
713 highlight
= palette
.color(QtGui
.QPalette
.Highlight
)
714 highlight_rgb
= rgb_css(highlight
)
716 button
.setStyleSheet(
721 background-color: none;
723 /* Hide the menu indicator */
724 QToolButton::menu-indicator {
728 border: %(border)spx solid %(highlight_rgb)s;
732 'border': defs
.border
,
733 'highlight_rgb': highlight_rgb
,
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
)
746 button
.setIconSize(QtCore
.QSize(defs
.small_icon
, defs
.small_icon
))
747 if visible
is not None:
748 button
.setVisible(visible
)
752 def ok_button(text
, default
=True, enabled
=True, icon
=None):
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"""
792 widget
.setToolTip(tooltip
)
793 if checked
is not None:
794 widget
.setChecked(checked
)
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()
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
)
823 self
.main_layout
= hbox(
825 defs
.titlebar_spacing
,
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')
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
)
871 dock
.setWidget(widget
)
875 def hide_dock(widget
):
876 widget
.toggleViewAction().setChecked(False)
880 def create_menu(title
, parent
):
881 """Create a menu and set its title."""
882 qmenu
= DebouncingMenu(title
, parent
)
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
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'):
915 parent
.addAction(menu
.menuAction())
919 def create_toolbutton(text
=None, layout
=None, tooltip
=None, icon
=None):
920 button
= tool_button()
923 button
.setIconSize(QtCore
.QSize(defs
.default_icon
, defs
.default_icon
))
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
)
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
)
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.
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
)
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
)
978 def path_mimetypes(include_urls
=True):
979 """Return a list of mimetypes that we generate"""
982 'text/plain;charset=utf-8',
985 mime_types
.append('text/uri-list')
986 mime_types
.append('text/x-moz-url')
991 """Context manager for blocking a signals on a widget"""
993 def __init__(self
, *widgets
):
994 self
.widgets
= widgets
998 """Block Qt signals for all of the captured widgets"""
999 self
.values
= [widget
.blockSignals(True) for widget
in self
.widgets
]
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"""
1017 QtCore
.QRunnable
.__init
__(self
)
1019 self
.channel
= Channel()
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)
1026 self
.result
= self
.task()
1027 self
.channel
.result
.emit(self
.result
)
1028 self
.channel
.finished
.emit(self
)
1031 """Perform a long-running task"""
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
):
1046 self
.kwargs
= kwargs
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
)
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:
1067 if hasattr(progress
, 'start'):
1070 # prevents garbage collection bugs in certain PyQt4 versions
1071 self
.tasks
.append(task
)
1073 self
.task_details
[task_id
] = (progress
, finish
, result
)
1074 task
.channel
.finished
.connect(self
.finish
, type=Qt
.QueuedConnection
)
1075 self
.threadpool
.start(task
)
1077 def finish(self
, task
):
1078 """The task has finished. Run the finish and result callbacks"""
1081 self
.tasks
.remove(task
)
1085 progress
, finish
, result
= self
.task_details
[task_id
]
1086 del self
.task_details
[task_id
]
1088 finish
= progress
= result
= None
1090 if progress
is not None:
1091 if hasattr(progress
, 'stop'):
1095 if result
is not None:
1098 if finish
is not None:
1102 # Syntax highlighting
1105 def rgb(red
, green
, blue
):
1106 """Create a QColor from r, g, b arguments"""
1107 color
= QtGui
.QColor()
1108 color
.setRgb(red
, green
, blue
)
1112 def rgba(red
, green
, blue
, alpha
=255):
1113 """Create a QColor with alpha from r, g, b, a arguments"""
1114 color
= rgb(red
, green
, blue
)
1115 color
.setAlpha(alpha
)
1119 def rgb_triple(args
):
1120 """Create a QColor from an argument with an [r, g, b] triple"""
1125 """Convert a QColor into an rgb #abcdef CSS string"""
1126 return '#%s' % rgb_hex(color
)
1130 """Convert a QColor into a hex aabbcc string"""
1131 return f
'{color.red():02x}{color.green():02x}{color.blue():02x}'
1134 def clamp_color(value
):
1135 """Clamp an integer value between 0 and 255"""
1136 return min(255, max(value
, 0))
1139 def css_color(value
):
1140 """Convert a #abcdef hex string into a QColor"""
1141 if value
.startswith('#'):
1144 red
= clamp_color(int(value
[:2], base
=16)) # ab
1148 green
= clamp_color(int(value
[2:4], base
=16)) # cd
1152 blue
= clamp_color(int(value
[4:6], base
=16)) # ef
1155 return rgb(red
, green
, blue
)
1158 def hsl(hue
, saturation
, lightness
):
1159 """Return a QColor from an hue, saturation and lightness"""
1160 return QtGui
.QColor
.fromHslF(
1161 utils
.clamp(hue
, 0.0, 1.0),
1162 utils
.clamp(saturation
, 0.0, 1.0),
1163 utils
.clamp(lightness
, 0.0, 1.0),
1167 def hsl_css(hue
, saturation
, lightness
):
1168 """Convert HSL values to a CSS #abcdef color string"""
1169 return rgb_css(hsl(hue
, saturation
, lightness
))
1172 def make_format(foreground
=None, background
=None, bold
=False):
1173 """Create a QTextFormat from the provided foreground, background and bold values"""
1174 fmt
= QtGui
.QTextCharFormat()
1176 fmt
.setForeground(foreground
)
1178 fmt
.setBackground(background
)
1180 fmt
.setFontWeight(QtGui
.QFont
.Bold
)
1186 # returns a list of QByteArray objects
1187 formats_qba
= QtGui
.QImageReader
.supportedImageFormats()
1188 # portability: python3 data() returns bytes, python2 returns str
1189 decode
= core
.decode
1190 formats
= [decode(x
.data()) for x
in formats_qba
]
1191 self
.extensions
= {'.' + fmt
for fmt
in formats
}
1193 def ok(self
, filename
):
1194 _
, ext
= os
.path
.splitext(filename
)
1195 return ext
.lower() in self
.extensions
1198 def set_scrollbar_values(widget
, hscroll_value
, vscroll_value
):
1199 """Set scrollbars to the specified values"""
1200 hscroll
= widget
.horizontalScrollBar()
1201 if hscroll
and hscroll_value
is not None:
1202 hscroll
.setValue(hscroll_value
)
1204 vscroll
= widget
.verticalScrollBar()
1205 if vscroll
and vscroll_value
is not None:
1206 vscroll
.setValue(vscroll_value
)
1209 def get_scrollbar_values(widget
):
1210 """Return the current (hscroll, vscroll) scrollbar values for a widget"""
1211 hscroll
= widget
.horizontalScrollBar()
1213 hscroll_value
= get(hscroll
)
1215 hscroll_value
= None
1216 vscroll
= widget
.verticalScrollBar()
1218 vscroll_value
= get(vscroll
)
1220 vscroll_value
= None
1221 return (hscroll_value
, vscroll_value
)
1224 def scroll_to_item(widget
, item
):
1225 """Scroll to an item while retaining the horizontal scroll position"""
1227 hscrollbar
= widget
.horizontalScrollBar()
1229 hscroll
= get(hscrollbar
)
1230 widget
.scrollToItem(item
)
1231 if hscroll
is not None:
1232 hscrollbar
.setValue(hscroll
)
1235 def select_item(widget
, item
):
1236 """Scroll to and make a QTreeWidget item selected and current"""
1237 scroll_to_item(widget
, item
)
1238 widget
.setCurrentItem(item
)
1239 item
.setSelected(True)
1242 def get_selected_values(widget
, top_level_idx
, values
):
1243 """Map the selected items under the top-level item to the values list"""
1244 # Get the top-level item
1245 item
= widget
.topLevelItem(top_level_idx
)
1246 return tree_selection(item
, values
)
1249 def get_selected_items(widget
, idx
):
1250 """Return the selected items under the top-level item"""
1251 item
= widget
.topLevelItem(idx
)
1252 return tree_selection_items(item
)
1255 def add_menu_actions(menu
, menu_actions
):
1256 """Add actions to a menu, treating None as a separator"""
1257 current_actions
= menu
.actions()
1259 first_action
= current_actions
[0]
1264 for action
in menu_actions
:
1266 action
= menu_separator(menu
)
1267 menu
.insertAction(first_action
, action
)