Directly show the menu when its action is added to a QToolBar - qt

I have a menu that I want to add to a QToolBar.
I know that I can add the menuAction() of the menu to the tool bar, but while that will properly show the "menu hint" on its side and popup the menu by clicking on it, clicking the main area of the button will have no effect.
That action is not supposed to have any result when triggered: the menu is used to set the font color in a text editor, and since it automatically updates its icon based on the current color, making it checkable (to set/unset the font color) is ineffective.
What I want is that the menu will be shown, no matter where the user clicks.
I know that I can add the action, then use widgetForAction() to get the actual QToolButton, and then change its popupMode, but since I know that I will have more situations like this, I was looking for a better approach.
This answer suggests to use QPushButton instead, and add that button to the toolbar, but that solution is not ideal: QPushButton is styled slightly differently from the default QToolButton, and, as the documentation suggests, even if I use a QToolButton it will not respect the ToolButtonStyle.
Here is a basic MRE of my current code. Please consider that the ColorMenu class is intended to be extended for other features (background text, colors for table borders and backgrounds, etc) by using subclasses:
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
class ColorMenu(QMenu):
def __init__(self, parent=None):
super().__init__(parent)
self.setTitle('Text color')
self.group = QActionGroup(self)
iconSize = self.style().pixelMetric(QStyle.PM_LargeIconSize)
pm = QPixmap(iconSize, iconSize)
pm.fill(self.palette().text().color())
self.defaultAction = self.addAction(QIcon(pm), 'Default color')
self.defaultAction.setCheckable(True)
self.group.addAction(self.defaultAction)
self.addSeparator()
self.customColorAction = self.addAction('Custom color')
self.customColorAction.setVisible(False)
self.customColorAction.setCheckable(True)
self.group.addAction(self.customColorAction)
self.addSeparator()
self.baseColorActions = []
colors = {}
# get valid global colors
for key, value in Qt.__dict__.items():
if (
isinstance(value, Qt.GlobalColor)
and 1 < value < 19
):
# make names more readable
if key.startswith('light'):
key = 'light {}'.format(key[5:].lower())
elif key.startswith('dark'):
key = 'dark {}'.format(key[4:].lower())
colors[value] = key.capitalize()
# more logical sorting of global colors
for i in (2, 4, 5, 6, 3, 7, 13, 8, 14, 9, 15, 10, 16, 11, 17, 12, 18):
color = QColor(Qt.GlobalColor(i))
pm = QPixmap(iconSize, iconSize)
pm.fill(color)
action = self.addAction(QIcon(pm), colors[i])
action.setData(color)
action.setCheckable(True)
self.group.addAction(action)
self.baseColorActions.append(action)
self.setColor(None)
def setColor(self, color):
if isinstance(color, QBrush) and color.style():
color = color.color()
elif isinstance(color, (Qt.GlobalColor, int):
color = QColor(color)
if instance(color, QColor) and color.isValid():
for action in self.baseColorActions:
if action.data() == color:
self.setIcon(action.icon())
action.setChecked(True)
self.customColorAction.setVisible(False)
break
else:
iconSize = self.style().pixelMetric(QStyle.PM_LargeIconSize)
pm = QPixmap(iconSize, iconSize)
pm.fill(color)
icon = QIcon(pm)
self.setIcon(icon)
self.customColorAction.setIcon(icon)
self.customColorAction.setData(color)
self.customColorAction.setVisible(True)
self.customColorAction.setChecked(True)
return
self.setIcon(self.defaultAction.icon())
self.defaultAction.setChecked(True)
self.customColorAction.setVisible(False)
class Editor(QMainWindow):
def __init__(self):
super().__init__()
self.editor = QTextEdit()
self.setCentralWidget(self.editor)
self.formatMenu = self.menuBar().addMenu('Format')
self.colorMenu = ColorMenu(self)
self.formatMenu.addMenu(self.colorMenu)
self.toolbar = QToolBar('Format')
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
self.toolbar.addAction(self.colorMenu.menuAction())
self.editor.currentCharFormatChanged.connect(self.updateColorMenu)
self.colorMenu.triggered.connect(self.setTextColor)
def setTextColor(self, action):
# assume that the action.data() has a color value, if not, revert to the default
if action.data():
self.editor.setTextColor(action.data())
else:
tc = self.editor.textCursor()
fmt = tc.charFormat()
fmt.clearForeground()
tc.setCharFormat(fmt)
def updateColorMenu(self, fmt):
self.colorMenu.setColor(fmt.foreground())
app = QApplication([])
editor = Editor()
editor.show()
app.exec()

A possibility is to use a subclass of QMenu and implement a specialized function that will return a dedicated action.
This is a bit of a hack/workaround, but may be effective in some situations.
That new action will:
be created by providing the tool bar, which will become its parent (to ensure proper deletion if the tool bar is destroyed);
forcibly show the menu when triggered;
update itself (title and icon) whenever the menu action is changed;
class ColorMenu(QMenu):
# ...
def toolBarAction(self, toolbar):
def triggerMenu():
try:
button = toolbar.widgetForAction(action)
if isinstance(button, QToolButton):
button.showMenu()
else:
print('Warning: action triggered from somewhere else')
except (TypeError, RuntimeError):
# the action has been destroyed
pass
def updateAction():
try:
action.setIcon(self.icon())
action.setText(self.title())
except (TypeError, RuntimeError):
# the action has been destroyed
pass
action = QAction(self.icon(), self.title(), toolbar)
action.triggered.connect(triggerMenu)
self.menuAction().changed.connect(updateAction)
action.setMenu(self)
return action
class Editor(QMainWindow):
def __init__(self):
# ...
# replace the related line with the following
self.toolbar.addAction(self.colorMenu.toolBarAction(self.toolbar))

Related

Should I use QGraphicsView to display an image and some decorated text side by side?

I want to create a "details" view for books I have downloaded.
With the attached image as an example, imagine the red block to the left is the book's cover page, and metadata related to it is displayed to the right.
With the way I have it done right now:
from PySide6 import QtWidgets as qtw
from PySide6 import QtGui as qtg
from PySide6 import QtCore as qtc
class Details:
def __init__(self):
self.location = "/home/user/Desktop/Untitled.png"
self.title = "Some title"
self.subtitle = "Sub title"
self.id = 123124
def to_html(self):
return """
<p>
<b>Author =</b> author<br/>
<b>Published Date =</b> 2000-1-1<br/>
<b>Pages =</b> 500<br/>
</p>
"""
class DetailsWidget(qtw.QWidget):
_title_font = qtg.QFont()
_title_font.setBold(True)
_title_font.setPixelSize(24)
_subtitle_font = qtg.QFont()
_subtitle_font.setBold(True)
_subtitle_font.setPixelSize(19)
_id_font = qtg.QFont()
_id_font.setBold(True)
_id_font.setPixelSize(15)
_redacted_details_font = qtg.QFont()
_redacted_details_font.setPixelSize(12)
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.setFixedSize(1000, 500)
self.setWindowFlag(qtc.Qt.WindowType.Dialog, True)
self.setLayout(qtw.QGridLayout())
self.layout().setContentsMargins(0, 0, 0, 0)
self._details: Details = Details()
self._thumbnail_image = qtg.QImage(self._details.location)
self._thumbnail_image = self._thumbnail_image.scaled(
500,
500,
qtc.Qt.AspectRatioMode.KeepAspectRatio,
qtc.Qt.TransformationMode.SmoothTransformation,
)
self._details_rect = qtc.QRect(
self._get_actual_geometry().left() + self._thumbnail_image.width() + 10,
self._get_actual_geometry().top(),
self._get_actual_geometry().width() - self._thumbnail_image.width() - 20,
self._get_actual_geometry().height(),
)
height = 0
self._title_rects = []
font_metrics_rect = qtg.QFontMetrics(self._title_font).boundingRect(
self._details_rect, qtc.Qt.TextFlag.TextWordWrap, self._details.title, 0
)
drawing_rect = qtc.QRect(self._details_rect)
self._title_rects.append(drawing_rect)
height += font_metrics_rect.height() + 10
drawing_rect = qtc.QRect(self._details_rect)
drawing_rect.moveTop(height)
self._title_rects.append(drawing_rect)
font_metrics_rect = qtg.QFontMetrics(self._title_font).boundingRect(
self._details_rect, qtc.Qt.TextFlag.TextWordWrap, self._details.subtitle, 0
)
drawing_rect = qtc.QRect(self._details_rect)
height += font_metrics_rect.height() - 3
drawing_rect.moveTop(height)
self._title_rects.append(drawing_rect)
font_metrics_rect = qtg.QFontMetrics(self._title_font).boundingRect(
self._details_rect,
qtc.Qt.TextFlag.TextWordWrap,
str(self._details.id),
0,
)
self._title_rects.append(drawing_rect)
height += font_metrics_rect.height() + 10
self._details_rect.moveTop(height)
self._redacted_details_text_document = qtg.QTextDocument()
self._redacted_details_text_document.setHtml(self._details.to_html())
# First set the width,
self._redacted_details_text_document.setTextWidth(self._details_rect.width())
# then get the height of the QTextDocument based on the given width and set
# that + the titles heights + bottom padding as the total height.
if (total_height:=height + self._redacted_details_text_document.size().height() + 10) > self.height():
self.setFixedHeight(total_height)
def _get_actual_geometry(self) -> qtc.QRect:
# Probably not needed for normal desktop environments with window
# managers but I'm an epik i3 user so self.geometry() does not work as
# intended when full screening the window with $mod + F. Or I'm just
# retarded and this is not even a problem.
geometry = self.geometry()
geometry.setTopLeft(qtc.QPoint(0, 0))
return geometry
def paintEvent(self, event: qtg.QPaintEvent) -> None:
total_height = 0
painter = qtg.QPainter(self)
painter.setRenderHint(qtg.QPainter.RenderHint.TextAntialiasing)
painter.drawImage(0, 0, self._thumbnail_image)
painter.save()
painter.setFont(self._title_font)
painter.drawText(
self._title_rects[0], qtc.Qt.TextFlag.TextWordWrap, self._details.title
)
painter.setFont(self._subtitle_font)
painter.drawText(
self._title_rects[1], qtc.Qt.TextFlag.TextWordWrap, self._details.subtitle
)
painter.setFont(self._id_font)
painter.drawText(
self._title_rects[2],
qtc.Qt.TextFlag.TextWordWrap,
str(self._details.id),
)
painter.translate(self._details_rect.topLeft())
painter.setFont(self._redacted_details_font)
self._redacted_details_text_document.drawContents(painter)
painter.restore()
app = qtw.QApplication()
widget = DetailsWidget()
widget.show()
app.exec()
I can display the text and the image next to each other just fine, but the text is not selectable. Looking around for a way to do so, I stumbled upon QGraphicsTextItem. Should I re-do the whole thing in a QGraphicsView instead of using the paintEvent on a QWidget? The reason I'm hesitant to do so is because I don't know of the cons of using a QGraphicsView, maybe it's a lot more resource heavy and not the best for this use case?
You're complicating things unnecessarily.
Just use a basic QHBoxLayout and two QLabels, with the one on the left for the image, and the one on the right for the details.
If you want to allow text selection, use QLabel.setTextInteractionFlags(Qt.TextSelectableByMouse).
An even better solution would be to use a QGraphicsView with a QGraphicsPixmapItem for the image (using fitInView() in the resizeEvent to always show it as large as possible) and a QTextEdit for the details, set in read only mode.
Note that your usage of _get_actual_geometry is wrong in principle (besides the fact that you're calling 4 times in a row, while you could just use a local variable instead), because when a widget has not been shown yet it always has a default size (100x30 for widgets created with a parent, otherwise 640x480), so not only you'll be getting a wrong geometry, but you're also changing it, since setTopLeft() will only move the corner, not translate the rectangle: if you want the basic rectangle of the widget, just use rect(). Obviously, if you properly use layouts as suggested above, this won't be necessary in the first place.

Extending selection in either direction in a QTextEdit

Currently, QTextEdit permits selecting text and then altering that selection with shift-click-drag only on the side of the selection opposite the anchor. The anchor is placed where the selection started. If the user tries to alter the selection near the start, the selection pivots around the anchor point instead of extending. I'd like to permit changing the selection from either side.
My first attempt is to simply set the anchor on the opposite side from where the cursor is located. Say, for example, the selection is from 10 to 20. If the cursor is shift-click-dragged at position 8, then the anchor would be set to 20. If the cursor is shift-click-dragged at position 22, then the anchor would be set to 10. Later, I'll try something more robust, perhaps based on the center point of the selection.
I thought this code would work, but it does not seem to affect the default behavior at all. What have I missed?
import sys
from PySide.QtCore import *
from PySide.QtGui import *
class TextEditor(QTextEdit):
def __init__(self, parent=None):
super().__init__(parent)
self.setReadOnly(True)
self.setMouseTracking(True)
def mouseMoveEvent(self, event):
point = QPoint()
x = event.x() #these are relative to the upper left corner of the text edit window
y = event.y()
point.setX(x)
point.setY(y)
self.mousepos = self.cursorForPosition(point).position() # get character position of current mouse using local window coordinates
if event.buttons()==Qt.LeftButton:
modifiers = QApplication.keyboardModifiers()
if modifiers == Qt.ShiftModifier:
start = -1 #initialize to something impossible
end = -1
cursor = self.textCursor()
select_point1 = cursor.selectionStart()
select_point2 = cursor.selectionEnd()
if select_point1 < select_point2: # determine order of selection points
start = select_point1
end = select_point2
elif select_point2 < select_point1:
start = select_point2
end = select_point1
if self.mousepos > end: # if past end when shift-click then trying to extend right
cursor.setPosition(start, mode=QTextCursor.MoveAnchor)
elif self.mousepos < start: # if before start when shift-click then trying to extend left
cursor.setPosition(end, mode=QTextCursor.MoveAnchor)
if start != -1 and end != -1: #if selection exists then this should trigger
self.setTextCursor(cursor)
super().mouseMoveEvent(event)
Here's a first stab at implementing shift+click extension of the current selection. It seems to work okay, but I have not tested it to death, so there may be one or two glitches. The intended behaviour is that a shift+click above or below the selection should extend the whole selection in that direction; and a shift+click with drag should do the same thing, only continuously.
Note that I have also set the text-interaction flags so that the caret is visible in read-only mode, and the selection can also be manipulated with the keyboard in various ways (e.g. ctrl+shift+right extends the selection to the next word).
import sys
from PySide.QtCore import *
from PySide.QtGui import *
class TextEditor(QTextEdit):
def __init__(self, parent=None):
super().__init__(parent)
self.setReadOnly(True)
self.setTextInteractionFlags(
Qt.TextSelectableByMouse |
Qt.TextSelectableByKeyboard)
def mouseMoveEvent(self, event):
if not self.setShiftSelection(event, True):
super().mouseMoveEvent(event)
def mousePressEvent(self, event):
if not self.setShiftSelection(event):
super().mousePressEvent(event)
def setShiftSelection(self, event, moving=False):
if (event.buttons() == Qt.LeftButton and
QApplication.keyboardModifiers() == Qt.ShiftModifier):
cursor = self.textCursor()
start = cursor.selectionStart()
end = cursor.selectionEnd()
if not moving or start != end:
anchor = cursor.anchor()
pos = self.cursorForPosition(event.pos()).position()
if pos <= start:
start = pos
elif pos >= end:
end = pos
elif anchor == start:
end = pos
else:
start = pos
if pos <= anchor:
start, end = end, start
cursor.setPosition(start, QTextCursor.MoveAnchor)
cursor.setPosition(end, QTextCursor.KeepAnchor)
self.setTextCursor(cursor)
return True
return False
if __name__ == '__main__':
app = QApplication(sys.argv)
window = TextEditor()
window.setText(open(__file__).read())
window.setGeometry(600, 50, 800, 800)
window.show()
sys.exit(app.exec_())

kivy button ID's within functions

I am working on a fantasy football type app for a school project.
We have created a scrollview with a list of characters in a team within it, each assigned to a button. on press of the button a new scrollview displaying a second list of 'inactive character buttons' is displayed, allowing the user to press one to swap the first and second character from team to team.
our issue comes from a difficulty in managing to 'locate' which button is pressed in order to tell our swap function which two characters to swap on the list. Is it possible to retain the id of a button and call it into a new function on press of said button?
Our code is a bit messy, but is displayed bellow:
class SMApp(App):
teamlist = []
idvar = ""
btnlist = []
def popupfunc(self, event):
"""
creates a popup asking if the user wishes to swap a character from team to subs
then proceeds to allow user to choose who swaps
"""
def subscroll(self):
"""
opens scroll list of substitute characters in a popup
"""
sublist = []
curs.execute('SELECT * FROM Subs')
for row in curs:
sublist.append([row[0], row[2]])
layout = GridLayout(cols=2, spacing=10, size_hint_y=None)
layout.bind(minimum_height=layout.setter('height'))
for i in range(len(sublist)):
btn = Button(text=str(sublist[i][0]), size_hint_y=None, height=40)
layout.add_widget(btn)
lbl = Label(text=str(sublist[i][1]), size_hinty=None, height=40)
layout.add_widget(lbl)
root = ScrollView(size_hint=(None, None), size=(400, 400))
root.add_widget(layout)
popup2 = Popup(content=root, size=(7, 10), size_hint=(0.55, 0.8), title="list of subs")
popup2.open()
box = BoxLayout()
btn1 = Button(text='yeah ok')
btn2 = Button(text='nope')
popup1 = Popup(content=box, size=(10, 10), size_hint=(0.3, 0.3), title="add to team?")
btn2.bind(on_press=popup1.dismiss)
btn1.bind(on_press=subscroll)
box.add_widget(btn1)
box.add_widget(btn2)
popup1.open()
def build(self):
curs.execute('SELECT * FROM Team')
for row in curs:
self.teamlist.append([row[0], row[2]])
layout = GridLayout(cols=2, spacing=10, size_hint_y=None)
layout.bind(minimum_height=layout.setter('height'))
for i in range(len(self.teamlist)):
btn = Button(text=str(self.teamlist[i][0]), size_hint_y=None, height=40, id=str(i))
btn.bind(on_press=self.popupfunc)
self.btnlist.append(btn)
layout.add_widget(btn)
lbl = Label(text=str(self.teamlist[i][1]), size_hinty=None, height=40)
layout.add_widget(lbl)
for item in self.btnlist:
print item.id
root = ScrollView(size_hint=(None, None), size=(400, 400),
pos_hint={'center_x':.5, 'center_y':.5})
root.add_widget(layout)
return root
if __name__ == '__main__':
SMApp().run()
Each of the btn = Button(...) you create is a different object, therefore you can tell which is pressed. The thing is what way you'll choose.
You can use:
str(your button) and get a specific object address(?) like 0xAABBCCEE
bad, don't do that
Button(id='something', ...)
ids from kv language
or create own widget with a property for specific identificator. Then you'd use a loop for the parent's children which would check for identificator and do something:
for child in layout.children:
if child.id == 'something':
# do something
And it seems you'd need this loop inside your subscroll, or access that layout some other way.

How to add QInputDialog.getText text inside a QGraphicsPolygonItem?

I'm building in PyQt4 and can't figure out how to add text to a QGraphicsPolygonItem. The idea is to have text set in the middle of a rectangular box after a user double clicks (and gets a dialog box via QInputDialog.getText).
The class is:
class DiagramItem(QtGui.QGraphicsPolygonItem):
def __init__(self, diagramType, contextMenu, parent=None, scene=None):
super(DiagramItem, self).__init__(parent, scene)
path = QtGui.QPainterPath()
rect = self.outlineRect()
path.addRoundRect(rect, self.roundness(rect.width()), self.roundness(rect.height()))
self.myPolygon = path.toFillPolygon()
My double mouse click event looks like this, but updates nothing!
def mouseDoubleClickEvent(self, event):
text, ok = QtGui.QInputDialog.getText(QtGui.QInputDialog(),'Create Region Title','Enter Region Name: ', \
QtGui.QLineEdit.Normal, 'region name')
if ok:
self.myText = str(text)
pic = QtGui.QPicture()
qp = QtGui.QPainter(pic)
qp.setFont(QtGui.QFont('Arial', 40))
qp.drawText(10,10,200,200, QtCore.Qt.AlignCenter, self.myText)
qp.end()
Well, you are not doing it correctly. You are painting to a QPicture (pic) and throwing it away.
I'm assuming you want to paint on the QGraphicsPolygonItem. paint method of QGraphicsItem (and its derivatives) is responsible for painting the item. If you want to paint extra things with the item, you should override that method and do your painting there:
class DiagramItem(QtGui.QGraphicsPolygonItem):
def __init__(self, diagramType, contextMenu, parent=None, scene=None):
super(DiagramItem, self).__init__(parent, scene)
# your `init` stuff
# ...
# just initialize an empty string for self.myText
self.myText = ''
def mouseDoubleClickEvent(self, event):
text, ok = QtGui.QInputDialog.getText(QtGui.QInputDialog(),
'Create Region Title',
'Enter Region Name: ',
QtGui.QLineEdit.Normal,
'region name')
if ok:
# you can leave it as QString
# besides in Python 2, you'll have problems with unicode text if you use str()
self.myText = text
# force an update
self.update()
def paint(self, painter, option, widget):
# paint the PolygonItem's own stuff
super(DiagramItem, self).paint(painter, option, widget)
# now paint your text
painter.setFont(QtGui.QFont('Arial', 40))
painter.drawText(10,10,200,200, QtCore.Qt.AlignCenter, self.myText)

Turning WA_TranslucentBackground off stops window repainting

I have a PyQt4.9 window where I would like to toggle the translucency on or off. The reason being is that it sometimes shows a full size phonon video control which doesn't work when the WA_TranslucentBackground attribute is set. (Due to a Qt bug https://bugreports.qt.io/browse/QTBUG-8119)
The problem I have is, after I turn WA_TranslucentBackground attribute back to false, after it has been true, the Window will no longer redraw, so it remains stuck showing the same thing from that point on. Interestingly, click events still respond.
Some example code follows. Click the increment button, and it will update the button text. Click the toggle button and then click the increment button again, and updates no longer show. Clicking the exit button closes the window, showing the events are still responding.
If anyone has any solutions, workarounds or fixes I'd appreciate them. Thanks.
import sys
from PyQt4.QtCore import *
from PyQt4.QtGui import *
class Settings(QWidget):
def __init__(self, desktop):
QWidget.__init__(self)
self.setAttribute(Qt.WA_TranslucentBackground, True)
self.setWindowFlags(Qt.FramelessWindowHint)
self.istransparent = True
self.count = 0
self.setWindowTitle("Transparent")
self.resize(300, 150)
self.incr_button = QPushButton("Increment")
toggle_button = QPushButton("Toggle Transparency")
exit_button = QPushButton("Exit")
grid = QGridLayout()
grid.addWidget(self.incr_button, 0, 0)
grid.addWidget(toggle_button, 1, 0)
grid.addWidget(exit_button, 2, 0)
self.setLayout(grid)
self.connect(toggle_button, SIGNAL("clicked()"), self.toggle)
self.connect(self.incr_button, SIGNAL("clicked()"), self.increment)
self.connect(exit_button, SIGNAL("clicked()"), self.close)
def increment(self):
self.count = self.count + 1
self.incr_button.setText("Increment (%i)" % self.count)
def toggle(self):
self.istransparent = not self.istransparent
self.setAttribute(Qt.WA_TranslucentBackground, self.istransparent)
if __name__ == "__main__":
app = QApplication(sys.argv)
s = Settings(app.desktop())
s.show()
sys.exit(app.exec_())
Try to replace self.setAttribute(Qt.WA_TranslucentBackground, ...) calls in __init__ and toggle with following method.
def set_transparency(self, enabled):
if enabled:
self.setAutoFillBackground(False)
else:
self.setAttribute(Qt.WA_NoSystemBackground, False)
self.setAttribute(Qt.WA_TranslucentBackground, enabled)
self.repaint()
Tested on PyQt-Py2.7-x86-gpl-4.9-1 (Windows 7)

Resources