I'm planning to use a QLineEdit
with three actions added via addAction()
.
Easy enough and it looks like this: (squares as icons for the example)
But a minor annoyance is that I find the spacing between the icons a bit too large.
Is it possible to adjust this spacing? QLineEdit doesn't seem to have an accessible layout where you could set the spacing.
Actions shown in QLineEdit (including that used for the "clear button") are implemented privately.
The addAction(<action|icon>, position)
is an overload of QWidget::addAction()
that, after calling the base implementation, also creates an instance of a private QToolButton subclass (QLineEditIconButton).
The reasoning behind using QToolButton is that it provides immediate mouse interaction, and also has a strong relation with QActions (see QToolButton::setDefaultAction()
).
Each button then follows common "side widgets parameters" that use hardcoded values:
QLineEditPrivate::SideWidgetParameters QLineEditPrivate::sideWidgetParameters() const
{
Q_Q(const QLineEdit);
SideWidgetParameters result;
result.iconSize = q->style()->pixelMetric(QStyle::PM_SmallIconSize, nullptr, q);
result.margin = result.iconSize / 4;
result.widgetWidth = result.iconSize + 6;
result.widgetHeight = result.iconSize + 2;
return result;
}
That is a lot of spacing and margins; it may be fine for relatively wide line edits or for systems using large icons and/or huge fonts, but also a considerable waste of space for most cases using more than one action (possibly including the clear button): even using a basic 16px icon size, this means that every 2 icons there is possibly enough space for another one.
In any case, once a new action is added, QLineEdit then lays out those "fake buttons" based on the above values, spacing them with the result.margin
and considering the increased width.
Note that those buttons are not even drawn as real QToolButtons, as the private QLineEditIconButton subclass also overrides its own paintEvent()
. In fact, it just draws the icon pixmap (using the enabled and pressed state of the action/button) within the QToolButton rect()
.
Luckily, painting and mouse interaction are always based on the button geometry, meaning that we can programmatically change their geometry once they've been set (and still have full functionality), which happens when:
layoutDirection
changes;For very basic applications that only consider "trailing actions", we could just consider the first three cases above (and left-to-right text), then arbitrarily update the geometries of the tool buttons based on assumed sizes and positions; that may not be sufficient, though, because there can be actions on both sides, and the layout direction also inverts the position (and order) of those actions.
Also, QLineEdit considers the effective text margins (the "bounding rectangle" in which text is actually shown and can be interacted with) based on the above "side widget parameters", meaning that once we've reduced the size and/or spacing between the buttons, we're still left out with the same, possibly small, horizontal space left for displayed text and cursor movement.
This means that we need to keep track of the position of each action (leading or trailing) and finally update the text margins with a further "private" implementation.
In the following example I'm showing a possible implementation of everything explained above, which should work fine in most cases. There may be some issues for actions that are explicitly hidden (but I'll check that later, as I'm under the impression that it could be a bug, see the note below[1]).
It should also work fine for both PyQt6 and PySide6, as well as PyQt5 and PySide2 (except for the enum namespaces for older PyQt5 versions and PySide2).
class CustomLineEdit(QLineEdit):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__textMargins = QMargins()
self.__iconMargins = QMargins()
self.__leadingActions = set()
self.__trailingActions = set()
self.__actions = {
QLineEdit.ActionPosition.LeadingPosition: self.__leadingActions,
QLineEdit.ActionPosition.TrailingPosition: self.__trailingActions
}
def __updateGeometries(self):
buttons = self.findChildren(QToolButton)
if len(buttons) <= 1:
return
iconSize = self.style().pixelMetric(
QStyle.PixelMetric.PM_SmallIconSize, None, self)
iconMargin = max(1, iconSize // 8)
btnWidth = iconSize + 2
leading = []
trailing = []
for button in buttons:
if button.defaultAction() in self.__leadingActions:
leading.append(button)
else:
trailing.append(button)
if self.layoutDirection() == Qt.LayoutDirection.RightToLeft:
leading, trailing = trailing, leading
if leading:
if len(leading) > 1:
leading.sort(key=lambda b: b.x())
lastGeo = leading[-1].geometry()
it = iter(leading)
prev = None
while True:
button = next(it, None)
if not button:
break
geo = button.geometry()
geo.setWidth(btnWidth)
if prev:
geo.moveLeft(prev.x() + prev.width() + iconMargin)
button.setGeometry(geo)
prev = button
else:
button = leading[0]
lastGeo = button.geometry()
geo = button.geometry()
geo.setWidth(btnWidth)
button.setGeometry(geo)
left = geo.right() - lastGeo.right()
else:
left = 0
if trailing:
if len(trailing) > 1:
trailing.sort(key=lambda b: -b.x())
lastGeo = trailing[-1].geometry()
it = iter(trailing)
prev = None
while True:
button = next(it, None)
if not button:
break
geo = button.geometry()
if prev:
geo.setWidth(btnWidth)
geo.moveRight(prev.x() - iconMargin)
else:
geo.setLeft(geo.right() - btnWidth + 1)
button.setGeometry(geo)
prev = button
else:
button = trailing[0]
lastGeo = button.geometry()
geo = button.geometry()
geo.setLeft(geo.right() - btnWidth + 1)
button.setGeometry(geo)
right = lastGeo.x() - geo.x()
else:
right = 0
self.__iconMargins = QMargins(left, 0, right, 0)
super().setTextMargins(self.__textMargins + self.__iconMargins)
# Note that these are NOT "real overrides"
def addAction(self, *args):
if len(args) != 2:
# possibly, the default QWidget::addAction()
super().addAction(*args)
return
arg, position = args
if isinstance(arg, QAction):
action = arg
# check if the action already exists in a different position, and
# eventually remove it from the related set
if (
position == QLineEdit.ActionPosition.LeadingPosition
and action in self.__trailingActions
):
self.__trailingActions.discard(action)
elif action in self.__leadingActions:
self.__leadingActions.discard(action)
super().addAction(action, position)
self.__actions[position].add(action)
# addAction(action, position) is "void" and returns None
return
action = super().addAction(arg, position)
self.__actions[position].add(action)
# for compliance with the default addAction() behavior
return action
def textMargins(self):
return QMargins(self.__textMargins)
def setTextMargins(self, *args):
self.__textMargins = QMargins(*args)
super().setTextMargins(self.__textMargins + self.__iconMargins)
# Common event overrides
def actionEvent(self, event):
super().actionEvent(event)
if event.type() == event.Type.ActionRemoved:
# this should take care of actions that are being deleted, even
# indirectly (eg. their parent is being destroyed)
action = event.action()
if action in self.__leadingActions:
self.__leadingActions.discard(action)
elif action in self.__trailingActions:
self.__trailingActions.discard(action)
else:
return
self.__updateGeometries()
def changeEvent(self, event):
super().changeEvent(event)
if event.type() == event.Type.LayoutDirectionChange:
self.__updateGeometries()
def childEvent(self, event):
super().childEvent(event)
if (
event.polished()
and isinstance(event.child(), QToolButton)
# the following is optional and the name may change in the future
and event.child().metaObject().className() == 'QLineEditIconButton'
):
self.__updateGeometries()
def resizeEvent(self, event):
super().resizeEvent(event)
self.__updateGeometries()
And here is an example code that creates two line edits with similar functionalities (two undo/redo actions and clear button), in order to compare the differences in layouts.
if __name__ == '__main__':
import sys
app = QApplication(sys.argv)
test = QWidget()
layout = QFormLayout(test)
undoIcon = QIcon.fromTheme('edit-undo')
if undoIcon.isNull():
undoIcon = app.style().standardIcon(
QStyle.StandardPixmap.SP_ArrowLeft)
redoIcon = QIcon.fromTheme('edit-redo')
if redoIcon.isNull():
redoIcon = app.style().standardIcon(
QStyle.StandardPixmap.SP_ArrowRight)
def makeLineEdit(cls):
def checkUndoRedo():
undoAction.setEnabled(widget.isUndoAvailable())
redoAction.setEnabled(widget.isRedoAvailable())
widget = cls()
widget.setClearButtonEnabled(True)
undoAction = QAction(undoIcon, 'Undo', widget, enabled=False)
redoAction = QAction(redoIcon, 'Redo', widget, enabled=False)
widget.addAction(redoAction, QLineEdit.ActionPosition.TrailingPosition)
widget.addAction(undoAction, QLineEdit.ActionPosition.TrailingPosition)
undoAction.triggered.connect(widget.undo)
redoAction.triggered.connect(widget.redo)
widget.textChanged.connect(checkUndoRedo)
return widget
layout.addRow('Standard QLineEdit:', makeLineEdit(QLineEdit))
layout.addRow('Custom QLineEdit:', makeLineEdit(CustomLineEdit))
test.show()
sys.exit(app.exec())
Here is the result:
[1]: there could be some inconsistencies with QEvent.Type.ActionChanged
for actionEvent()
(which is received first by the private QLineEditIconButton) when the visibility of an action changes at runtime and after __updateGeometries()
has been already called following the other events, which may reset the text margins. I will do further investigation, as I believe it as a symptom of a bug, other than a possible inconsistency in the behavior of the latest Qt versions.
[2]: I've not tested this on many styles, nor with complex selections or drag&drop; feel free to leave a comment about these aspects or related unexpected behavior.