diff --git a/app.py b/app.py index e76c8ba..620d856 100644 --- a/app.py +++ b/app.py @@ -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): diff --git a/theme.py b/theme.py index 40808fe..c334972 100644 --- a/theme.py +++ b/theme.py @@ -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. */ diff --git a/tools/check_rail_align.py b/tools/check_rail_align.py index c64b1c2..8000e3f 100644 --- a/tools/check_rail_align.py +++ b/tools/check_rail_align.py @@ -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}")