From 35f24e0e2819ef6144d82a58f0e1bb596f2bd51c Mon Sep 17 00:00:00 2001 From: Duy Le Huu Date: Thu, 17 Sep 2026 15:34:57 +0900 Subject: [PATCH] =?UTF-8?q?fix(rail):=20canh=20h=C3=A0ng=20C=C3=A0i=20?= =?UTF-8?q?=C4=91=E1=BA=B7t=20th=E1=BA=B3ng=20v=E1=BB=9Bi=20Dashboard=20v?= =?UTF-8?q?=C3=A0=20Gi=C3=A1m=20s=C3=A1t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hàng "Cài đặt" là một QPushButton tự dựng icon + chữ chứ không phải hàng của QTreeWidget như Dashboard/Giám sát, nên khoảng cách icon-chữ do layout đặt chứ không do delegate của cây. Đặt _NAV_ROW_GAP + 4 (=10) ở đó, cộng 10px lề trái và hộp icon 22px, đưa chữ ra x=42 trong khi delegate đặt chữ ở x=35 — thụt phải 7px, thấy rõ bằng mắt. Lấy khe đó từ style (PM_FocusFrameHMargin + 1) thay vì tự đặt số, đúng khe mà delegate dùng. Kèm test hồi quy đo bằng pixel trên cửa sổ thật, ép ba hàng về cùng icon và cùng chữ để đo bố cục chứ không đo hình dáng glyph. Co-Authored-By: Claude Opus 5 --- presentation/shell/top_bar.py | 12 ++- tests/ui/test_nav_rail_alignment.py | 135 ++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 tests/ui/test_nav_rail_alignment.py diff --git a/presentation/shell/top_bar.py b/presentation/shell/top_bar.py index adf83d1..afde317 100644 --- a/presentation/shell/top_bar.py +++ b/presentation/shell/top_bar.py @@ -32,10 +32,10 @@ class TopBarMixin: ``_build_account_row`` ngay bên dưới, chỉ khác chỗ đặt trên màn hình. """ from PySide6.QtCore import Qt - from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton + from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStyle from ...i18n import tr from ...ui.icons import icon as _icon - from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET, _NAV_SETTINGS_GAP + from .rail_metrics import _NAV_ROW_INSET, _NAV_SETTINGS_GAP # Bottom-pinned group: the places you visit occasionally, kept out of the # way of the ones you live in. A hairline (styled via #navrailBottom in @@ -60,7 +60,13 @@ class TopBarMixin: # (28 wanted, 20 given), which both clipped the icon and pushed the text # 8px below an even pitch with Dashboard / Giám sát. srow.setContentsMargins(_NAV_ROW_INSET + 2, 0, 8, 0) - srow.setSpacing(_NAV_ROW_GAP + 4) + # Khe giữa icon và chữ phải là khe của STYLE, không phải nhịp riêng của + # rail: delegate của cây vẽ chữ ngay sau hộp icon, cách đúng + # ``PM_FocusFrameHMargin + 1``. Đặt ``_NAV_ROW_GAP + 4`` (=10) ở đây cộng + # với 10px lề trái và hộp icon 22px thành 42 — trong khi Dashboard / + # Giám sát đặt chữ ở 35, nên hàng Cài đặt thụt phải 7px. + srow.setSpacing( + self.nav_bottom.style().pixelMetric(QStyle.PM_FocusFrameHMargin) + 1) self._nav_settings_icon = QLabel() self._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16)) self._nav_settings_icon.setFixedSize(22, 16) diff --git a/tests/ui/test_nav_rail_alignment.py b/tests/ui/test_nav_rail_alignment.py new file mode 100644 index 0000000..b81aa1c --- /dev/null +++ b/tests/ui/test_nav_rail_alignment.py @@ -0,0 +1,135 @@ +"""Ba hàng cuối thanh rail phải thẳng hàng: Dashboard, Giám sát, Cài đặt. + +Dashboard và Giám sát là hàng của ``QTreeWidget`` (``#navrailBottom``), còn Cài +đặt là một ``QPushButton`` tự dựng lấy icon + chữ trong ``top_bar.py``. Hai cách +vẽ khác nhau nên không có gì tự giữ cho chúng thẳng hàng — phải chốt bằng test. + +Lần lệch gần nhất: ``srow.setSpacing(_NAV_ROW_GAP + 4)`` (=10) cộng với 10px lề +trái và hộp icon 22px đặt chữ "Cài đặt" ở x=42, trong khi delegate của cây đặt +chữ ở x=35 — thụt phải 7px, thấy rõ bằng mắt trên thanh rail. + +Cách đo: render thanh rail ra ảnh rồi tìm cột mực đầu tiên, vì đó đúng là thứ +người dùng nhìn thấy. Trước khi đo, ba hàng được ép về **cùng một icon và cùng +một chữ** — chữ khác nhau thì phần nhô trái của glyph đầu tiên ("D" so với "G" +so với "C") đã lệch nhau vài pixel, và bài test sẽ đo hình dáng chữ chứ không +đo bố cục. +""" +from __future__ import annotations + +import os + +import pytest + +pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật") + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +#: Bỏ qua cột mực nằm sát mép trái: hàng đang được chọn có thêm vạch +#: ``border-left: 2px solid $accent`` (theme/qss.py), không phải icon. +_BO_QUA_MEP_TRAI = 5 + + +@pytest.fixture(scope="module") +def window(qapp, tmp_path_factory): + """Cửa sổ thật, có nạp stylesheet đúng như ``app.py`` làm. + + Không nạp thì ``QTreeWidget::item { padding: 6px 10px }`` không áp, hàng của + cây thụt về 0 còn nút Cài đặt vẫn giữ lề 10px của layout — bài test sẽ đỏ vì + thiếu theme chứ không vì lỗi bố cục. + """ + from cowork_local.presentation.shell.bootstrap import build_config, build_context + from cowork_local.presentation.shell.main_window import MainWindow + from cowork_local.theme import set_active_theme, stylesheet + + config_path = tmp_path_factory.mktemp("cfg") / "config.json" + build_config(config_path) + ctx = build_context(config_path) + css_cu = qapp.styleSheet() + set_active_theme(ctx.config.theme) + qapp.setStyleSheet(stylesheet(ctx.config.theme)) + win = MainWindow(ctx) + win.resize(1280, 800) + win.show() + for _ in range(3): + qapp.processEvents() + yield win + win.close() + qapp.setStyleSheet(css_cu) + + +def _cum_muc(img, y0: int, y1: int): + """Các cụm cột có mực trong dải ``[y0, y1)``, dạng ``[(x_đầu, x_cuối), ...]``. + + Màu nền lấy ở cột sát mép phải cùng dòng y, nên hàng đang được tô nền chọn + vẫn so sánh đúng. + """ + w = img.width() + co_muc = [any(img.pixel(x, y) != img.pixel(w - 3, y) for y in range(y0, y1)) + for x in range(w)] + cum, dau = [], None + for x, c in enumerate(co_muc): + if c and dau is None: + dau = x + elif not c and dau is not None: + if x - dau >= 2: + cum.append((dau, x - 1)) + dau = None + if dau is not None: + cum.append((dau, w - 1)) + return [c for c in cum if c[0] >= _BO_QUA_MEP_TRAI] + + +def _ep_ba_hang_ve_cung_hinh(window): + """Cho ba hàng cùng icon và cùng chữ, để chỉ còn bố cục là khác biệt.""" + from cowork_local.ui.icons import icon as _icon + + for i in range(2): + it = window.nav_bottom.topLevelItem(i) + it.setIcon(0, _icon("gear")) + it.setText(0, "M") + window._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16)) + window._nav_settings_text.setText("M") + + +def _vi_tri_ba_hang(qapp, window): + """``{tên hàng: (x_icon, x_chữ)}`` đo từ ảnh render của thanh rail.""" + from PySide6.QtCore import QPoint + + _ep_ba_hang_ve_cung_hinh(window) + for _ in range(3): + qapp.processEvents() + ref = window._nav_wrap + img = ref.grab().toImage() + + ket = {} + for i, ten in ((0, "Dashboard"), (1, "Giám sát")): + it = window.nav_bottom.topLevelItem(i) + r = window.nav_bottom.visualItemRect(it) + y = window.nav_bottom.viewport().mapTo(ref, QPoint(0, r.y())).y() + cum = _cum_muc(img, y + 4, y + r.height() - 4) + assert len(cum) >= 2, f"{ten}: không tìm thấy đủ icon và chữ để đo" + ket[ten] = (cum[0][0], cum[1][0]) + + btn = window._nav_settings_btn + y = btn.mapTo(ref, QPoint(0, 0)).y() + cum = _cum_muc(img, y + 4, y + btn.height() - 4) + assert len(cum) >= 2, "Cài đặt: không tìm thấy đủ icon và chữ để đo" + ket["Cài đặt"] = (cum[0][0], cum[1][0]) + return ket + + +def test_icon_ba_hang_thang_hang(qapp, window): + """Icon của ba hàng phải bắt đầu ở cùng một cột.""" + vi_tri = _vi_tri_ba_hang(qapp, window) + x = {ten: v[0] for ten, v in vi_tri.items()} + assert len(set(x.values())) == 1, f"icon lệch nhau: {x}" + + +def test_chu_ba_hang_thang_hang(qapp, window): + """Chữ của ba hàng phải bắt đầu ở cùng một cột. + + Đây là bài đỏ trước khi sửa: Cài đặt ở 42, hai hàng kia ở 35. + """ + vi_tri = _vi_tri_ba_hang(qapp, window) + x = {ten: v[1] for ten, v in vi_tri.items()} + assert len(set(x.values())) == 1, f"chữ lệch nhau: {x}"