class Prompt(QLabel): """Blocking prompt widget asking the user a question. The prompt is initialized with a question and displays the question, its title and the valid keybindings. Calling ``run`` blocks the UI until a valid keybinding to answer/abort the question was given. Class Attributes: BINDINGS: Valid keybindings to answer/abort the question. Attributes: question: Question object defining title, question and answer. loop: Event loop used to block the UI. """ STYLESHEET = """ QLabel { font: {prompt.font}; color: {prompt.fg}; background-color: {prompt.bg}; padding: {prompt.padding}; border-top-right-radius: {prompt.border_radius}; border-top: {prompt.border} {prompt.border.color}; border-right: {prompt.border} {prompt.border.color}; } """ BINDINGS = ( ("y", "Yes"), ("n", "No"), ("<return>", "No"), ("<escape>", "Abort"), ) def __init__(self, question: api.prompt.Question, *, parent): super().__init__(parent=parent) self.question = question self.loop = QEventLoop() styles.apply(self) header = f"<h3>{question.title}</h3>{question.body}" self.setText(header + self.bindings_table()) _logger.debug("Initialized %s", self) self.setFocus() self.adjustSize() self.raise_() self.show() def __str__(self): return f"prompt for '{self.question.title}'" @classmethod def bindings_table(cls): """Return a formatted html table with the valid keybindings.""" return utils.format_html_table( (f"<b>{utils.escape_html(binding)}</b>", command) for binding, command in cls.BINDINGS ) def run(self): """Run the blocking event loop.""" _logger.debug("Running blocking %s", self) self.loop.exec_() def update_geometry(self, _width: int, bottom: int): y = bottom - self.height() self.setGeometry(0, y, self.width(), self.height()) def leave(self, *, answer=None): """Leave the prompt by answering the question and quitting the loop.""" _logger.debug("Leaving %s with '%s'", self, answer) self.question.answer = answer self.loop.quit() self.loop.deleteLater() self.deleteLater() api.modes.current().widget.setFocus() def keyPressEvent(self, event): """Leave the prompt on a valid key binding.""" if event.key() == Qt.Key_Y: self.leave(answer=True) elif event.key() in (Qt.Key_N, Qt.Key_Return): self.leave(answer=False) elif event.key() == Qt.Key_Escape: self.leave() def focusOutEvent(self, event): """Leave the prompt without answering when unfocused.""" if self.loop.isRunning(): self.leave() super().focusOutEvent(event)
class QuickMenu(FramelessWindow): """ A quick menu popup for the widgets. The widgets are set using :func:`QuickMenu.setModel` which must be a model as returned by :func:`QtWidgetRegistry.model` """ #: An action has been triggered in the menu. triggered = Signal(QAction) #: An action has been hovered in the menu hovered = Signal(QAction) def __init__(self, parent=None, **kwargs): FramelessWindow.__init__(self, parent, **kwargs) self.setWindowFlags(Qt.Popup) self.__filterFunc = None self.__setupUi() self.__loop = None self.__model = None self.setModel(QStandardItemModel()) self.__triggeredAction = None def __setupUi(self): self.setLayout(QVBoxLayout(self)) self.layout().setContentsMargins(6, 6, 6, 6) self.__search = SearchWidget(self, objectName="search-line") self.__search.setPlaceholderText( self.tr("Search for widget or select from the list.") ) self.layout().addWidget(self.__search) self.__frame = QFrame(self, objectName="menu-frame") layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) self.__frame.setLayout(layout) self.layout().addWidget(self.__frame) self.__pages = PagedMenu(self, objectName="paged-menu") self.__pages.currentChanged.connect(self.setCurrentIndex) self.__pages.triggered.connect(self.triggered) self.__pages.hovered.connect(self.hovered) self.__frame.layout().addWidget(self.__pages) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) self.__suggestPage = SuggestMenuPage(self, objectName="suggest-page") self.__suggestPage.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE) self.__suggestPage.setIcon(icon_loader().get("icons/Search.svg")) if sys.platform == "darwin": view = self.__suggestPage.view() view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True) # Don't show the focus frame because it expands into the tab bar. view.setAttribute(Qt.WA_MacShowFocusRect, False) i = self.addPage(self.tr("Quick Search"), self.__suggestPage) button = self.__pages.tabButton(i) button.setObjectName("search-tab-button") button.setStyleSheet( "TabButton {\n" " qproperty-flat_: false;\n" " border: none;" "}\n") self.__search.textEdited.connect(self.__on_textEdited) self.__navigator = ItemViewKeyNavigator(self) self.__navigator.setView(self.__suggestPage.view()) self.__search.installEventFilter(self.__navigator) self.__grip = WindowSizeGrip(self) self.__grip.raise_() def setSizeGripEnabled(self, enabled): """ Enable the resizing of the menu with a size grip in a bottom right corner (enabled by default). """ if bool(enabled) != bool(self.__grip): if self.__grip: self.__grip.deleteLater() self.__grip = None else: self.__grip = WindowSizeGrip(self) self.__grip.raise_() def sizeGripEnabled(self): """ Is the size grip enabled. """ return bool(self.__grip) def addPage(self, name, page): """ Add the `page` (:class:`MenuPage`) with `name` and return it's index. The `page.icon()` will be used as the icon in the tab bar. """ return self.insertPage(self.__pages.count(), name, page) def insertPage(self, index, name, page): icon = page.icon() tip = name if page.toolTip(): tip = page.toolTip() index = self.__pages.insertPage(index, page, name, icon, tip) # Route the page's signals page.triggered.connect(self.__onTriggered) page.hovered.connect(self.hovered) # Install event filter to intercept key presses. page.view().installEventFilter(self) return index def createPage(self, index): """ Create a new page based on the contents of an index (:class:`QModeIndex`) item. """ page = MenuPage(self) page.setModel(index.model()) page.setRootIndex(index) view = page.view() if sys.platform == "darwin": view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True) # Don't show the focus frame because it expands into the tab # bar at the top. view.setAttribute(Qt.WA_MacShowFocusRect, False) name = six.text_type(index.data(Qt.DisplayRole)) page.setTitle(name) icon = qtcompat.qunwrap(index.data(Qt.DecorationRole)) if isinstance(icon, QIcon): page.setIcon(icon) page.setToolTip(qtcompat.qunwrap(index.data(Qt.ToolTipRole))) return page def __clear(self): for i in range(self.__pages.count() - 1, 0, -1): self.__pages.removePage(i) def setModel(self, model): """ Set the model containing the actions. """ if self.__model is not None: self.__model.dataChanged.disconnect(self.__on_dataChanged) self.__model.rowsInserted.disconnect(self.__on_rowsInserted) self.__model.rowsRemoved.disconnect(self.__on_rowsRemoved) self.__clear() for i in range(model.rowCount()): index = model.index(i, 0) self.__insertPage(i + 1, index) self.__model = model self.__suggestPage.setModel(model) if model is not None: self.__model.dataChanged.connect(self.__on_dataChanged) self.__model.rowsInserted.connect(self.__on_rowsInserted) self.__model.rowsRemoved.connect(self.__on_rowsRemoved) def __on_dataChanged(self, topLeft, bottomRight): parent = topLeft.parent() # Only handle top level item (categories). if not parent.isValid(): for row in range(topLeft.row(), bottomRight.row() + 1): index = topLeft.sibling(row, 0, parent) # Note: the tab buttons are offest by 1 (to accommodate # the Suggest Page). button = self.__pages.tabButton(row + 1) brush = index.data(QtWidgetRegistry.BACKGROUND_ROLE) brush = qvariant_to_qbrush(brush) if brush is not None: base_color = brush.color() button.setStyleSheet( TAB_BUTTON_STYLE_TEMPLATE % (create_css_gradient(base_color), create_css_gradient(base_color.darker(120))) ) def __on_rowsInserted(self, parent, start, end): # Only handle top level item (categories). if not parent.isValid(): for row in range(start, end + 1): index = self.__model.index(row, 0) self.__insertPage(row + 1, index) def __on_rowsRemoved(self, parent, start, end): # Only handle top level item (categories). if not parent.isValid(): for row in range(end, start - 1, -1): self.__removePage(row + 1) def __insertPage(self, row, index): page = self.createPage(index) page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE) i = self.insertPage(row, page.title(), page) brush = index.data(QtWidgetRegistry.BACKGROUND_ROLE) brush = qvariant_to_qbrush(brush) if brush is not None: base_color = brush.color() button = self.__pages.tabButton(i) button.setStyleSheet( TAB_BUTTON_STYLE_TEMPLATE % (create_css_gradient(base_color), create_css_gradient(base_color.darker(120))) ) def __removePage(self, row): page = self.__pages.page(row) page.triggered.disconnect(self.__onTriggered) page.hovered.disconnect(self.hovered) page.view().removeEventFilter(self) self.__pages.removePage(row) def setFilterFunc(self, func): """ Set a filter function. """ if func != self.__filterFunc: self.__filterFunc = func for i in range(0, self.__pages.count()): self.__pages.page(i).setFilterFunc(func) def popup(self, pos=None, searchText=""): """ Popup the menu at `pos` (in screen coordinates). 'Search' text field is initialized with `searchText` if provided. """ if pos is None: pos = QPoint() self.__clearCurrentItems() self.__search.setText(searchText) self.__suggestPage.setFilterFixedString(searchText) self.ensurePolished() if self.testAttribute(Qt.WA_Resized) and self.sizeGripEnabled(): size = self.size() else: size = self.sizeHint() desktop = QApplication.desktop() screen_geom = desktop.availableGeometry(pos) # Adjust the size to fit inside the screen. if size.height() > screen_geom.height(): size.setHeight(screen_geom.height()) if size.width() > screen_geom.width(): size.setWidth(screen_geom.width()) geom = QRect(pos, size) if geom.top() < screen_geom.top(): geom.setTop(screen_geom.top()) if geom.left() < screen_geom.left(): geom.setLeft(screen_geom.left()) bottom_margin = screen_geom.bottom() - geom.bottom() right_margin = screen_geom.right() - geom.right() if bottom_margin < 0: # Falls over the bottom of the screen, move it up. geom.translate(0, bottom_margin) # TODO: right to left locale if right_margin < 0: # Falls over the right screen edge, move the menu to the # other side of pos. geom.translate(-size.width(), 0) self.setGeometry(geom) self.show() if searchText: self.setFocusProxy(self.__search) else: self.setFocusProxy(None) def exec_(self, pos=None, searchText=""): """ Execute the menu at position `pos` (in global screen coordinates). Return the triggered :class:`QAction` or `None` if no action was triggered. 'Search' text field is initialized with `searchText` if provided. """ self.popup(pos, searchText) self.setFocus(Qt.PopupFocusReason) self.__triggeredAction = None self.__loop = QEventLoop() self.__loop.exec_() self.__loop.deleteLater() self.__loop = None action = self.__triggeredAction self.__triggeredAction = None return action def hideEvent(self, event): """ Reimplemented from :class:`QWidget` """ FramelessWindow.hideEvent(self, event) if self.__loop: self.__loop.exit() def setCurrentPage(self, page): """ Set the current shown page to `page`. """ self.__pages.setCurrentPage(page) def setCurrentIndex(self, index): """ Set the current page index. """ self.__pages.setCurrentIndex(index) def __clearCurrentItems(self): """ Clear any selected (or current) items in all the menus. """ for i in range(self.__pages.count()): self.__pages.page(i).view().selectionModel().clear() def __onTriggered(self, action): """ Re-emit the action from the page. """ self.__triggeredAction = action # Hide and exit the event loop if necessary. self.hide() self.triggered.emit(action) def __on_textEdited(self, text): self.__suggestPage.setFilterFixedString(text) self.__pages.setCurrentPage(self.__suggestPage) def triggerSearch(self): """ Trigger action search. This changes to current page to the 'Suggest' page and sets the keyboard focus to the search line edit. """ self.__pages.setCurrentPage(self.__suggestPage) self.__search.setFocus(Qt.ShortcutFocusReason) # Make sure that the first enabled item is set current. self.__suggestPage.ensureCurrent() def keyPressEvent(self, event): if event.text(): # Ignore modifiers, ... self.__search.setFocus(Qt.ShortcutFocusReason) self.setCurrentIndex(0) self.__search.keyPressEvent(event) FramelessWindow.keyPressEvent(self, event) event.accept() def event(self, event): if event.type() == QEvent.ShortcutOverride: log.debug("Overriding shortcuts") event.accept() return True return FramelessWindow.event(self, event) def eventFilter(self, obj, event): if isinstance(obj, QTreeView): etype = event.type() if etype == QEvent.KeyPress: # ignore modifiers non printable characters, Enter, ... if event.text() and event.key() not in \ [Qt.Key_Enter, Qt.Key_Return]: self.__search.setFocus(Qt.ShortcutFocusReason) self.setCurrentIndex(0) self.__search.keyPressEvent(event) return True return FramelessWindow.eventFilter(self, obj, event)