16. Creating Custom Widgets

When standard Qt components cannot fulfill your requirements - particularly if you plan to reuse the widget in multiple places - you should create a custom widget rather than using ad-hoc solutions.

Qt has several ways to build custom widgets. In this chapter, we explore the two most common approaches using a tag input widget as the example:

  1. Composition. The most convenient way is to build your widget from standard Qt components adding custom properties, signals, and slots to it. This approach is quick and easy to maintain.

  2. Subclassing and custom painting. When you need more control, you can subclass QWidget directly and draw everything yourself using QPainter in the paintEvent().

16.1 Subclassing QWidget - The Minimal TagBar

The fastest way to create a custom widget is by composing existing Qt widgets. In this section we build a simple and functional TagBar that nonetheless has some drawbacks.

An icon of a clipboard-list1

You are tasked with creating tag input widget as quickly as possible. Given the tight deadline, you decide to build a custom widget using the available standard Qt widgets.

We will design the TagBar as two cooperating classes:

  • A TagChip widget representing a single tag pill, built from a QFrame containing a QLabel and a QToolButton.
  • A TagBar container that arranges chips in a QHBoxLayout alongside a QLineEdit. The result is fully functional - chips appear, signal their removal, and the input field accepts new tags - with no custom painting yet.

To build a custom tag bar widget:

 1 from PySide6.QtCore import Signal, Qt
 2 from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QToolButton
 3 
 4 
 5 # 1. Create the TagChip widget.
 6 
 7 class TagChip(QFrame):
 8 
 9     closed = Signal(str)
10 
11     def __init__(self, tag, parent=None):
12 
13         super().__init__(parent)
14         
15         self.bg_color = '#e0d4f5'
16         self.border_color = '#c4a4e8'
17         self.close_color = '#4a2c8f'
18         self.btn_hover_color = '#ebe2fa'
19 
20         self.tag = tag
21 
22         layout = QHBoxLayout(self)
23         layout.setContentsMargins(10, 2, 4, 2)
24         layout.setSpacing(4)
25 
26         label = QLabel(tag)
27 
28         close_button = QToolButton()
29         close_button.setText('\u2715')
30         close_button.setCursor(
31             Qt.CursorShape.PointingHandCursor)
32         close_button.setAutoRaise(True)
33 
34         close_button.clicked.connect(
35             lambda: self.closed.emit(self.tag))
36 
37         layout.addWidget(label)
38         layout.addWidget(close_button)
39 
40         self.setStyleSheet(f'''
41             TagChip {{
42                 background-color: {self.bg_color};
43                 border: 1px solid {self.border_color};
44                 border-radius: 10px;
45             }}
46             QToolButton {{
47                 border: none;
48                 background: transparent;
49                 padding: 0px;
50                 color: {self.close_color};
51                 font-weight: bold;
52                 border-radius: 7px;
53             }}
54             QToolButton:hover {{
55                 background: {self.btn_hover_color};
56             }}
57         ''')
  1. Create the TagChip widget. Start by building a single tag “pill”. TagChip is a small composite widget implemented as a QFrame subclass that contains aQLabel for the tag text, and a QToolButton for the close button. Use a stylesheet to give it a rounded appearance and a matching color scheme. Clicking the Close button emits the custom closed signal with the clicked tag text.
 1 from PySide6.QtCore import Signal, Slot
 2 from PySide6.QtWidgets import (QWidget, QHBoxLayout, QLineEdit)
 3 from tagchip import TagChip
 4 
 5 
 6 # 2. Create the TagBar container.
 7 
 8 class TagBar(QWidget):
 9 
