Files
cowork-local/tools/check_rail_align.py
T
Nam Pham Dinh ThanhandClaude Opus 5 cb03bc1494 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>
2026-08-18 11:01:56 +09:00

235 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())