class HeaderWithButton(QHeaderView): """Class that reimplements the QHeaderView section paint event to draw a button that is used to display and change the type of that column or row. """ def __init__(self, orientation, parent=None): super(HeaderWithButton, self).__init__(orientation, parent) self.setHighlightSections(True) self.setSectionsClickable(True) self.setDefaultAlignment(Qt.AlignLeft) self.sectionResized.connect(self._section_resize) self.sectionMoved.connect(self._section_move) self._font = QFont('Font Awesome 5 Free Solid') self._display_all = True self._display_sections = [] self._margin = Margin(left=0, right=0, top=0, bottom=0) self._menu = self._create_menu() self._button = QToolButton(parent=self) self._button.setMenu(self._menu) self._button.setPopupMode(QToolButton.InstantPopup) self._button.setFont(self._font) self._button.hide() self._render_button = QToolButton(parent=self) self._render_button.setFont(self._font) self._render_button.hide() self._button_logical_index = None self.setMinimumSectionSize(self.minimumSectionSize() + self.widget_width()) @property def display_all(self): return self._display_all @display_all.setter def display_all(self, display_all): self._display_all = display_all self.viewport().update() @property def sections_with_buttons(self): return self._display_sections @sections_with_buttons.setter def sections_with_buttons(self, sections): self._display_sections = set(sections) self.viewport().update() def _create_menu(self): menu = QMenu(self) for at in _ALLOWED_TYPES: action = QAction(parent=menu) action.setText(at) menu.addAction(action) menu.triggered.connect(self._menu_pressed) return menu def _menu_pressed(self, action): type_str = action.text() if type_str == "integer sequence datetime": dialog = NewIntegerSequenceDateTimeConvertSpecDialog() if dialog.exec_(): convert_spec = dialog.get_spec() else: return else: convert_spec = value_to_convert_spec(type_str) logical_index = self._button_logical_index self.model().set_type(logical_index, convert_spec, self.orientation()) def widget_width(self): """Width of widget Returns: [int] -- Width of widget """ if self.orientation() == Qt.Horizontal: return self.height() return self.sectionSize(0) def widget_height(self): """Height of widget Returns: [int] -- Height of widget """ if self.orientation() == Qt.Horizontal: return self.height() return self.sectionSize(0) def mouseMoveEvent(self, mouse_event): """Moves the button to the correct section so that interacting with the button works. """ log_index = self.logicalIndexAt(mouse_event.x(), mouse_event.y()) if not self._display_all and log_index not in self._display_sections: self._button_logical_index = None self._button.hide() super().mouseMoveEvent(mouse_event) return if self._button_logical_index != log_index: self._button_logical_index = log_index self._set_button_geometry(self._button, log_index) self._button.show() super().mouseMoveEvent(mouse_event) def mousePressEvent(self, mouse_event): """Move the button to the pressed location and show or hide it if button should not be shown. """ log_index = self.logicalIndexAt(mouse_event.x(), mouse_event.y()) if not self._display_all and log_index not in self._display_sections: self._button_logical_index = None self._button.hide() super().mousePressEvent(mouse_event) return if self._button_logical_index != log_index: self._button_logical_index = log_index self._set_button_geometry(self._button, log_index) self._button.show() super().mousePressEvent(mouse_event) def leaveEvent(self, event): """Hide button """ self._button_logical_index = None self._button.hide() super().leaveEvent(event) def _set_button_geometry(self, button, index): """Sets a buttons geometry depending on the index. Arguments: button {QWidget} -- QWidget that geometry should be set index {int} -- logical_index to set position and geometry to. """ margin = self._margin if self.orientation() == Qt.Horizontal: button.setGeometry( self.sectionViewportPosition(index) + margin.left, margin.top, self.widget_width() - self._margin.left - self._margin.right, self.widget_height() - margin.top - margin.bottom, ) else: button.setGeometry( margin.left, self.sectionViewportPosition(index) + margin.top, self.widget_width() - self._margin.left - self._margin.right, self.widget_height() - margin.top - margin.bottom, ) def _section_resize(self, i): """When a section is resized. Arguments: i {int} -- logical index to section being resized """ self._button.hide() if i == self._button_logical_index: self._set_button_geometry(self._button, self._button_logical_index) def paintSection(self, painter, rect, logical_index): """Paints a section of the QHeader view. Works by drawing a pixmap of the button to the left of the orignial paint rectangle. Then shifts the original rect to the right so these two doesn't paint over eachother. """ if not self._display_all and logical_index not in self._display_sections: super().paintSection(painter, rect, logical_index) return # get the type of the section. type_spec = self.model().get_type(logical_index, self.orientation()) if type_spec is None: type_spec = "string" else: type_spec = type_spec.DISPLAY_NAME font_str = _TYPE_TO_FONT_AWESOME_ICON[type_spec] # set data for both interaction button and render button. self._button.setText(font_str) self._render_button.setText(font_str) self._set_button_geometry(self._render_button, logical_index) # get pixmap from render button and draw into header section. rw = self._render_button.grab() if self.orientation() == Qt.Horizontal: painter.drawPixmap(self.sectionViewportPosition(logical_index), 0, rw) else: painter.drawPixmap(0, self.sectionViewportPosition(logical_index), rw) # shift rect that super class should paint in to the right so it doesn't # paint over the button rect.adjust(self.widget_width(), 0, 0, 0) super().paintSection(painter, rect, logical_index) def sectionSizeFromContents(self, logical_index): """Add the button width to the section so it displays right. Arguments: logical_index {int} -- logical index of section Returns: [QSize] -- Size of section """ org_size = super().sectionSizeFromContents(logical_index) org_size.setWidth(org_size.width() + self.widget_width()) return org_size def _section_move(self, logical, old_visual_index, new_visual_index): """Section beeing moved. Arguments: logical {int} -- logical index of section beeing moved. old_visual_index {int} -- old visual index of section new_visual_index {int} -- new visual index of section """ self._button.hide() if self._button_logical_index is not None: self._set_button_geometry(self._button, self._button_logical_index) def fix_widget_positions(self): """Update position of interaction button """ if self._button_logical_index is not None: self._set_button_geometry(self._button, self._button_logical_index) def set_margins(self, margins): self._margin = margins
class TabBarPlus(QTabBar): """Tab bar that has a plus button floating to the right of the tabs.""" plus_clicked = Signal() def __init__(self, parent): """ Args: parent (MultiSpineDBEditor) """ super().__init__(parent) self._parent = parent self._plus_button = QToolButton(self) self._plus_button.setIcon(QIcon(CharIconEngine("\uf067"))) self._plus_button.clicked.connect( lambda _=False: self.plus_clicked.emit()) self._move_plus_button() self.setShape(QTabBar.RoundedNorth) self.setTabsClosable(True) self.setMovable(True) self.setElideMode(Qt.ElideLeft) self.drag_index = None self._tab_hot_spot_x = None self._hot_spot_y = None def resizeEvent(self, event): """Sets the dimension of the plus button. Also, makes the tab bar as wide as the parent.""" super().resizeEvent(event) self.setFixedWidth(self.parent().width()) self.setMinimumHeight(self.height()) self._move_plus_button() extent = max(0, self.height() - 2) self._plus_button.setFixedSize(extent, extent) self.setExpanding(False) def tabLayoutChange(self): super().tabLayoutChange() self._move_plus_button() def _move_plus_button(self): """Places the plus button at the right of the last tab.""" left = sum([self.tabRect(i).width() for i in range(self.count())]) top = self.geometry().top() + 1 self._plus_button.move(left, top) def mousePressEvent(self, event): """Registers the position of the press, in case we need to detach the tab.""" super().mousePressEvent(event) tab_rect = self.tabRect(self.tabAt(event.pos())) self._tab_hot_spot_x = event.pos().x() - tab_rect.x() self._hot_spot_y = event.pos().y() - tab_rect.y() def mouseMoveEvent(self, event): """Detaches a tab either if the user moves beyond the limits of the tab bar, or if it's the only one.""" self._plus_button.hide() if self.count() == 1: self._send_release_event(event.pos()) hot_spot = QPoint(event.pos().x(), self._hot_spot_y) self._parent.start_drag(hot_spot) return if self.count() > 1 and not self.geometry().contains(event.pos()): self._send_release_event(event.pos()) hot_spot_x = event.pos().x() hot_spot = QPoint(event.pos().x(), self._hot_spot_y) index = self.tabAt(hot_spot) if index == -1: index = self.count() - 1 self._parent.detach(index, hot_spot, hot_spot_x - self._tab_hot_spot_x) return super().mouseMoveEvent(event) def _send_release_event(self, pos): """Sends a mouse release event at given position in local coordinates. Called just before detaching a tab. Args: pos (QPoint) """ self.drag_index = None release_event = QMouseEvent(QEvent.MouseButtonRelease, pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) QApplication.sendEvent(self, release_event) def mouseReleaseEvent(self, event): super().mouseReleaseEvent(event) self._plus_button.show() self.update() self.releaseMouse() if self.drag_index is not None: # Pass it to parent event.ignore() def start_dragging(self, index): """Stars dragging the given index. This happens when a detached tab is reattached to this bar. Args: index (int) """ self.drag_index = index press_pos = self.tabRect(self.drag_index).center() press_event = QMouseEvent(QEvent.MouseButtonPress, press_pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) QApplication.sendEvent(self, press_event) QApplication.processEvents() move_pos = self.mapFromGlobal(QCursor.pos()) if self.geometry().contains(move_pos): move_event = QMouseEvent(QEvent.MouseMove, move_pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) QApplication.sendEvent(self, move_event) self.grabMouse() def index_under_mouse(self): """Returns the index under the mouse cursor, or None if the cursor isn't over the tab bar. Used to check for drop targets. Returns: int or NoneType """ pos = self.mapFromGlobal(QCursor.pos()) if not self.geometry().contains(pos): return None index = self.tabAt(pos) if index == -1: index = self.count() return index def _show_plus_button_context_menu(self, global_pos): toolbox = self._parent.db_mngr.parent() if toolbox is None: return ds_urls = { ds.name: ds.project_item.sql_alchemy_url() for ds in toolbox.project_item_model.items("Data Stores") } if not ds_urls: return menu = QMenu(self) for name, url in ds_urls.items(): action = menu.addAction(name, lambda name=name, url=url: self._parent. add_new_tab({url: name})) action.setEnabled(bool(url)) menu.popup(global_pos) menu.aboutToHide.connect(menu.deleteLater) def contextMenuEvent(self, event): index = self.tabAt(event.pos()) if self._plus_button.underMouse(): self._show_plus_button_context_menu(event.globalPos()) return if self.tabButton(index, QTabBar.RightSide).underMouse(): return db_editor = self._parent.tab_widget.widget(index) if db_editor is None: return menu = QMenu(self) others = self._parent.others() if others: move_tab_menu = menu.addMenu("Move tab to another window") move_tab_to_new_window = move_tab_menu.addAction( "New window", lambda _=False, index=index: self._parent.move_tab( index, None)) for other in others: move_tab_menu.addAction( other.name(), lambda _=False, index=index, other=other: self._parent. move_tab(index, other)) else: move_tab_to_new_window = menu.addAction( "Move tab to new window", lambda _=False, index=index: self._parent.move_tab( index, None)) move_tab_to_new_window.setEnabled(self.count() > 1) menu.addSeparator() menu.addAction(db_editor.url_toolbar.reload_action) db_url_codenames = db_editor.db_url_codenames menu.addAction( QIcon(CharIconEngine("\uf24d")), "Duplicate", lambda _=False, index=index + 1, db_url_codenames=db_url_codenames: self._parent.insert_new_tab(index, db_url_codenames), ) menu.popup(event.globalPos()) menu.aboutToHide.connect(menu.deleteLater) event.accept()