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>
235 lines
9.0 KiB
Python
235 lines
9.0 KiB
Python
"""Rail icons must sit on one vertical line, and stay there when it collapses.
|
||
|
||
Two bugs this catches:
|
||
· "Cài đặt" sat 42px right of "Dashboard"/"Giám sát" — its QSS margin pushed
|
||
the button in while the tree rows above start at the rail edge.
|
||
· Collapsing re-placed the icon of every label-less button, sliding + to the
|
||
middle of the 54px rail.
|
||
|
||
Runs with the app's real stylesheet loaded. Without it the window has no
|
||
padding, margins or borders and neither bug is visible — see _apply_theme.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
|
||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
from pathlib import Path
|
||
|
||
REPO = Path(__file__).resolve().parent.parent
|
||
sys.path.insert(0, str(REPO.parent))
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
|
||
from capture_screens import ( # noqa: E402
|
||
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
|
||
|
||
TOL = 2 # px; anti-aliasing on an icon edge
|
||
|
||
|
||
def main() -> int:
|
||
sandbox = _isolate_home()
|
||
from PySide6.QtCore import QPoint
|
||
from PySide6.QtWidgets import QApplication
|
||
|
||
app = QApplication([])
|
||
_load_fonts()
|
||
_freeze_schedulers()
|
||
theme_name = _apply_theme(app)
|
||
|
||
from cowork_local.config import CONFIG_DIR
|
||
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
|
||
|
||
from seed_demo_data import seed
|
||
seed()
|
||
|
||
from cowork_local.app import MainWindow
|
||
from cowork_local.config import AppConfig
|
||
from cowork_local.i18n import set_language
|
||
from cowork_local.state import AppContext
|
||
|
||
set_language("vi")
|
||
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
|
||
win.resize(1400, 900)
|
||
win.show()
|
||
app.processEvents()
|
||
rail = win._nav_wrap
|
||
|
||
def ink_x(w):
|
||
"""Leftmost painted pixel of a widget, in rail coordinates."""
|
||
img = w.grab().toImage()
|
||
bg = img.pixelColor(w.width() - 3, 2)
|
||
for x in range(img.width()):
|
||
for y in range(2, img.height() - 2):
|
||
c = img.pixelColor(x, y)
|
||
if (abs(c.red() - bg.red()) + abs(c.green() - bg.green())
|
||
+ abs(c.blue() - bg.blue())) > 60:
|
||
return w.mapTo(rail, QPoint(x, 0)).x()
|
||
return None
|
||
|
||
def tree_text_x(tree):
|
||
from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
|
||
index = tree.indexFromItem(tree.topLevelItem(0), 0)
|
||
opt = QStyleOptionViewItem()
|
||
tree.initViewItemOption(opt)
|
||
opt.rect = tree.visualRect(index)
|
||
tree.itemDelegate().initStyleOption(opt, index)
|
||
txt = tree.style().subElementRect(QStyle.SE_ItemViewItemText, opt, tree)
|
||
return tree.mapTo(rail, QPoint(txt.left(), 0)).x()
|
||
|
||
def btn_text_x(w):
|
||
"""Left edge of the label: first ink past the icon's gap."""
|
||
from PySide6.QtGui import QIcon
|
||
if not w.text():
|
||
return None
|
||
img = w.grab().toImage()
|
||
bg = img.pixelColor(w.width() - 3, 2)
|
||
ink = []
|
||
for x in range(img.width()):
|
||
for y in range(2, img.height() - 2):
|
||
c = img.pixelColor(x, y)
|
||
if (abs(c.red() - bg.red()) + abs(c.green() - bg.green())
|
||
+ abs(c.blue() - bg.blue())) > 60:
|
||
ink.append(x)
|
||
break
|
||
if not ink:
|
||
return None
|
||
for a, b in zip(ink, ink[1:]): # first gap = icon/label spacing
|
||
if b - a > 2:
|
||
return w.mapTo(rail, QPoint(b, 0)).x()
|
||
return None
|
||
|
||
def tree_icon_x(tree):
|
||
from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
|
||
index = tree.indexFromItem(tree.topLevelItem(0), 0)
|
||
opt = QStyleOptionViewItem()
|
||
tree.initViewItemOption(opt)
|
||
opt.rect = tree.visualRect(index)
|
||
tree.itemDelegate().initStyleOption(opt, index)
|
||
deco = tree.style().subElementRect(
|
||
QStyle.SE_ItemViewItemDecoration, opt, tree)
|
||
return tree.mapTo(rail, QPoint(deco.left(), 0)).x()
|
||
|
||
def snapshot():
|
||
app.processEvents()
|
||
out = {"nav rows": tree_icon_x(win.nav),
|
||
"bottom rows": tree_icon_x(win.nav_bottom),
|
||
"bottom rows text": tree_text_x(win.nav_bottom)}
|
||
for label, attr in (("MENU", "_nav_toggle_btn"),
|
||
("new chat", "nav_new_chat"),
|
||
("settings", "_nav_settings_btn")):
|
||
w = getattr(win, attr, None)
|
||
if w is not None and w.isVisible():
|
||
out[label] = ink_x(w)
|
||
if label == "settings":
|
||
out["settings text"] = btn_text_x(w)
|
||
return out
|
||
|
||
fails = []
|
||
for theme_name in ("dark", "light"):
|
||
_apply_theme(app, theme_name)
|
||
app.processEvents()
|
||
if win._nav_collapsed:
|
||
win._toggle_nav()
|
||
opened = snapshot()
|
||
win._toggle_nav()
|
||
app.processEvents()
|
||
closed = snapshot()
|
||
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:
|
||
print(f"FAIL {f}")
|
||
print("PASS every rail icon holds its line" if not fails
|
||
else f"{len(fails)} problem(s)")
|
||
sys.stdout.flush()
|
||
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}")
|
||
print(f"{'element':<12}{'open':>7}{'collapsed':>11}{'drift':>8}")
|
||
fails = []
|
||
for key in opened:
|
||
a, b = opened[key], closed.get(key)
|
||
drift = "-" if a is None or b is None else f"{b - a:+d}"
|
||
print(f"{key:<12}{str(a):>7}{str(b):>11}{drift:>8}")
|
||
if a is not None and b is not None and abs(b - a) > TOL:
|
||
fails.append(f"{key}: icon moves {b - a:+d}px when the rail collapses")
|
||
|
||
# Settings is a button but reads as one more row in the bottom list, so
|
||
# both its icon and its label have to start where theirs do.
|
||
for state, snap in (("open", opened), ("collapsed", closed)):
|
||
for what in ("", " text"):
|
||
ref, got = snap.get("bottom rows" + what), snap.get("settings" + what)
|
||
if ref is not None and got is not None and abs(got - ref) > TOL:
|
||
fails.append(f"{theme_name} {state}: settings{what} x={got} but "
|
||
f"the rows above it start at x={ref}")
|
||
return fails
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|