10     tagsChanged = Signal(set)
11 
12     def __init__(self, parent=None):
13 
14         super().__init__(parent)
15 
16         self._tags = set()
17         self.buttons = {}
18 
19         self.h_layout = QHBoxLayout(self)
20         self.h_layout.setContentsMargins(2, 2, 2, 2)
21         self.h_layout.setSpacing(4)
22 
23         self.tag_edit = QLineEdit()
24         self.tag_edit.setPlaceholderText('Add tag...')
25         self.tag_edit.setFixedWidth(80)
26         self.tag_edit.returnPressed.connect(self.on_return_pressed)
27 
28         self.h_layout.addWidget(self.tag_edit)
29         self.h_layout.addStretch()
30         
31         self.setFocusProxy(self.tag_edit)
32     
33     # 3. Implement tag management.
34     
35     @property
36     def tags(self):
37         return set(self._tags)
38 
39     @tags.setter
40     def tags(self, new_tags):
41         if new_tags == self._tags:
42             return
43         self.blockSignals(True)
44         self.clear_buttons()
45         self._tags = set(new_tags)
46         for tag in sorted(self._tags):
47             self.create_button(tag)
48         self.blockSignals(False)
49 
50     @Slot()
51     def add_tag(self, tag):
52         tag = tag.strip()
53         if not tag or tag in self._tags:
54             self.tag_edit.clear()
55             return
56         self._tags.add(tag)
57         self.create_button(tag)
58         self.tag_edit.clear()
59         self.tagsChanged.emit(set(self._tags))
60 
61     def remove_tag(self, tag):
62         if tag not in self._tags:
63             return
64         self._tags.discard(tag)
65         button = self.buttons.pop(tag, None)
66         if button:
67             self.h_layout.removeWidget(button)
68             button.deleteLater()
69         self.tagsChanged.emit(set(self._tags))
70 
71     def create_button(self, tag):
72         chip = TagChip(tag)
73         chip.closed.connect(self.remove_tag)
74         self.buttons[tag] = chip
75         insert_index = self.h_layout.indexOf(self.tag_edit)
76         self.h_layout.insertWidget(insert_index, chip)
77 
78     def clear_buttons(self):
79         for button in self.buttons.values():
80             self.h_layout.removeWidget(button)
81             button.deleteLater()
82         self.buttons.clear()
83 
84     @Slot()
85     def on_return_pressed(self):
86         self.add_tag(self.tag_edit.text())
87         self.tag_edit.setFocus()
  1. Create the TagBar container. Next, create the TagBar widget. It uses a horizontal layout to arrange multiple TagChips and a QLineEdit at the end, which allows the user to type and add new tags.

  2. Implement tag management logic.

    • Maintain an internal set of tags.
    • Prevent duplicate tags.
    • add_tag() adds a new tag on Enter.
    • Clicking the TagChip’s Close button removes the tag.
  1 import sys
  2 from PySide6.QtCore import Qt, Slot
  3 from PySide6.QtGui import QAction
  4 from PySide6.QtWidgets import (QApplication, QMainWindow,
  5     QTextEdit, QWidget, QHBoxLayout, QVBoxLayout,
  6     QListWidget, QListWidgetItem, QStyle, QAbstractItemDelegate)
  7 
  8 from tagbar import TagBar
  9 
 10 
 11 class Note:
 12 
 13     def __init__(self):
 14         self.title = 'New Note'
 15         self.text = ''
 16         self.tags = set()
 17 
 18 
 19 class NoteEditor(QMainWindow):
 20 
 21     def __init__(self, parent=None):
 22 
 23         super().__init__(parent)
 24         self.setWindowTitle('Acme Notes')
 25         self.resize(660, 420)
 26 
 27         central = QWidget()
 28         self.setCentralWidget(central)
 29         h_layout = QHBoxLayout(central)
 30 
 31         self.note_list = QListWidget()
 32         self.note_list.setFixedWidth(150)
 33         h_layout.addWidget(self.note_list)
 34 
 35         editor_panel = QWidget()
 36         v_layout = QVBoxLayout(editor_panel)
 37         v_layout.setContentsMargins(0, 0, 0, 0)
 38         v_layout.setSpacing(2)
 39         h_layout.addWidget(editor_panel)
 40         
 41         # 4. Integrate with the main window.
 42         
 43         self.tag_bar = TagBar()
 44         self.text_edit = QTextEdit()
 45         v_layout.addWidget(self.tag_bar)
 46         v_layout.addWidget(self.text_edit)
 47         
 48         self.tag_bar.setEnabled(False)
 49         self.text_edit.setEnabled(False)
 50 
 51         self.note_list.currentItemChanged.connect(self.on_note_changed)
 52         self.note_list.itemChanged.connect(self.on_item_changed)
 53         self.tag_bar.tagsChanged.connect(self.on_tags_changed)
 54         self.note_list.itemDelegate().closeEditor.connect(
 55             self.on_title_editor_closed)
 56 
 57         new_action = QAction('New', self)
 58         new_action.setIcon(self.style().standardIcon(
 59             QStyle.StandardPixmap.SP_FileIcon))
 60         new_action.triggered.connect(self.add_note)
 61 
 62         toolbar = self.addToolBar('Main')
 63         toolbar.addAction(new_action)
 64         toolbar.setToolButtonStyle(
 65             Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
 66 
 67     @Slot()
 68     def add_note(self):
 69 
 70         note = Note()
 71 
 72         item = QListWidgetItem(note.title)
 73         item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
 74         item.setData(Qt.ItemDataRole.UserRole, note)
 75 
 76         self.note_list.addItem(item)
 77         self.note_list.setCurrentItem(item)
 78         self.note_list.editItem(item)
 79 
 80     @Slot()
 81     def on_note_changed(self, current, previous):
 82 
 83         if previous:
 84             note = previous.data(Qt.ItemDataRole.UserRole)
 85             if note:
 86                 note.text = self.text_edit.toPlainText()
 87                 note.tags = self.tag_bar.tags
 88 
 89         if current:
 90             note = current.data(Qt.ItemDataRole.UserRole)
 91             if note:
 92                 self.text_edit.setPlainText(note.text)
 93                 self.tag_bar.tags = note.tags
 94         else:
 95             self.text_edit.clear()
 96             self.tag_bar.tags = set()
 97             
 98         has_note = current is not None
 99         self.tag_bar.setEnabled(has_note)
100         self.text_edit.setEnabled(has_note)
101         
102         if not has_note:
103             self.text_edit.clear()
104             self.tag_bar.tags = set()
105 
106     @Slot()
107     def on_item_changed(self, item):
108         note = item.data(Qt.ItemDataRole.UserRole)
109         if note:
110             note.title = item.text().strip() or 'Untitled'
111     
112     @Slot(set)     
113     def on_tags_changed(self, tags):
114         item = self.note_list.currentItem()
115         if not item:
116             return
117         note = item.data(Qt.ItemDataRole.UserRole)
118         if note:
119             note.tags = tags 
120             
121     @Slot()
122     def on_title_editor_closed(self, editor, hint):
123         if hint == QAbstractItemDelegate.EndEditHint.SubmitModelCache:
124             self.tag_bar.setFocus()
125 
126 
127 if __name__ == '__main__':
128 
129     app = QApplication(sys.argv)
130     editor = NoteEditor()
131     editor.show()
132     sys.exit(app.exec())
  1. Integrate with the main editor. Finally, we connect the Tagbar to the NoteEditor so that tags are properly saved and loaded when the user switches between notes.

Now we have a working tag bar and are able to add and remove tags, but with one major flaw: the TagBar widget does not wrap tags when their total length exceeds its lenght, it just expands.

16.2 Drawing Tags with QPainter and paintEvent()

Although the composite TagChip built from QFrame, QLabel, and QToolButton works, it difficult to achieve a perfectly balanced pill shape with consistent proportions, padding, and hover feedback across different fonts and DPI settings.

Painting the chip ourselves with QPainter gives us precise visual control, clean hover states, lower memory overhead when many tags are present, and easier future customizations.

An icon of a clipboard-list1

Due to the limitations of the composite approach you decide to replace the QFrame, QLabel, and QToolButton entirely. You subclass QWidget directly and implement the entire TagChip using QPainter in paintEvent(), while leaving the TagBar container unchanged for the time being.

The diagram above illustrates the geometry and constants used in the painted TagChip. The main widget rectangle (dashed outer border) defines the chip’s total bounds. Inside it the filled rounded rectangle (the visible tag pill), ofset by border_width.

Text is drawn with text_padding on the left and space reserved on the right for the Close button (close_width). The Close button consists of a circular area (close_radius) positioned using close_padding. The sizeHint() calculation combines the variable text width with fixed spacing (2 * text_padding + close_width) to make sure the widget reports the correct size to layouts.

To implement a Qt widget using custom painting:

 1 from PySide6.QtCore import Signal, QSize, QRect, Qt
 2 from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QFont
 3 from PySide6.QtWidgets import QWidget
 4 
 5 
 6 class TagChip(QWidget):
 7     
 8     closed = Signal(str)
 9     
10     def __init__(self, tag, parent=None):
11         
12         super().__init__(parent)
13         
14         self.chip_height = 28
15         self.border_width = 1
16         self.radius = 14
17         self.text_padding = 14
18         self.close_width = 28
19         self.close_radius = 9
20         self.close_padding = 4
21         
22         self.bg_color = '#e0d4f5'
23         self.border_color = '#c4a4e8'
24         self.close_color = '#4a2c8f'
25         self.btn_hover_color = '#ebe2fa'
26         
27         self.tag = tag
28         
29         self.setMinimumHeight(self.chip_height)
30         self.setMaximumHeight(self.chip_height)
31         
32     def sizeHint(self):
33         font_metrics = self.fontMetrics()
34         text_width = font_metrics.horizontalAdvance(self.tag)
35         total_width = text_width + 2 * self.text_padding + self.close_width
36         return QSize(total_width, self.chip_height)
37     
38     def get_close_rect(self):
39         return QRect(
40             self.width() - self.close_width + self.close_padding,
41             self.close_padding,
42             self.close_width - 2 * self.close_padding,
43             self.chip_height - 2 * self.close_padding)
44     
45     def paintEvent(self, event):
46         
47         painter = QPainter(self)
48         painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
49         
50         rect = self.rect()
51 
52         painter.setBrush(QBrush(self.bg_color))
53         painter.setPen(QPen(QColor(self.border_color)))
54         
55         # 1. Draw the widget shape.
56         
57         painter.drawRoundedRect(
58             rect.adjusted(self.border_width,
59                           self.border_width,
60                           -self.border_width,
61                           -self.border_width),
62             self.radius, self.radius)
63         
64         painter.setPen(QColor(self.close_color))
65         font = QFont()
66         font.setPointSize(9)
67         font.setBold(False)
68         painter.setFont(font)
69         
70         # 2. Draw the widget text.
71         
72         text_rect = rect.adjusted(self.text_padding, 0, -self.close_width, 0)
73         painter.drawText(text_rect, Qt.AlignmentFlag.AlignVCenter, self.tag)
74         
75         # 3. Draw the widget user interaction area.
76         
77         close_rect = self.get_close_rect()
78         painter.setBrush(QColor(Qt.GlobalColor.transparent))
79         painter.setPen(Qt.PenStyle.NoPen)
80         painter.drawEllipse(close_rect.center(), self.close_radius, self.close_radius)
81         
82         painter.setPen(QColor(self.close_color))
83         font = QFont()
84         font.setPointSize(8)
85         font.setBold(True)
86         painter.setFont(font)
87         
88         painter.drawText(close_rect, Qt.AlignmentFlag.AlignCenter, '\u2715')
  1. Draw the widget shape. Use QPainter to draw a rounded rectangle that forms the pill-shaped background. Adjust the rectangle by the border width so the stroke sits neatly inside the widget bounds, giving the chip its pill appearance.

  2. Draw the widget text. Render the tag name using drawText(). Position the text with a left padding and leave space on the right for the Close button. Align it vertically in the center.

  3. Draw the widget user interaction area (Close button). Draw a transparent circular area to mark where the close button will be, then render the × symbol on top. At this point the button is purely visual: clicking and hovering aren’t implemented yet.

16.3 Handling Events

The painted TagChip from 16.2 looks correct but is completely unresponsive. Clicks are ignored and there is no visual feedback when hovering over the Close button. Because the × is drawn with QPainter the widget must handle its own mouse interactions and hit-testing.

An icon of a clipboard-list1

You need to add events handling to TagChip. You override mouseMoveEvent() and mousePressEvent() to detect when the cursor is over he × zone, update hover states, and emit the closed signal when the button is clicked.

To make a custom-painted widget interactive:

  1 from PySide6.QtCore import Signal, QSize, QRect, QRectF, Qt
  2 from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QFont
  3 from PySide6.QtWidgets import QWidget
  4 
  5 
  6 class TagChip(QWidget):
  7     
  8     closed = Signal(str)
  9     
 10     def __init__(self, tag, parent=None):
 11         
 12         super().__init__(parent)
 13         
 14         self.chip_height = 28
 15         self.border_width = 1
 16         self.radius = 14
 17         self.text_padding = 14
 18         self.close_width = 28
 19         self.close_radius = 9
 20         self.close_padding = 4
 21         
 22         self.bg_color = '#e0d4f5'
 23         self.border_color = '#c4a4e8'
 24         self.close_color = '#4a2c8f'
 25         self.btn_hover_color = '#ebe2fa'
 26         
 27         self.hover = False
 28         self.close_hover = False
 29         
 30         self.tag = tag
 31         
 32         # 1. Enable mouse tracking.
 33         
 34         self.setMouseTracking(True)
 35         
 36         self.setMinimumHeight(self.chip_height)
 37         self.setMaximumHeight(self.chip_height)
 38         
 39     def sizeHint(self):
 40         font_metrics = self.fontMetrics()
 41         text_width = font_metrics.horizontalAdvance(self.tag)
 42         total_width = text_width + 2 * self.text_padding + self.close_width
 43         return QSize(total_width, self.chip_height)
 44     
 45     def get_close_rect(self):
 46         return QRect(
 47             self.width() - self.close_width + self.close_padding,
 48             self.close_padding,
 49             self.close_width - 2 * self.close_padding,
 50             self.chip_height - 2 * self.close_padding)
 51     
 52     def paintEvent(self, event):
 53         
 54         painter = QPainter(self)
 55         painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
 56         
 57         rect = self.rect()
 58 
 59         painter.setBrush(QBrush(self.bg_color))
 60         painter.setPen(QPen(QColor(self.border_color)))
 61         painter.drawRoundedRect(
 62             rect.adjusted(self.border_width,
 63                           self.border_width,
 64                           -self.border_width,
 65                           -self.border_width),
 66             self.radius, self.radius)
 67         
 68         painter.setPen(QColor(self.close_color))
 69         font = QFont()
 70         font.setPointSize(9)
 71         font.setBold(False)
 72         painter.setFont(font)
 73         
 74         text_rect = rect.adjusted(self.text_padding, 0, -self.close_width, 0)
 75         painter.drawText(text_rect, Qt.AlignmentFlag.AlignVCenter, self.tag)
 76         
 77         close_rect = self.get_close_rect()
 78         if self.close_hover:
 79             painter.setBrush(QColor(self.btn_hover_color))
 80         else:
 81             painter.setBrush(QColor(Qt.GlobalColor.transparent))
 82         painter.setPen(Qt.PenStyle.NoPen)
 83         painter.drawEllipse(QRectF(close_rect).center(), self.close_radius, self.close_radius)
 84         
 85         painter.setPen(QColor(self.close_color))
 86         font = QFont()
 87         font.setPointSize(10)
 88         font.setBold(True)
 89         painter.setFont(font)
 90         
 91         painter.drawText(close_rect, Qt.AlignmentFlag.AlignCenter, '\u2715')
 92         
 93     def enterEvent(self, event):
 94         self.hover = True
 95         self.update()
 96         super().enterEvent(event)
 97         
 98     def leaveEvent(self, event):
 99         self.hover = False
100         self.close_hover = False
101         self.update()
102         super().leaveEvent(event)
103     
104     # 2. Detect hover states.
105         
106     def mouseMoveEvent(self, event):
107         close_rect = self.get_close_rect()
108         new_close_hover = close_rect.contains(event.pos())
109         if new_close_hover != self.close_hover:
110             self.close_hover = new_close_hover
111             self.update()
112         super().mouseMoveEvent(event)
113     
114     # 3. Handle clicks.
115     
116     def mousePressEvent(self, event):
117         if event.button() == Qt.MouseButton.LeftButton:
118             close_rect = self.get_close_rect()
119             if close_rect.contains(event.pos()):
120                 self.closed.emit(self.tag)
121                 return
122         super().mousePressEvent(event)
123         
124     def mouseReleaseEvent(self, event):
125         super().mouseReleaseEvent(event)
  1. Enable mouse tracking. Call setMouseTracking(True) so the widget receives mouse move events even when no button is pressed.

  2. Detect hover states. Reimplement mouseMoveEvent() to check whether the mouse is over the close button area (using the same get_close_rect() as in painting). Update the close_hover flag and trigger a repaint when the state changes. Also handle enterEvent() and leaveEvent() to reset hover states.

  3. Handle clicks. Reimplement mousePressEvent() to test if the left click occurred inside the close button rectangle. If it did, emit the closed signals with the tag text.

Note that, although the Close button is circular, we still use the rectangular close_rect for hit-testing. This is for practicality and performance reasons: rectangle checks (QRect.contains()) are fast and simple. The slightly larger rectangular area provides a more forgiving click target without a noticeable downside.

With this, the TagChip provides visual feedback and responds to clicks like the original composite version. However, the pill wrapping problem is still present.

16.4 Flow Layout, Size Hints, and Layout Integration

TagChip widgets are now properly painted and interactive but the pill-wrapping problem still remains. When too many tags are added, they expand the TagBar instead of wrapping to new rows. Standard layouts like QHBoxLayout cannot handle flowing/wrapping items while keeping a trailing QLineEdit.

The diagram above illustrates the core compute_rects() algorithm: a left-to right cursor advances across the chips, wrapping to a new row when the next item would ovewrflow the available width. The QLineEdit follows the same cursor after the last chip.

An icon of a clipboard-list1

To enable wrapping of TagChip widgets, you remove TagBar’s QHBoxLayout and implement geometry management yourself.

To create a flowing tag bar:

  1 from PySide6.QtCore import Signal, Slot, QSize, Qt
  2 from PySide6.QtWidgets import QWidget, QLineEdit
  3 from tagchip import TagChip
  4 
  5 
  6 class TagBar(QWidget):
  7 
  8     tagsChanged = Signal(set)
  9 
 10     def __init__(self, parent=None):
 11 
 12         super().__init__(parent)
 13 
 14         self._tags = set()
 15         self.buttons = {}
 16 
 17         self.tag_edit = QLineEdit(self)
 18         self.tag_edit.setPlaceholderText('Add tag...')
 19         self.tag_edit.setFixedWidth(100)
 20         self.tag_edit.returnPressed.connect(self.on_return_pressed)
 21 
 22         self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 23         self.setMinimumHeight(40)
 24         self.setFocusProxy(self.tag_edit)
 25 
 26     @property
 27     def tags(self):
 28         return set(self._tags)
 29 
 30     @tags.setter
 31     def tags(self, new_tags):
 32         if new_tags == self._tags:
 33             return
 34         self.blockSignals(True)
 35         self.clear_buttons()
 36         self._tags = set(new_tags)
 37         for tag in sorted(self._tags):
 38             self.create_button(tag)
 39         self.blockSignals(False)
 40         self.update_geometry()
 41 
 42     # 1. Implement the wrapping logic.
 43 
 44     def compute_rects(self):
 45         margin = 4
 46         spacing = 6
 47         row_h = 32
 48         available_w = max(100, self.width() - 2 * margin)
 49 
 50         x = margin
 51         y = margin
 52 
 53         positions = []
 54         widgets = []
 55 
 56         for chip in self.buttons.values():
 57             w = max(chip.sizeHint().width(), 60)
 58 
 59             if x + w > available_w and x > margin:
 60                 x = margin
 61                 y += row_h + spacing
 62 
 63             positions.append((x, y, w, row_h))
 64             widgets.append(chip)
 65             x += w + spacing
 66 
 67         edit_w = 100
 68         if x + edit_w > available_w and x > margin:
 69             x = margin
 70             y += row_h + spacing
 71 
 72         positions.append((x, y, edit_w, row_h))
 73         widgets.append(self.tag_edit)
 74 
 75         total_h = y + row_h + 2 * margin
 76         return positions, widgets, total_h
 77     
 78     # 2. Update widget positions and height.
 79 
 80     def update_geometry(self):
 81         if self.width() < 50:
 82             return
 83 
 84         positions, widgets, needed_h = self.compute_rects()
 85 
 86         for (x, y, w, h), widget in zip(positions, widgets):
 87             widget.setGeometry(x, y, w, h)
 88             widget.show()
 89 
 90         self.setMinimumHeight(needed_h)
 91         self.updateGeometry()
 92     
 93     # 3. Coordinate with Qt's layout system.
 94 
 95     def resizeEvent(self, event):
 96         super().resizeEvent(event)
 97         self.update_geometry()
 98 
 99     def showEvent(self, event):
100         super().showEvent(event)
101         self.update_geometry()
102 
103     def sizeHint(self):
104         _, _, h = self.compute_rects()
105         return QSize(500, max(40, h))
106 
107     def create_button(self, tag):
108         if tag in self.buttons:
109             return
110         chip = TagChip(tag, parent=self)
111         chip.closed.connect(self.remove_tag)
112         self.buttons[tag] = chip
113         self.update_geometry()
114 
115     def clear_buttons(self):
116         for chip in list(self.buttons.values()):
117             chip.deleteLater()
118         self.buttons.clear()
119 
120     def remove_tag(self, tag):
121         if tag not in self._tags:
122             return
123         self._tags.discard(tag)
124         chip = self.buttons.pop(tag, None)
125         if chip:
126             chip.deleteLater()
127         self.tagsChanged.emit(set(self._tags))
128         self.update_geometry()
129 
130     @Slot()
131     def add_tag(self, tag):
132         tag = tag.strip()
133         if not tag or tag in self._tags:
134             self.tag_edit.clear()
135             return
136         self._tags.add(tag)
137         self.create_button(tag)
138         self.tag_edit.clear()
139         self.tagsChanged.emit(set(self._tags))
140 
141     @Slot()
142     def on_return_pressed(self):
143         self.add_tag(self.tag_edit.text())
144         self.tag_edit.setFocus()
  1. Implement the wrapping logic. In compute_rects(), walk through the chips from left to right, tracking a cursor position (x, y) that starts at the top-left margin. Each chip’s width comes from its sizeHint(), floored at a small minimum so very short tags don’t shrink below a usable size. Before placing a chip, check whether it would overflow the available width. If it would, and it isn’t already the first item on the current row, reset x to the margin and drop y down by one row height plus spacing. The ‘first item on the row’ check matters - without it, a single chip wider than the available width would trigger an endless wrap instead of just being on its own row. Place the QLineEdit immediately after the last chip using the same logic.

  2. Update widget positions and height. In update_geometry(), call compute_rects() and use setGeometry() on every chip and the line edit. Also update the TagBars minimum height nd notify the parent layout with updateGeometry().

  3. Coordinate with Qt’s layout system. Reimplement resizeEvent() so the layout recalculates when the user resizes the window. Implement sizeHint() and call update_geometry() in showEvent() so the parent widget knows how tall the TagBar needs to be.

This diagram shows how resizeEvent() and sizeHint() both go through the same compute_rects() function. We keep all geometry logic in one place so the widget behaves correctly both when resized by the user and when Qt’s layout system asks for its preferred size.

As mentioned, in addition to composition and custom painting, there are other ways to make a custom widget. A third option is specialization: subclassing an existing widget like QLabel or QPushButton and overriding just the behavior or appearance you need. Style sheets, QStyle/QProxyStyle, QGraphicsView, and item delegates are all custom-widget techniques too, covered elsewhere in this book.