import os import logging from PySide6.QtCore import QRect, QSize, Qt, QRectF, QPoint, QEvent, Property from PySide6.QtWidgets import QMenu, QStyle, QStyleOptionMenuItem, QStyleOption, QWidget, QApplication from PySide6.QtWidgets import QToolTip from PySide6.QtGui import QIcon, QImage, QPainter, QFont, QPalette, QColor, QKeySequence, QAction, QActionGroup import Kikka from Kikka.KikkaConst import * from Kikka.Utils.Singleton import singleton class KMenu(QMenu): def __init__(self, title='', parent=None): QMenu.__init__(self, title, parent) self._parent = parent self._aRect = {} self._bg_image = None self._fg_image = None self._side_image = None self._hover_color = None self.installEventFilter(self) self.setMouseTracking(True) self.setSeparatorsCollapsible(False) def getHoverColor(self): return self._hover_color def setHoverColor(self, color): if isinstance(color, QColor): self._hover_color = color hoverColor = Property(QColor, fget=getHoverColor, fset=setHoverColor) def addMenuItem(self, text, callbackfunc=None, iconfilepath=None, group=None): if iconfilepath is None: act = QAction(text, self._parent) elif os.path.exists(iconfilepath): act = QAction(QIcon(iconfilepath), text, self._parent) else: logging.info("fail to add menu item") return if callbackfunc is not None: act.triggered.connect(callbackfunc) if group is None: self.addAction(act) else: self.addAction(group.addAction(act)) return act def addSubMenu(self, menu): act = self.addMenu(menu) return act def getSubMenu(self, menuName): for i in range(len(self.actions())): act = self.actions()[i] if act.text() == menuName: return act.menu() return None def getAction(self, actionName): for i in range(len(self.actions())): act = self.actions()[i] if act.text() == actionName: return act return None def getActionByData(self, data): for i in range(len(self.actions())): act = self.actions()[i] if act.data() == data: return act return None def checkAction(self, actionName, isChecked): for i in range(len(self.actions())): act = self.actions()[i] if act.text() != actionName: continue act.setChecked(isChecked) pass def setPosition(self, pos): rect = QApplication.instance().primaryScreen().geometry() w = rect.width() h = rect.height() if pos.y() + self.height() > h: pos.setY(h - self.height()) if pos.y() < 0: pos.setY(0) if pos.x() + self.width() > w: pos.setX(w - self.width()) if pos.x() < 0: pos.setX(0) self.move(pos) def updateActionRect(self): """ void QMenuPrivate::updateActionRects(const QRect &screen) const https://cep.xray.aps.anl.gov/software/qt4-x11-4.8.6-browser/da/d61/class_q_menu_private.html#acf93cda3ebe88b1234dc519c5f1b0f5d """ self._aRect = {} topmargin = 0 leftmargin = 0 rightmargin = 0 # qmenu.cpp Line 259: # init max_column_width = 0 dh = self.height() y = 0 style = self.style() opt = QStyleOption() opt.initFrom(self) hmargin = style.pixelMetric(QStyle.PixelMetric.PM_MenuHMargin, opt, self) vmargin = style.pixelMetric(QStyle.PixelMetric.PM_MenuVMargin, opt, self) icone = style.pixelMetric(QStyle.PixelMetric.PM_SmallIconSize, opt, self) fw = style.pixelMetric(QStyle.PixelMetric.PM_MenuPanelWidth, opt, self) deskFw = style.pixelMetric(QStyle.PixelMetric.PM_MenuDesktopFrameWidth, opt, self) tearoffHeight = style.pixelMetric(QStyle.PixelMetric.PM_MenuTearoffHeight, opt, self) if self.isTearOffEnabled() else 0 # for compatibility now - will have to refactor this away tabWidth = 0 maxIconWidth = 0 hasCheckableItems = False # ncols = 1 # sloppyAction = 0 for i in range(len(self.actions())): act = self.actions()[i] if act.isSeparator() or act.isVisible() is False: continue # ..and some members hasCheckableItems |= act.isCheckable() ic = act.icon() if ic.isNull() is False: maxIconWidth = max(maxIconWidth, icone + 4) # qmenu.cpp Line 291: # calculate size qfm = self.fontMetrics() previousWasSeparator = True # this is true to allow removing the leading separators for i in range(len(self.actions())): act = self.actions()[i] if act.isVisible() is False \ or (self.separatorsCollapsible() and previousWasSeparator and act.isSeparator()): # we continue, this action will get an empty QRect self._aRect[i] = QRect() continue previousWasSeparator = act.isSeparator() # let the style modify the above size.. opt = QStyleOptionMenuItem() self.initStyleOption(opt, act) fm = opt.fontMetrics sz = QSize() # sz = self.sizeHint().expandedTo(self.minimumSize()).expandedTo(self.minimumSizeHint()).boundedTo(self.maximumSize()) # calc what I think the size is.. if act.isSeparator(): sz = QSize(2, 2) else: s = act.text() if '\t' in s: t = s.index('\t') act.setText(s[t + 1:]) tabWidth = max(int(tabWidth), qfm.boundingRect(s[t + 1:]).width()) else: seq = act.shortcut() if seq.isEmpty() is False: tabWidth = max(int(tabWidth), qfm.boundingRect(seq.toString()).width()) sz.setWidth(fm.boundingRect(QRect(), Qt.TextFlag.TextSingleLine | Qt.TextFlag.TextShowMnemonic, s).width()) sz.setHeight(fm.height()) if not act.icon().isNull(): is_sz = QSize(icone, icone) if is_sz.height() > sz.height(): sz.setHeight(is_sz.height()) sz = style.sizeFromContents(QStyle.ContentsType.CT_MenuItem, opt, sz, self) if sz.isEmpty() is False: max_column_width = max(max_column_width, sz.width()) # wrapping if y + sz.height() + vmargin > dh - deskFw * 2: # ncols += 1 y = vmargin y += sz.height() # update the item self._aRect[i] = QRect(0, 0, sz.width(), sz.height()) pass # exit for max_column_width += tabWidth # finally add in the tab width sfcMargin = style.sizeFromContents(QStyle.ContentsType.CT_Menu, opt, QSize(0, 0), self).width() min_column_width = self.minimumWidth() - (sfcMargin + leftmargin + rightmargin + 2 * (fw + hmargin)) max_column_width = max(min_column_width, max_column_width) # qmenu.cpp Line 259: # calculate position base_y = vmargin + fw + topmargin + tearoffHeight x = hmargin + fw + leftmargin y = base_y for i in range(len(self.actions())): if self._aRect[i].isNull(): continue if y + self._aRect[i].height() > dh - deskFw * 2: x += max_column_width + hmargin y = base_y self._aRect[i].translate(x, y) # move self._aRect[i].setWidth(max_column_width) # uniform width y += self._aRect[i].height() # update menu size s = self.sizeHint() self.resize(s) def getPenColor(self, opt): # if opt.menuItemType == QStyleOptionMenuItem.MenuItemType.Separator: # color = self.separator_color if opt.state & QStyle.StateFlag.State_Selected and opt.state & QStyle.StateFlag.State_Enabled: if self._hover_color: color = self._hover_color else: color = opt.palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.HighlightedText) elif not (opt.state & QStyle.StateFlag.State_Enabled): color = opt.palette.color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text) else: color = opt.palette.color(QPalette.ColorGroup.Normal, QPalette.ColorRole.Text) return color def drawControl(self, p, opt, arect, icon): """ due to overrides the "paintEvent" method, so we must repaint all menu item by self. luckly, we have qt source code to reference. File: qtbase\src\widgets\styles\qstylesheetstyle.cpp Function: void drawControl (ControlElement element, const QStyleOption *opt, QPainter *p, const QWidget *w=0) const """ # Line 3891: case CE_MenuItem: style = self.style() p.setPen(self.getPenColor(opt)) # Line 3932: draw icon and checked sign checkable = opt.checkType != QStyleOptionMenuItem.CheckType.NotCheckable checked = opt.checked if checkable else False if opt.icon.isNull() is False: # has custom icon dis = not (opt.state & QStyle.StateFlag.State_Enabled) active = opt.state & QStyle.StateFlag.State_Selected mode = QIcon.Mode.Disabled if dis else QIcon.Mode.Normal if active != 0 and not dis: mode = QIcon.Mode.Active fw = style.pixelMetric(QStyle.PixelMetric.PM_MenuPanelWidth, opt, self) icone = style.pixelMetric(QStyle.PixelMetric.PM_SmallIconSize, opt, self) iconRect = QRectF(arect.x() - fw, arect.y(), self._side_image.width(), arect.height()) if checked: pixmap = icon.pixmap(QSize(icone, icone), mode, QIcon.State.On) else: pixmap = icon.pixmap(QSize(icone, icone), mode) pixw = pixmap.width() pixh = pixmap.height() pmr = QRectF(0, 0, pixw, pixh) pmr.moveCenter(iconRect.center()) if checked: p.drawRect(QRectF(pmr.x() - 1, pmr.y() - 1, pixw + 2, pixh + 2)) p.drawPixmap(pmr.topLeft(), pixmap) elif checkable and checked: # draw default checked sign opt.rect = QRect(0, arect.y(), self._side_image.width(), arect.height()) opt.palette.setColor(QPalette.ColorRole.Text, self.getPenColor(opt)) style.drawPrimitive(QStyle.PrimitiveElement.PE_IndicatorMenuCheckMark, opt, p, self) # Line 3952: draw menu text p.setFont(opt.font) text_flag = Qt.AlignmentFlag.AlignVCenter | Qt.TextFlag.TextShowMnemonic | Qt.TextFlag.TextDontClip | Qt.TextFlag.TextSingleLine tr = QRect(arect) s = opt.text if '\t' in s: ss = s[s.index('\t') + 1:] fontwidth = opt.fontMetrics.boundingRect(ss).width() tr.moveLeft(opt.rect.right() - fontwidth) tr = QStyle.visualRect(opt.direction, opt.rect, tr) p.drawText(tr, text_flag, ss) tr.moveLeft(self._side_image.width() + arect.x()) tr = QStyle.visualRect(opt.direction, opt.rect, tr) p.drawText(tr, text_flag, s) # Line 3973: draw sub menu arrow if opt.menuItemType == QStyleOptionMenuItem.MenuItemType.SubMenu: arrowW = style.pixelMetric(QStyle.PixelMetric.PM_IndicatorWidth, opt, self) arrowH = style.pixelMetric(QStyle.PixelMetric.PM_IndicatorHeight, opt, self) arrowRect = QRect(0, 0, arrowW, arrowH) arrowRect.moveBottomRight(arect.bottomRight()) arrow = QStyle.PrimitiveElement.PE_IndicatorArrowLeft if opt.direction == Qt.LayoutDirection.RightToLeft else QStyle.PrimitiveElement.PE_IndicatorArrowRight opt.rect = arrowRect opt.palette.setColor(QPalette.ColorRole.ButtonText, self.getPenColor(opt)) style.drawPrimitive(arrow, opt, p, self) pass def paintEvent(self, event): # init self._bg_image = Kikka.style.getMenuBGImage() self._fg_image = Kikka.style.getMenuFGImage() self._side_image = Kikka.style.getMenuSideImage() self.updateActionRect() p = QPainter(self) # draw background p.fillRect(QRect(QPoint(), self.size()), self._side_image.pixelColor(0, 0)) vertical = False y = self.height() while y > 0: yy = y - self._bg_image.height() p.drawImage(0, yy, self._side_image.mirrored(False, vertical)) x = self._side_image.width() while x < self.width(): p.drawImage(x, yy, self._bg_image.mirrored(False, vertical)) x += self._bg_image.width() p.drawImage(x, yy, self._bg_image.mirrored(True, vertical)) x += self._bg_image.width() + 1 y -= self._bg_image.height() vertical = not vertical # draw item actioncount = len(self.actions()) for i in range(actioncount): act = self.actions()[i] arect = QRect(self._aRect[i]) if event.rect().intersects(arect) is False: continue opt = QStyleOptionMenuItem() self.initStyleOption(opt, act) opt.rect = arect if opt.state & QStyle.StateFlag.State_Selected and opt.state & QStyle.StateFlag.State_Enabled: # Selected Item, draw foreground image p.setClipping(True) p.setClipRect(arect.x() + self._side_image.width(), arect.y(), self.width() - self._side_image.width(), arect.height()) p.fillRect(QRect(QPoint(), self.size()), self._fg_image.pixelColor(0, 0)) vertical = False y = self.height() while y > 0: x = self._side_image.width() while x < self.width(): yy = y - self._fg_image.height() p.drawImage(x, yy, self._fg_image.mirrored(False, vertical)) x += self._fg_image.width() p.drawImage(x, yy, self._fg_image.mirrored(True, vertical)) x += self._fg_image.width() + 1 y -= self._fg_image.height() vertical = not vertical p.setClipping(False) if opt.menuItemType == QStyleOptionMenuItem.MenuItemType.Separator: # Separator # p.setPen(opt.palette.color(QPalette.ColorRole.Text)) y = int(arect.y() + arect.height() / 2) p.drawLine(self._side_image.width(), y, arect.width(), y) else: # MenuItem self.drawControl(p, opt, arect, act.icon()) pass # exit for def eventFilter(self, obj, event): if obj == self: if event.type() == QEvent.Type.WindowDeactivate: self.Hide() elif event.type() == QEvent.Type.ToolTip: act = self.activeAction() if act != 0 and act.toolTip() != act.text(): QToolTip.showText(event.globalPos(), act.toolTip()) else: QToolTip.hideText() return False def _createTestMenu(parent=None): # test callback function def _test_callback(index=0, title=''): logging.info("MainMenu_callback: click [%d] %s" % (index, title)) def _test_Exit(testmenu): # from kikka_app import KikkaApp callbackfunc = lambda: print("Exit!!") testmenu.addMenuItem("Exit", callbackfunc) def _test_MenuItemState(testmenu): menu = KMenu("MenuItem State", testmenu) c = 16 for i in range(c): text = str("%s-item%d" % (menu.title(), i)) callbackfunc = lambda checked, a=i, b=text: _test_callback(a, b) act = menu.addMenuItem(text, callbackfunc) if i >= c / 2: act.setDisabled(True) act.setText("%s-disable" % act.text()) if i % 8 >= c / 4: act.setIcon(icon) act.setText("%s-icon" % act.text()) if i % 4 >= c / 8: act.setCheckable(True) act.setText("%s-ckeckable" % act.text()) if i % 2 >= c / 16: act.setChecked(True) act.setText("%s-checked" % act.text()) testmenu.addSubMenu(menu) def _test_Shortcut(testmenu): menu = KMenu("Shortcut", testmenu) c = 4 for i in range(c): text = str("%s-item" % (str(chr(ord('A') + i)))) callbackfunc = lambda checked, a=i, b=text: _test_callback(a, b) act = menu.addMenuItem(text, callbackfunc) if i == 0: act.setShortcut(QKeySequence("Ctrl+T")) act.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut) act.setShortcutVisibleInContextMenu(True) testmenu.addSubMenu(menu) pass def _test_StatusTip(testmenu): pass def _test_Separator(testmenu): menu = KMenu("Separator", testmenu) menu.addSeparator() c = 5 for i in range(c): text = str("%s-item%d" % (menu.title(), i)) callbackfunc = lambda checked, a=i, b=text: _test_callback(a, b) menu.addMenuItem(text, callbackfunc) for j in range(i + 2): menu.addSeparator() testmenu.addSubMenu(menu) def _test_MultipleItem(testmenu): menu = KMenu("Multiple item", testmenu) for i in range(100): text = str("%s-item%d" % (menu.title(), i)) callbackfunc = lambda checked, a=i, b=text: _test_callback(a, b) menu.addMenuItem(text, callbackfunc) testmenu.addSubMenu(menu) def _test_LongTextItem(testmenu): menu = KMenu("Long text item", testmenu) for i in range(5): text = str("%s-item%d " % (menu.title(), i)) * 20 callbackfunc = lambda checked, a=i, b=text: _test_callback(a, b) menu.addMenuItem(text, callbackfunc) testmenu.addSubMenu(menu) def _test_LargeMenu(testmenu): menu = KMenu("Large menu", testmenu) for i in range(60): text = str("%s-item%d " % (menu.title(), i)) * 10 callbackfunc = lambda checked, a=i, b=text: _test_callback(a, b) menu.addMenuItem(text, callbackfunc) if i % 5 == 0: menu.addSeparator() testmenu.addSubMenu(menu) def _test_LimitTest(testmenu): menu = KMenu("LimitTest", testmenu) _test_LargeMenu(menu) _test_MultipleItem(menu) _test_LongTextItem(menu) testmenu.addSubMenu(menu) def _test_Submenu(testmenu): menu = KMenu("Submenu", testmenu) testmenu.addSubMenu(menu) submenu = KMenu("submenu1", menu) menu.addSubMenu(submenu) m = submenu for i in range(8): next = KMenu("submenu%d" % (i + 2), testmenu) m.addSubMenu(next) m = next submenu = KMenu("submenu2", menu) menu.addSubMenu(submenu) m = submenu for i in range(8): for j in range(10): text = str("%s-item%d" % (m.title(), j)) callbackfunc = lambda checked, a=j, b=text: _test_callback(a, b) m.addMenuItem(text, callbackfunc) next = KMenu("submenu%d" % (i + 2), testmenu) m.addSubMenu(next) m = next submenu = KMenu("SubMenu State", testmenu) c = 16 for i in range(c): text = str("%s-%d" % (submenu.title(), i)) m = KMenu(text, submenu) act = submenu.addSubMenu(m) callbackfunc = lambda checked, a=i, b=text: _test_callback(a, b) act.triggered.connect(callbackfunc) if i >= c / 2: act.setDisabled(True) act.setText("%s-disable" % act.text()) if i % 8 >= c / 4: act.setIcon(icon) act.setText("%s-icon" % act.text()) if i % 4 >= c / 8: act.setCheckable(True) act.setText("%s-ckeckable" % act.text()) if i % 2 >= c / 16: act.setChecked(True) act.setText("%s-checked" % act.text()) submenu.addSubMenu(m) menu.addSubMenu(submenu) def _test_ImageTest(testmenu): imagetestmenu = KMenu("ImageTest", testmenu) testmenu.addSubMenu(imagetestmenu) menu = KMenu("MenuImage-normal", imagetestmenu) for i in range(32): text = " " * 54 menu.addMenuItem(text) imagetestmenu.addSubMenu(menu) menu = KMenu("MenuImage-bit", imagetestmenu) menu.addMenuItem('') imagetestmenu.addSubMenu(menu) menu = KMenu("MenuImage-small", imagetestmenu) for i in range(10): text = " " * 30 menu.addMenuItem(text) imagetestmenu.addSubMenu(menu) menu = KMenu("MenuImage-long", imagetestmenu) for i in range(64): text = " " * 54 menu.addMenuItem(text) imagetestmenu.addSubMenu(menu) menu = KMenu("MenuImage-long2", imagetestmenu) for i in range(32): text = " " * 30 menu.addMenuItem(text) imagetestmenu.addSubMenu(menu) menu = KMenu("MenuImage-large", imagetestmenu) for i in range(64): text = " " * 300 menu.addMenuItem(text) imagetestmenu.addSubMenu(menu) menu = KMenu("MenuImage-verylarge", imagetestmenu) for i in range(100): text = " " * 600 menu.addMenuItem(text) imagetestmenu.addSubMenu(menu) if parent is None: parent = QWidget(f=Qt.WindowType.Dialog) icon = QIcon(r"icon.ico") menu_test = KMenu("TestMenu", parent) _test_Exit(menu_test) menu_test.addSeparator() _test_MenuItemState(menu_test) _test_Shortcut(menu_test) _test_StatusTip(menu_test) _test_Separator(menu_test) _test_LimitTest(menu_test) _test_Submenu(menu_test) menu_test.addSeparator() _test_ImageTest(menu_test) menu_test.addSeparator() _test_Exit(menu_test) return menu_test if __name__ == '__main__': menu = _createTestMenu() menu.setPosition(QPoint(100, 100)) menu.show()