Pin rail row icons to the left edge instead of letting Qt centre them

QStyledItemDelegate hands the style decorationAlignment = AlignHCenter |
AlignVCenter. For a row with a label that is invisible — the icon sits left of
the text either way — but every rail row loses its label when the rail
collapses to 54px, and a centred decoration then lands wherever the column
happens to leave room. _NavItemDelegate overrides it to AlignLeft, so the icon
is on the row's left padding regardless of column width, style or platform.

Honest limit: this was reported from the running app and I could not reproduce
it offscreen — sweeping the column width leaves the icon at a fixed x with or
without the delegate. What is verifiable is the instruction, and centring is
the only mechanism by which Qt can put a label-less row's icon anywhere but
the left. check_rail_align now asserts the flag itself, which fails (132) the
moment the delegate is removed, alongside the column sweep.

Settings follows the rows to their new inset (padding-left 7px → 4px). Its
label still sat 3px left of Dashboard's because a button leaves less air
between icon and label than a tree row does; a 19px icon box closes that to
1px, with the 16px glyph left-aligned inside it.

15/15 checkers pass, no flakes this run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-18 11:01:56 +09:00
co-authored by Claude Opus 5
parent d060d5679a
commit cb03bc1494
3 changed files with 90 additions and 9 deletions
+26 -7
View File
@@ -9,6 +9,7 @@ from typing import List
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtWidgets import (
QStyledItemDelegate,
QApplication, QComboBox, QHBoxLayout, QLabel, QMainWindow, QMenu,
QPushButton, QScrollArea, QSizePolicy, QSplitter, QStackedWidget,
QSystemTrayIcon, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
@@ -79,6 +80,23 @@ class _Toast(QLabel):
self._timer.start(ms)
class _NavItemDelegate(QStyledItemDelegate):
"""Keep a rail row's icon on the left edge, whatever the column is doing.
QStyledItemDelegate hands the style decorationAlignment = AlignHCenter, so
a row with no label — every row once the rail collapses to 54px — has its
icon centred inside whatever box the column happens to give it. That box
tracks the column width, which is not stable: stretched to the viewport the
icons land in the middle of the rail, while a column left wider than the
view leaves them at the left. Same code, two different pictures, which is
why a test render disagreed with the running app.
"""
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
option.decorationAlignment = Qt.AlignLeft | Qt.AlignVCenter
class MainWindow(QMainWindow):
# Nav rows (Dashboard/Schedule/Monitoring are lazy; Workspace is the eager home page).
_ROW_DASHBOARD, _ROW_SCHEDULE, _ROW_WORKSPACE, _ROW_MONITORING = 0, 1, 2, 3
@@ -448,13 +466,14 @@ class MainWindow(QMainWindow):
tree.setIndentation(0)
tree.setRootIsDecorated(False)
tree.setUniformRowHeights(True)
# The column follows the viewport instead of the widest label. Left
# to size itself it stayed ~100px wide inside the 54px collapsed
# rail, so a horizontal scrollbar appeared and slid the icons out of
# the position they hold while the rail is open.
from PySide6.QtWidgets import QHeaderView
tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
# The column follows the viewport instead of the widest label. Left
# to size itself it stayed ~100px wide inside the 54px collapsed
# rail, so a horizontal scrollbar appeared and slid the icons out of
# the position they hold while the rail is open.
from PySide6.QtWidgets import QHeaderView
tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
tree.setItemDelegate(_NavItemDelegate(tree))
return tree
def _nav_rows(self):
+6 -2
View File
@@ -349,7 +349,7 @@ QToolTip {
/* Icons are drawn at text scale, not as decoration. */
QPushButton, QToolButton, QComboBox, QTabBar { qproperty-iconSize: 15px 15px; }
QTreeWidget#navrail { qproperty-iconSize: 16px 16px; }
QTreeWidget#navrail { qproperty-iconSize: 22px 16px; }
/* ---- shell ------------------------------------------------------------ */
QWidget#topbar { background: $bg; border: none; border-bottom: 1px solid $border; }
@@ -471,7 +471,11 @@ QComboBox#navProjectPick {
}
QPushButton#navSettingsBtn {
background: transparent; border: none; color: $text_muted;
padding: 6px 8px 6px 7px; text-align: left; border-radius: ${radius}px;
padding: 6px 8px 6px 4px; text-align: left; border-radius: ${radius}px;
/* A button leaves less air between icon and label than a tree row
does, so the word sat 3px left of Dashboard's. Widening the icon
box (the glyph stays 16px, left-aligned) closes the gap. */
qproperty-iconSize: 19px 16px;
/* No side margin: Settings reads as one more row under Dashboard/Giám sát,
so its icon has to start on their x. A 6px margin put it at 14 — near
enough the middle of the collapsed 54px rail to look centred. */
+58
View File
@@ -139,6 +139,7 @@ def main() -> int:
win._toggle_nav()
app.processEvents()
fails += compare(theme_name, rail, opened, closed)
fails += column_widths_do_not_move_icons(win, app, theme_name)
print()
for f in fails:
@@ -149,6 +150,63 @@ def main() -> int:
os._exit(1 if fails else 0)
def column_widths_do_not_move_icons(win, app, theme_name):
"""The icon must not care how wide the column is.
On the machine that reported this the column matched the rail and the icons
sat in the middle; in a test render the column stayed wider than the view
and the same code drew them at the left. So sweep the width and require the
icon to hold still.
"""
from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
if not win._nav_collapsed:
win._toggle_nav()
app.processEvents()
fails, seen = [], {}
# The invariant that matters. A centred decoration is the only way Qt can
# put a label-less row's icon anywhere but the left edge, and how far it
# travels depends on the box the column hands it — which is why this
# reproduces on one machine and not another. Require the instruction
# itself, not just the pixel it happens to produce here.
from PySide6.QtCore import Qt as _Qt
for tree_name in ("nav", "nav_bottom"):
tree = getattr(win, tree_name)
index = tree.indexFromItem(tree.topLevelItem(0), 0)
opt = QStyleOptionViewItem()
tree.initViewItemOption(opt)
tree.itemDelegate().initStyleOption(opt, index)
align = int(opt.decorationAlignment)
if align & int(_Qt.AlignHCenter) or not align & int(_Qt.AlignLeft):
fails.append(f"{theme_name} {tree_name}: decorationAlignment={align}"
f" — icon is free to drift off the left edge")
for tree_name in ("nav", "nav_bottom"):
tree = getattr(win, tree_name)
keep = tree.columnWidth(0)
for width in (tree.viewport().width(), 70, 100, 140):
tree.setColumnWidth(0, width)
app.processEvents()
index = tree.indexFromItem(tree.topLevelItem(0), 0)
opt = QStyleOptionViewItem()
tree.initViewItemOption(opt)
opt.rect = tree.visualRect(index)
tree.itemDelegate().initStyleOption(opt, index)
x = tree.style().subElementRect(
QStyle.SE_ItemViewItemDecoration, opt, tree).left()
seen.setdefault(tree_name, []).append((width, x))
tree.setColumnWidth(0, keep)
app.processEvents()
xs = {x for _w, x in seen[tree_name]}
if len(xs) > 1:
fails.append(f"{theme_name} {tree_name}: icon x changes with the "
f"column width — {seen[tree_name]}")
print(f" column sweep: " + " ".join(
f"{n}={[x for _w, x in v]}" for n, v in seen.items()))
win._toggle_nav()
app.processEvents()
return fails
def compare(theme_name, rail, opened, closed):
print()
print(f"theme={theme_name}")