qtutils: add text_width() and text_size() helper functions
[git-cola.git] / cola / qtutils.py
bloba1d669d0c596b67f2443ac0415d44f1ee36f3a26
1 """Miscellaneous Qt utility functions."""
2 import os
4 from qtpy import compat
5 from qtpy import QtGui
6 from qtpy import QtCore
7 from qtpy import QtWidgets
8 from qtpy.QtCore import Qt
9 from qtpy.QtCore import Signal
11 from . import core
12 from . import hotkeys
13 from . import icons
14 from . import utils
15 from .i18n import N_
16 from .compat import int_types
17 from .compat import ustr
18 from .models import prefs
19 from .widgets import defs
22 STRETCH = object()
23 SKIPPED = object()
26 def active_window():
27 """Return the active window for the current application"""
28 return QtWidgets.QApplication.activeWindow()
31 def current_palette():
32 """Return the QPalette for the current application"""
33 return QtWidgets.QApplication.instance().palette()
36 def connect_action(action, func):
37 """Connect an action to a function"""
38 action.triggered[bool].connect(lambda x: func(), type=Qt.QueuedConnection)
41 def connect_action_bool(action, func):
42 """Connect a triggered(bool) action to a function"""
43 action.triggered[bool].connect(func, type=Qt.QueuedConnection)
46 def connect_button(button, func):
47 """Connect a button to a function"""
48 # Some versions of Qt send the `bool` argument to the clicked callback,
49 # and some do not. The lambda consumes all callback-provided arguments.
50 button.clicked.connect(lambda *args, **kwargs: func(), type=Qt.QueuedConnection)
53 def connect_checkbox(widget, func):
54 """Connect a checkbox to a function taking bool"""
55 widget.clicked.connect(
56 lambda *args, **kwargs: func(get(checkbox)), type=Qt.QueuedConnection
60 def connect_released(button, func):
61 """Connect a button to a function"""
62 button.released.connect(func, type=Qt.QueuedConnection)
65 def button_action(button, action):
66 """Make a button trigger an action"""
67 connect_button(button, action.trigger)
70 def connect_toggle(toggle, func):
71 """Connect a toggle button to a function"""
72 toggle.toggled.connect(func, type=Qt.QueuedConnection)
75 def disconnect(signal):
76 """Disconnect signal from all slots"""
77 try:
78 signal.disconnect()
79 except TypeError: # allow unconnected slots
80 pass
83 def get(widget, default=None):
84 """Query a widget for its python value"""
85 if hasattr(widget, 'isChecked'):
86 value = widget.isChecked()
87 elif hasattr(widget, 'value'):
88 value = widget.value()
89 elif hasattr(widget, 'text'):
90 value = widget.text()
91 elif hasattr(widget, 'toPlainText'):
92 value = widget.toPlainText()
93 elif hasattr(widget, 'sizes'):
94 value = widget.sizes()
95 elif hasattr(widget, 'date'):
96 value = widget.date().toString(Qt.ISODate)
97 else:
98 value = default
99 return value
102 def hbox(margin, spacing, *items):
103 """Create an HBoxLayout with the specified sizes and items"""
104 return box(QtWidgets.QHBoxLayout, margin, spacing, *items)
107 def vbox(margin, spacing, *items):
108 """Create a VBoxLayout with the specified sizes and items"""
109 return box(QtWidgets.QVBoxLayout, margin, spacing, *items)
112 def buttongroup(*items):
113 """Create a QButtonGroup for the specified items"""
114 group = QtWidgets.QButtonGroup()
115 for i in items:
116 group.addButton(i)
117 return group
120 def set_margin(layout, margin):
121 """Set the content margins for a layout"""
122 layout.setContentsMargins(margin, margin, margin, margin)
125 def box(cls, margin, spacing, *items):
126 """Create a QBoxLayout with the specified sizes and items"""
127 stretch = STRETCH
128 skipped = SKIPPED
129 layout = cls()
130 layout.setSpacing(spacing)
131 set_margin(layout, margin)
133 for i in items:
134 if isinstance(i, QtWidgets.QWidget):
135 layout.addWidget(i)
136 elif isinstance(
139 QtWidgets.QHBoxLayout,
140 QtWidgets.QVBoxLayout,
141 QtWidgets.QFormLayout,
142 QtWidgets.QLayout,
145 layout.addLayout(i)
146 elif i is stretch:
147 layout.addStretch()
148 elif i is skipped:
149 continue
150 elif isinstance(i, int_types):
151 layout.addSpacing(i)
153 return layout
156 def form(margin, spacing, *widgets):
157 """Create a QFormLayout with the specified sizes and items"""
158 layout = QtWidgets.QFormLayout()
159 layout.setSpacing(spacing)
160 layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.ExpandingFieldsGrow)
161 set_margin(layout, margin)
163 for idx, (name, widget) in enumerate(widgets):
164 if isinstance(name, (str, ustr)):
165 layout.addRow(name, widget)
166 else:
167 layout.setWidget(idx, QtWidgets.QFormLayout.LabelRole, name)
168 layout.setWidget(idx, QtWidgets.QFormLayout.FieldRole, widget)
170 return layout
173 def grid(margin, spacing, *widgets):
174 """Create a QGridLayout with the specified sizes and items"""
175 layout = QtWidgets.QGridLayout()
176 layout.setSpacing(spacing)
177 set_margin(layout, margin)
179 for row in widgets:
180 item = row[0]
181 if isinstance(item, QtWidgets.QWidget):
182 layout.addWidget(*row)
183 elif isinstance(item, QtWidgets.QLayoutItem):
184 layout.addItem(*row)
186 return layout
189 def splitter(orientation, *widgets):
190 """Create a spliter over the specified widgets
192 :param orientation: Qt.Horizontal or Qt.Vertical
195 layout = QtWidgets.QSplitter()
196 layout.setOrientation(orientation)
197 layout.setHandleWidth(defs.handle_width)
198 layout.setChildrenCollapsible(True)
200 for idx, widget in enumerate(widgets):
201 layout.addWidget(widget)
202 layout.setStretchFactor(idx, 1)
204 # Workaround for Qt not setting the WA_Hover property for QSplitter
205 # Cf. https://bugreports.qt.io/browse/QTBUG-13768
206 layout.handle(1).setAttribute(Qt.WA_Hover)
208 return layout
211 def label(text=None, align=None, fmt=None, selectable=True):
212 """Create a QLabel with the specified properties"""
213 widget = QtWidgets.QLabel()
214 if align is not None:
215 widget.setAlignment(align)
216 if fmt is not None:
217 widget.setTextFormat(fmt)
218 if selectable:
219 widget.setTextInteractionFlags(Qt.TextBrowserInteraction)
220 widget.setOpenExternalLinks(True)
221 if text:
222 widget.setText(text)
223 return widget
226 class ComboBox(QtWidgets.QComboBox):
227 """Custom read-only combobox with a convenient API"""
229 def __init__(self, items=None, editable=False, parent=None, transform=None):
230 super().__init__(parent)
231 self.setEditable(editable)
232 self.transform = transform
233 self.item_data = []
234 if items:
235 self.addItems(items)
236 self.item_data.extend(items)
238 def set_index(self, idx):
239 idx = utils.clamp(idx, 0, self.count() - 1)
240 self.setCurrentIndex(idx)
242 def add_item(self, text, data):
243 self.addItem(text)
244 self.item_data.append(data)
246 def current_data(self):
247 return self.item_data[self.currentIndex()]
249 def set_value(self, value):
250 if self.transform:
251 value = self.transform(value)
252 try:
253 index = self.item_data.index(value)
254 except ValueError:
255 index = 0
256 self.setCurrentIndex(index)
259 def combo(items, editable=False, tooltip='', parent=None):
260 """Create a readonly (by default) combobox from a list of items"""
261 combobox = ComboBox(editable=editable, items=items, parent=parent)
262 if tooltip:
263 combobox.setToolTip(tooltip)
264 return combobox
267 def combo_mapped(data, editable=False, transform=None, parent=None):
268 """Create a readonly (by default) combobox from a list of items"""
269 widget = ComboBox(editable=editable, transform=transform, parent=parent)
270 for k, v in data:
271 widget.add_item(k, v)
272 return widget
275 def textbrowser(text=None):
276 """Create a QTextBrowser for the specified text"""
277 widget = QtWidgets.QTextBrowser()
278 widget.setOpenExternalLinks(True)
279 if text:
280 widget.setText(text)
281 return widget
284 def link(url, text, palette=None):
285 if palette is None:
286 palette = QtGui.QPalette()
288 color = palette.color(QtGui.QPalette.WindowText)
289 rgb_color = f'rgb({color.red()}, {color.green()}, {color.blue()})'
290 scope = {'rgb': rgb_color, 'text': text, 'url': url}
292 return (
294 <a style="font-style: italic; text-decoration: none; color: %(rgb)s;"
295 href="%(url)s">
296 %(text)s
297 </a>
299 % scope
303 def add_completer(widget, items):
304 """Add simple completion to a widget"""
305 completer = QtWidgets.QCompleter(items, widget)
306 completer.setCaseSensitivity(Qt.CaseInsensitive)
307 completer.setCompletionMode(QtWidgets.QCompleter.InlineCompletion)
308 widget.setCompleter(completer)
311 def prompt(msg, title=None, text='', parent=None):
312 """Presents the user with an input widget and returns the input."""
313 if title is None:
314 title = msg
315 if parent is None:
316 parent = active_window()
317 result = QtWidgets.QInputDialog.getText(
318 parent, title, msg, QtWidgets.QLineEdit.Normal, text
320 return (result[0], result[1])
323 def prompt_n(msg, inputs):
324 """Presents the user with N input widgets and returns the results"""
325 dialog = QtWidgets.QDialog(active_window())
326 dialog.setWindowModality(Qt.WindowModal)
327 dialog.setWindowTitle(msg)
329 long_value = msg
330 for k, v in inputs:
331 if len(k + v) > len(long_value):
332 long_value = k + v
334 min_width = min(720, text_width(dialog.font(), long_value) + 100)
335 dialog.setMinimumWidth(min_width)
337 ok_b = ok_button(msg, enabled=False)
338 close_b = close_button()
340 form_widgets = []
342 def get_values():
343 return [pair[1].text().strip() for pair in form_widgets]
345 for name, value in inputs:
346 lineedit = QtWidgets.QLineEdit()
347 # Enable the OK button only when all fields have been populated
348 # pylint: disable=no-member
349 lineedit.textChanged.connect(
350 lambda x: ok_b.setEnabled(all(get_values())), type=Qt.QueuedConnection
352 if value:
353 lineedit.setText(value)
354 form_widgets.append((name, lineedit))
356 # layouts
357 form_layout = form(defs.no_margin, defs.button_spacing, *form_widgets)
358 button_layout = hbox(defs.no_margin, defs.button_spacing, STRETCH, close_b, ok_b)
359 main_layout = vbox(defs.margin, defs.button_spacing, form_layout, button_layout)
360 dialog.setLayout(main_layout)
362 # connections
363 connect_button(ok_b, dialog.accept)
364 connect_button(close_b, dialog.reject)
366 accepted = dialog.exec_() == QtWidgets.QDialog.Accepted
367 text = get_values()
368 success = accepted and all(text)
369 return (success, text)
372 def standard_item_type_value(value):
373 """Return a custom UserType for use in QTreeWidgetItem.type() overrides"""
374 return custom_item_type_value(QtGui.QStandardItem, value)
377 def graphics_item_type_value(value):
378 """Return a custom UserType for use in QGraphicsItem.type() overrides"""
379 return custom_item_type_value(QtWidgets.QGraphicsItem, value)
382 def custom_item_type_value(cls, value):
383 """Return a custom cls.UserType for use in cls.type() overrides"""
384 user_type = enum_value(cls.UserType)
385 return user_type + value
388 def enum_value(value):
389 """Qt6 has enums with an inner '.value' attribute."""
390 if hasattr(value, 'value'):
391 value = value.value
392 return value
395 class TreeWidgetItem(QtWidgets.QTreeWidgetItem):
396 TYPE = standard_item_type_value(101)
398 def __init__(self, path, icon, deleted):
399 QtWidgets.QTreeWidgetItem.__init__(self)
400 self.path = path
401 self.deleted = deleted
402 self.setIcon(0, icons.from_name(icon))
403 self.setText(0, path)
405 def type(self):
406 return self.TYPE
409 def paths_from_indexes(model, indexes, item_type=TreeWidgetItem.TYPE, item_filter=None):
410 """Return paths from a list of QStandardItemModel indexes"""
411 items = [model.itemFromIndex(i) for i in indexes]
412 return paths_from_items(items, item_type=item_type, item_filter=item_filter)
415 def _true_filter(_value):
416 return True
419 def paths_from_items(items, item_type=TreeWidgetItem.TYPE, item_filter=None):
420 """Return a list of paths from a list of items"""
421 if item_filter is None:
422 item_filter = _true_filter
423 return [i.path for i in items if i.type() == item_type and item_filter(i)]
426 def tree_selection(tree_item, items):
427 """Returns an array of model items that correspond to the selected
428 QTreeWidgetItem children"""
429 selected = []
430 count = min(tree_item.childCount(), len(items))
431 for idx in range(count):
432 if tree_item.child(idx).isSelected():
433 selected.append(items[idx])
435 return selected
438 def tree_selection_items(tree_item):
439 """Returns selected widget items"""
440 selected = []
441 for idx in range(tree_item.childCount()):
442 child = tree_item.child(idx)
443 if child.isSelected():
444 selected.append(child)
446 return selected
449 def selected_item(list_widget, items):
450 """Returns the model item that corresponds to the selected QListWidget
451 row."""
452 widget_items = list_widget.selectedItems()
453 if not widget_items:
454 return None
455 widget_item = widget_items[0]
456 row = list_widget.row(widget_item)
457 if row < len(items):
458 item = items[row]
459 else:
460 item = None
461 return item
464 def selected_items(list_widget, items):
465 """Returns an array of model items that correspond to the selected
466 QListWidget rows."""
467 item_count = len(items)
468 selected = []
469 for widget_item in list_widget.selectedItems():
470 row = list_widget.row(widget_item)
471 if row < item_count:
472 selected.append(items[row])
473 return selected
476 def open_file(title, directory=None):
477 """Creates an Open File dialog and returns a filename."""
478 result = compat.getopenfilename(
479 parent=active_window(), caption=title, basedir=directory
481 return result[0]
484 def open_files(title, directory=None, filters=''):
485 """Creates an Open File dialog and returns a list of filenames."""
486 result = compat.getopenfilenames(
487 parent=active_window(), caption=title, basedir=directory, filters=filters
489 return result[0]
492 def _enum_value(value):
493 """Resolve Qt6 enum values"""
494 if hasattr(value, 'value'):
495 return value.value
496 return value
499 def opendir_dialog(caption, path):
500 """Prompts for a directory path"""
501 options = QtWidgets.QFileDialog.Option(
502 _enum_value(QtWidgets.QFileDialog.Directory)
503 | _enum_value(QtWidgets.QFileDialog.DontResolveSymlinks)
504 | _enum_value(QtWidgets.QFileDialog.ReadOnly)
505 | _enum_value(QtWidgets.QFileDialog.ShowDirsOnly)
507 return compat.getexistingdirectory(
508 parent=active_window(), caption=caption, basedir=path, options=options
512 def save_as(filename, title='Save As...'):
513 """Creates a Save File dialog and returns a filename."""
514 result = compat.getsavefilename(
515 parent=active_window(), caption=title, basedir=filename
517 return result[0]
520 def existing_file(directory, title='Append...'):
521 """Creates a Save File dialog and returns a filename."""
522 result = compat.getopenfilename(
523 parent=active_window(), caption=title, basedir=directory
525 return result[0]
528 def copy_path(filename, absolute=True):
529 """Copy a filename to the clipboard"""
530 if filename is None:
531 return
532 if absolute:
533 filename = core.abspath(filename)
534 set_clipboard(filename)
537 def set_clipboard(text):
538 """Sets the copy/paste buffer to text."""
539 if not text:
540 return
541 clipboard = QtWidgets.QApplication.clipboard()
542 clipboard.setText(text, QtGui.QClipboard.Clipboard)
543 if not utils.is_darwin() and not utils.is_win32():
544 clipboard.setText(text, QtGui.QClipboard.Selection)
545 persist_clipboard()
548 # pylint: disable=line-too-long
549 def persist_clipboard():
550 """Persist the clipboard
552 X11 stores only a reference to the clipboard data.
553 Send a clipboard event to force a copy of the clipboard to occur.
554 This ensures that the clipboard is present after git-cola exits.
555 Otherwise, the reference is destroyed on exit.
557 C.f. https://stackoverflow.com/questions/2007103/how-can-i-disable-clear-of-clipboard-on-exit-of-pyqt4-application
559 """ # noqa
560 clipboard = QtWidgets.QApplication.clipboard()
561 event = QtCore.QEvent(QtCore.QEvent.Clipboard)
562 QtWidgets.QApplication.sendEvent(clipboard, event)
565 def add_action_bool(widget, text, func, checked, *shortcuts):
566 tip = text
567 action = _add_action(widget, text, tip, func, connect_action_bool, *shortcuts)
568 action.setCheckable(True)
569 action.setChecked(checked)
570 return action
573 def add_action(widget, text, func, *shortcuts):
574 """Create a QAction and bind it to the `func` callback and hotkeys"""
575 tip = text
576 return _add_action(widget, text, tip, func, connect_action, *shortcuts)
579 def add_action_with_icon(widget, icon, text, func, *shortcuts):
580 """Create a QAction using a custom icon bound to the `func` callback and hotkeys"""
581 tip = text
582 action = _add_action(widget, text, tip, func, connect_action, *shortcuts)
583 action.setIcon(icon)
584 return action
587 def add_action_with_tooltip(widget, text, tip, func, *shortcuts):
588 """Create an action with a tooltip"""
589 return _add_action(widget, text, tip, func, connect_action, *shortcuts)
592 def menu_separator(widget, text=''):
593 """Return a QAction whose isSeparator() returns true. Used in context menus"""
594 action = QtWidgets.QAction(text, widget)
595 action.setSeparator(True)
596 return action
599 def _add_action(widget, text, tip, func, connect, *shortcuts):
600 action = QtWidgets.QAction(text, widget)
601 if hasattr(action, 'setIconVisibleInMenu'):
602 action.setIconVisibleInMenu(True)
603 if tip:
604 action.setStatusTip(tip)
605 connect(action, func)
606 if shortcuts:
607 action.setShortcuts(shortcuts)
608 if hasattr(Qt, 'WidgetWithChildrenShortcut'):
609 action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
610 widget.addAction(action)
611 return action
614 def set_selected_item(widget, idx):
615 """Sets a the currently selected item to the item at index idx."""
616 if isinstance(widget, QtWidgets.QTreeWidget):
617 item = widget.topLevelItem(idx)
618 if item:
619 item.setSelected(True)
620 widget.setCurrentItem(item)
623 def add_items(widget, items):
624 """Adds items to a widget."""
625 for item in items:
626 if item is None:
627 continue
628 widget.addItem(item)
631 def set_items(widget, items):
632 """Clear the existing widget contents and set the new items."""
633 widget.clear()
634 add_items(widget, items)
637 def create_treeitem(filename, staged=False, deleted=False, untracked=False):
638 """Given a filename, return a TreeWidgetItem for a status widget
640 "staged", "deleted, and "untracked" control which icon is used.
643 icon_name = icons.status(filename, deleted, staged, untracked)
644 icon = icons.name_from_basename(icon_name)
645 return TreeWidgetItem(filename, icon, deleted=deleted)
648 def add_close_action(widget):
649 """Adds close action and shortcuts to a widget."""
650 return add_action(widget, N_('Close...'), widget.close, hotkeys.CLOSE, hotkeys.QUIT)
653 def app():
654 """Return the current application"""
655 return QtWidgets.QApplication.instance()
658 def desktop_size():
659 rect = app().primaryScreen().geometry()
660 return (rect.width(), rect.height())
663 def center_on_screen(widget):
664 """Move widget to the center of the default screen"""
665 width, height = desktop_size()
666 center_x = width // 2
667 center_y = height // 2
668 widget.move(center_x - widget.width() // 2, center_y - widget.height() // 2)
671 def default_size(parent, width, height, use_parent_height=True):
672 """Return the parent's size, or the provided defaults"""
673 if parent is not None:
674 width = parent.width()
675 if use_parent_height:
676 height = parent.height()
677 return (width, height)
680 def default_monospace_font():
681 if utils.is_darwin():
682 family = 'Monaco'
683 elif utils.is_win32():
684 family = 'Courier'
685 else:
686 family = 'Monospace'
687 mfont = QtGui.QFont()
688 mfont.setFamily(family)
689 return mfont
692 def diff_font_str(context):
693 cfg = context.cfg
694 font_str = cfg.get(prefs.FONTDIFF)
695 if not font_str:
696 font_str = default_monospace_font().toString()
697 return font_str
700 def diff_font(context):
701 return font_from_string(diff_font_str(context))
704 def font_from_string(string):
705 qfont = QtGui.QFont()
706 qfont.fromString(string)
707 return qfont
710 def create_button(
711 text='', layout=None, tooltip=None, icon=None, enabled=True, default=False
713 """Create a button, set its title, and add it to the parent."""
714 button = QtWidgets.QPushButton()
715 button.setCursor(Qt.PointingHandCursor)
716 button.setFocusPolicy(Qt.NoFocus)
717 if text:
718 button.setText(' ' + text)
719 if icon is not None:
720 button.setIcon(icon)
721 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
722 if tooltip is not None:
723 button.setToolTip(tooltip)
724 if layout is not None:
725 layout.addWidget(button)
726 if not enabled:
727 button.setEnabled(False)
728 if default:
729 button.setDefault(True)
730 return button
733 def tool_button():
734 """Create a flat border-less button"""
735 button = QtWidgets.QToolButton()
736 button.setPopupMode(QtWidgets.QToolButton.InstantPopup)
737 button.setCursor(Qt.PointingHandCursor)
738 button.setFocusPolicy(Qt.NoFocus)
739 # Highlight colors
740 palette = QtGui.QPalette()
741 highlight = palette.color(QtGui.QPalette.Highlight)
742 highlight_rgb = rgb_css(highlight)
744 button.setStyleSheet(
746 /* No borders */
747 QToolButton {
748 border: none;
749 background-color: none;
751 /* Hide the menu indicator */
752 QToolButton::menu-indicator {
753 image: none;
755 QToolButton:hover {
756 border: %(border)spx solid %(highlight_rgb)s;
760 'border': defs.border,
761 'highlight_rgb': highlight_rgb,
764 return button
767 def create_action_button(tooltip=None, icon=None, visible=None):
768 """Create a small toolbutton for use in dock title widgets"""
769 button = tool_button()
770 if tooltip is not None:
771 button.setToolTip(tooltip)
772 if icon is not None:
773 button.setIcon(icon)
774 button.setIconSize(QtCore.QSize(defs.small_icon, defs.small_icon))
775 if visible is not None:
776 button.setVisible(visible)
777 return button
780 def ok_button(text, default=True, enabled=True, icon=None):
781 if icon is None:
782 icon = icons.ok()
783 return create_button(text=text, icon=icon, default=default, enabled=enabled)
786 def close_button(text=None, icon=None):
787 text = text or N_('Close')
788 icon = icons.mkicon(icon, icons.close)
789 return create_button(text=text, icon=icon)
792 def edit_button(enabled=True, default=False):
793 return create_button(
794 text=N_('Edit'), icon=icons.edit(), enabled=enabled, default=default
798 def refresh_button(enabled=True, default=False):
799 return create_button(
800 text=N_('Refresh'), icon=icons.sync(), enabled=enabled, default=default
804 def checkbox(text='', tooltip='', checked=None):
805 """Create a checkbox"""
806 return _checkbox(QtWidgets.QCheckBox, text, tooltip, checked)
809 def radio(text='', tooltip='', checked=None):
810 """Create a radio button"""
811 return _checkbox(QtWidgets.QRadioButton, text, tooltip, checked)
814 def _checkbox(cls, text, tooltip, checked):
815 """Create a widget and apply properties"""
816 widget = cls()
817 if text:
818 widget.setText(text)
819 if tooltip:
820 widget.setToolTip(tooltip)
821 if checked is not None:
822 widget.setChecked(checked)
823 return widget
826 class DockTitleBarWidget(QtWidgets.QFrame):
827 def __init__(self, parent, title, stretch=True):
828 QtWidgets.QFrame.__init__(self, parent)
829 self.setAutoFillBackground(True)
830 self.label = qlabel = QtWidgets.QLabel(title, self)
831 qfont = qlabel.font()
832 qfont.setBold(True)
833 qlabel.setFont(qfont)
834 qlabel.setCursor(Qt.OpenHandCursor)
836 self.close_button = create_action_button(
837 tooltip=N_('Close'), icon=icons.close()
840 self.toggle_button = create_action_button(
841 tooltip=N_('Detach'), icon=icons.external()
844 self.corner_layout = hbox(defs.no_margin, defs.spacing)
845 self.title_layout = hbox(defs.no_margin, defs.button_spacing, qlabel)
847 if stretch:
848 separator = STRETCH
849 else:
850 separator = SKIPPED
852 self.main_layout = hbox(
853 defs.small_margin,
854 defs.titlebar_spacing,
855 self.title_layout,
856 separator,
857 self.corner_layout,
858 self.toggle_button,
859 self.close_button,
861 self.setLayout(self.main_layout)
863 connect_button(self.toggle_button, self.toggle_floating)
864 connect_button(self.close_button, self.toggle_visibility)
866 def toggle_floating(self):
867 self.parent().setFloating(not self.parent().isFloating())
868 self.update_tooltips()
870 def toggle_visibility(self):
871 self.parent().toggleViewAction().trigger()
873 def set_title(self, title):
874 self.label.setText(title)
876 def add_title_widget(self, widget):
877 """Add widgets to the title area"""
878 self.title_layout.addWidget(widget)
880 def add_corner_widget(self, widget):
881 """Add widgets to the corner area"""
882 self.corner_layout.addWidget(widget)
884 def update_tooltips(self):
885 if self.parent().isFloating():
886 tooltip = N_('Attach')
887 else:
888 tooltip = N_('Detach')
889 self.toggle_button.setToolTip(tooltip)
892 def create_dock(name, title, parent, stretch=True, widget=None, func=None):
893 """Create a dock widget and set it up accordingly."""
894 dock = QtWidgets.QDockWidget(parent)
895 dock.setWindowTitle(title)
896 dock.setObjectName(name)
897 titlebar = DockTitleBarWidget(dock, title, stretch=stretch)
898 dock.setTitleBarWidget(titlebar)
899 dock.setAutoFillBackground(True)
900 if hasattr(parent, 'dockwidgets'):
901 parent.dockwidgets.append(dock)
902 if func:
903 widget = func(dock)
904 if widget:
905 dock.setWidget(widget)
906 return dock
909 def hide_dock(widget):
910 widget.toggleViewAction().setChecked(False)
911 widget.hide()
914 def create_menu(title, parent):
915 """Create a menu and set its title."""
916 qmenu = DebouncingMenu(title, parent)
917 return qmenu
920 class DebouncingMenu(QtWidgets.QMenu):
921 """Menu that debounces mouse release action ie. stops it if occurred
922 right after menu creation.
924 Disables annoying behaviour when RMB is pressed to show menu, cursor is
925 moved accidentally 1px onto newly created menu and released causing to
926 execute menu action
929 threshold_ms = 400
931 def __init__(self, title, parent):
932 QtWidgets.QMenu.__init__(self, title, parent)
933 self.created_at = utils.epoch_millis()
934 if hasattr(self, 'setToolTipsVisible'):
935 self.setToolTipsVisible(True)
937 def mouseReleaseEvent(self, event):
938 threshold = DebouncingMenu.threshold_ms
939 if (utils.epoch_millis() - self.created_at) > threshold:
940 QtWidgets.QMenu.mouseReleaseEvent(self, event)
943 def add_menu(title, parent):
944 """Create a menu and set its title."""
945 menu = create_menu(title, parent)
946 if hasattr(parent, 'addMenu'):
947 parent.addMenu(menu)
948 else:
949 parent.addAction(menu.menuAction())
950 return menu
953 def create_toolbutton(text=None, layout=None, tooltip=None, icon=None):
954 button = tool_button()
955 if icon is not None:
956 button.setIcon(icon)
957 button.setIconSize(QtCore.QSize(defs.default_icon, defs.default_icon))
958 if text is not None:
959 button.setText(' ' + text)
960 button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
961 if tooltip is not None:
962 button.setToolTip(tooltip)
963 if layout is not None:
964 layout.addWidget(button)
965 return button
968 def create_toolbutton_with_callback(callback, text, icon, tooltip, layout=None):
969 """Create a toolbutton that runs the specified callback"""
970 toolbutton = create_toolbutton(text=text, layout=layout, tooltip=tooltip, icon=icon)
971 connect_button(toolbutton, callback)
972 return toolbutton
975 # pylint: disable=line-too-long
976 def mimedata_from_paths(context, paths, include_urls=True):
977 """Return mimedata with a list of absolute path URLs
979 Set `include_urls` to False to prevent URLs from being included
980 in the mimedata. This is useful in some terminals that do not gracefully handle
981 multiple URLs being included in the payload.
983 This allows the mimedata to contain just plain a plain text value that we
984 are able to format ourselves.
986 Older verisons of gnome-terminal expected a utf-16 encoding, but that
987 behavior is no longer needed.
988 """ # noqa
989 abspaths = [core.abspath(path) for path in paths]
990 paths_text = core.list2cmdline(abspaths)
992 # The text/x-moz-list format is always included by Qt, and doing
993 # mimedata.removeFormat('text/x-moz-url') has no effect.
994 # http://www.qtcentre.org/threads/44643-Dragging-text-uri-list-Qt-inserts-garbage
996 # Older versions of gnome-terminal expect utf-16 encoded text, but other terminals,
997 # e.g. terminator, expect utf-8, so use cola.dragencoding to override the default.
998 # NOTE: text/x-moz-url does not seem to be used/needed by modern versions of
999 # gnome-terminal, kitty, and terminator.
1000 mimedata = QtCore.QMimeData()
1001 mimedata.setText(paths_text)
1002 if include_urls:
1003 urls = [QtCore.QUrl.fromLocalFile(path) for path in abspaths]
1004 encoding = context.cfg.get('cola.dragencoding', 'utf-16')
1005 encoded_text = core.encode(paths_text, encoding=encoding)
1006 mimedata.setUrls(urls)
1007 mimedata.setData('text/x-moz-url', encoded_text)
1009 return mimedata
1012 def path_mimetypes(include_urls=True):
1013 """Return a list of mimetypes that we generate"""
1014 mime_types = [
1015 'text/plain',
1016 'text/plain;charset=utf-8',
1018 if include_urls:
1019 mime_types.append('text/uri-list')
1020 mime_types.append('text/x-moz-url')
1021 return mime_types
1024 class BlockSignals:
1025 """Context manager for blocking a signals on a widget"""
1027 def __init__(self, *widgets):
1028 self.widgets = widgets
1029 self.values = []
1031 def __enter__(self):
1032 """Block Qt signals for all of the captured widgets"""
1033 self.values = [widget.blockSignals(True) for widget in self.widgets]
1034 return self
1036 def __exit__(self, exc_type, exc_val, exc_tb):
1037 """Restore Qt signals when we exit the scope"""
1038 for widget, value in zip(self.widgets, self.values):
1039 widget.blockSignals(value)
1042 class Channel(QtCore.QObject):
1043 finished = Signal(object)
1044 result = Signal(object)
1047 class Task(QtCore.QRunnable):
1048 """Run a task in the background and return the result using a Channel"""
1050 def __init__(self):
1051 QtCore.QRunnable.__init__(self)
1053 self.channel = Channel()
1054 self.result = None
1055 # Python's garbage collector will try to double-free the task
1056 # once it's finished, so disable Qt's auto-deletion as a workaround.
1057 self.setAutoDelete(False)
1059 def run(self):
1060 self.result = self.task()
1061 self.channel.result.emit(self.result)
1062 self.channel.finished.emit(self)
1064 def task(self):
1065 """Perform a long-running task"""
1066 return ()
1068 def connect(self, handler):
1069 self.channel.result.connect(handler, type=Qt.QueuedConnection)
1072 class SimpleTask(Task):
1073 """Run a simple callable as a task"""
1075 def __init__(self, func, *args, **kwargs):
1076 Task.__init__(self)
1078 self.func = func
1079 self.args = args
1080 self.kwargs = kwargs
1082 def task(self):
1083 return self.func(*self.args, **self.kwargs)
1086 class RunTask(QtCore.QObject):
1087 """Runs QRunnable instances and transfers control when they finish"""
1089 def __init__(self, parent=None):
1090 QtCore.QObject.__init__(self, parent)
1091 self.tasks = []
1092 self.task_details = {}
1093 self.threadpool = QtCore.QThreadPool.globalInstance()
1094 self.result_func = None
1096 def start(self, task, progress=None, finish=None, result=None):
1097 """Start the task and register a callback"""
1098 self.result_func = result
1099 if progress is not None:
1100 if hasattr(progress, 'start'):
1101 progress.start()
1103 # prevents garbage collection bugs in certain PyQt4 versions
1104 self.tasks.append(task)
1105 task_id = id(task)
1106 self.task_details[task_id] = (progress, finish, result)
1107 task.channel.finished.connect(self.finish, type=Qt.QueuedConnection)
1108 self.threadpool.start(task)
1110 def finish(self, task):
1111 """The task has finished. Run the finish and result callbacks"""
1112 task_id = id(task)
1113 try:
1114 self.tasks.remove(task)
1115 except ValueError:
1116 pass
1117 try:
1118 progress, finish, result = self.task_details[task_id]
1119 del self.task_details[task_id]
1120 except KeyError:
1121 finish = progress = result = None
1123 if progress is not None:
1124 if hasattr(progress, 'stop'):
1125 progress.stop()
1126 progress.hide()
1128 if result is not None:
1129 result(task.result)
1131 if finish is not None:
1132 finish(task)
1135 # Syntax highlighting
1138 def rgb(red, green, blue):
1139 """Create a QColor from r, g, b arguments"""
1140 color = QtGui.QColor()
1141 color.setRgb(red, green, blue)
1142 return color
1145 def rgba(red, green, blue, alpha=255):
1146 """Create a QColor with alpha from r, g, b, a arguments"""
1147 color = rgb(red, green, blue)
1148 color.setAlpha(alpha)
1149 return color
1152 def rgb_triple(args):
1153 """Create a QColor from an argument with an [r, g, b] triple"""
1154 return rgb(*args)
1157 def rgb_css(color):
1158 """Convert a QColor into an rgb #abcdef CSS string"""
1159 return '#%s' % rgb_hex(color)
1162 def rgb_hex(color):
1163 """Convert a QColor into a hex aabbcc string"""
1164 return f'{color.red():02x}{color.green():02x}{color.blue():02x}'
1167 def clamp_color(value):
1168 """Clamp an integer value between 0 and 255"""
1169 return min(255, max(value, 0))
1172 def css_color(value):
1173 """Convert a #abcdef hex string into a QColor"""
1174 if value.startswith('#'):
1175 value = value[1:]
1176 try:
1177 red = clamp_color(int(value[:2], base=16)) # ab
1178 except ValueError:
1179 red = 255
1180 try:
1181 green = clamp_color(int(value[2:4], base=16)) # cd
1182 except ValueError:
1183 green = 255
1184 try:
1185 blue = clamp_color(int(value[4:6], base=16)) # ef
1186 except ValueError:
1187 blue = 255
1188 return rgb(red, green, blue)
1191 def hsl(hue, saturation, lightness):
1192 """Return a QColor from an hue, saturation and lightness"""
1193 return QtGui.QColor.fromHslF(
1194 utils.clamp(hue, 0.0, 1.0),
1195 utils.clamp(saturation, 0.0, 1.0),
1196 utils.clamp(lightness, 0.0, 1.0),
1200 def hsl_css(hue, saturation, lightness):
1201 """Convert HSL values to a CSS #abcdef color string"""
1202 return rgb_css(hsl(hue, saturation, lightness))
1205 def make_format(foreground=None, background=None, bold=False):
1206 """Create a QTextFormat from the provided foreground, background and bold values"""
1207 fmt = QtGui.QTextCharFormat()
1208 if foreground:
1209 fmt.setForeground(foreground)
1210 if background:
1211 fmt.setBackground(background)
1212 if bold:
1213 fmt.setFontWeight(QtGui.QFont.Bold)
1214 return fmt
1217 class ImageFormats:
1218 def __init__(self):
1219 # returns a list of QByteArray objects
1220 formats_qba = QtGui.QImageReader.supportedImageFormats()
1221 # portability: python3 data() returns bytes, python2 returns str
1222 decode = core.decode
1223 formats = [decode(x.data()) for x in formats_qba]
1224 self.extensions = {'.' + fmt for fmt in formats}
1226 def ok(self, filename):
1227 _, ext = os.path.splitext(filename)
1228 return ext.lower() in self.extensions
1231 def set_scrollbar_values(widget, hscroll_value, vscroll_value):
1232 """Set scrollbars to the specified values"""
1233 hscroll = widget.horizontalScrollBar()
1234 if hscroll and hscroll_value is not None:
1235 hscroll.setValue(hscroll_value)
1237 vscroll = widget.verticalScrollBar()
1238 if vscroll and vscroll_value is not None:
1239 vscroll.setValue(vscroll_value)
1242 def get_scrollbar_values(widget):
1243 """Return the current (hscroll, vscroll) scrollbar values for a widget"""
1244 hscroll = widget.horizontalScrollBar()
1245 if hscroll:
1246 hscroll_value = get(hscroll)
1247 else:
1248 hscroll_value = None
1249 vscroll = widget.verticalScrollBar()
1250 if vscroll:
1251 vscroll_value = get(vscroll)
1252 else:
1253 vscroll_value = None
1254 return (hscroll_value, vscroll_value)
1257 def scroll_to_item(widget, item):
1258 """Scroll to an item while retaining the horizontal scroll position"""
1259 hscroll = None
1260 hscrollbar = widget.horizontalScrollBar()
1261 if hscrollbar:
1262 hscroll = get(hscrollbar)
1263 widget.scrollToItem(item)
1264 if hscroll is not None:
1265 hscrollbar.setValue(hscroll)
1268 def select_item(widget, item):
1269 """Scroll to and make a QTreeWidget item selected and current"""
1270 scroll_to_item(widget, item)
1271 widget.setCurrentItem(item)
1272 item.setSelected(True)
1275 def get_selected_values(widget, top_level_idx, values):
1276 """Map the selected items under the top-level item to the values list"""
1277 # Get the top-level item
1278 item = widget.topLevelItem(top_level_idx)
1279 return tree_selection(item, values)
1282 def get_selected_items(widget, idx):
1283 """Return the selected items under the top-level item"""
1284 item = widget.topLevelItem(idx)
1285 return tree_selection_items(item)
1288 def add_menu_actions(menu, menu_actions):
1289 """Add actions to a menu, treating None as a separator"""
1290 current_actions = menu.actions()
1291 if current_actions:
1292 first_action = current_actions[0]
1293 else:
1294 first_action = None
1295 menu.addSeparator()
1297 for action in menu_actions:
1298 if action is None:
1299 action = menu_separator(menu)
1300 menu.insertAction(first_action, action)
1303 def fontmetrics_width(metrics, text):
1304 """Get the width in pixels of specified text
1306 Calls QFontMetrics.horizontalAdvance() when available.
1307 QFontMetricswidth() is deprecated. Qt 5.11 added horizontalAdvance().
1309 if hasattr(metrics, 'horizontalAdvance'):
1310 return metrics.horizontalAdvance(text)
1311 return metrics.width(text)
1314 def text_width(font, text):
1315 """Get the width in pixels for the QFont and text"""
1316 metrics = QtGui.QFontMetrics(font)
1317 return fontmetrics_width(metrics, text)
1320 def text_size(font, text):
1321 """Return the width in pixels for the specified text
1323 :param font_or_widget: The QFont or widget providing the font to use.
1324 :param text: The text to measure.
1326 metrics = QtGui.QFontMetrics(font)
1327 return (fontmetrics_width(metrics, text), metrics.height())