Files
cowork-local/tools/check_rail_align.py
1419587401
CI / test (push) Canceled after 0s
Feature/fsg gamma team ui fix (#3)
## Summary

What changed and why?

## Change Type

- [ ] Cowork feature
- [ ] Bug fix
- [x] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Co-authored-by: NamPDT <minhanhpkpro@gmail.com>
Reviewed-on: #3
2026-08-20 12:12:56 +00:00

269 lines
11 KiB
Python
Raw Permalink 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."""
# Settings lays its own row out now, so read the label widget directly
# rather than hunting for a gap in the painted pixels.
lbl = getattr(win, "_nav_settings_text", None)
if w is getattr(win, "_nav_settings_btn", None) and lbl is not None:
return lbl.mapTo(rail, QPoint(0, 0)).x() if lbl.isVisible() else None
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)
fails += rows_stay_under_the_header(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 rows_stay_under_the_header(win, app, theme_name):
"""The destinations start just below the + button in both states.
Collapsing hides RECENTS, the one item in the scroll body with a stretch
factor. With nothing left to expand, a box layout centres what remains, and
the whole group slid ~300px down the rail.
"""
from PySide6.QtCore import QPoint
rail = win._nav_wrap
gaps = {}
for state in ("open", "collapsed"):
if (state == "collapsed") != win._nav_collapsed:
win._toggle_nav()
app.processEvents()
btn = win.nav_new_chat
below = btn.mapTo(rail, QPoint(0, btn.height())).y()
top = win.nav.mapTo(rail, QPoint(0, 0)).y() + win.nav.visualItemRect(win.nav.topLevelItem(0)).top()
gaps[state] = top - below
if win._nav_collapsed:
win._toggle_nav()
app.processEvents()
print(f" gap under +: open={gaps['open']}px collapsed={gaps['collapsed']}px")
if abs(gaps["collapsed"] - gaps["open"]) > 4:
return [f"{theme_name}: the destinations sit {gaps['collapsed']}px below "
f"the + button when collapsed but {gaps['open']}px when open"]
return []
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())