diff --git a/theme.py b/theme.py index e4441c8..40808fe 100644 --- a/theme.py +++ b/theme.py @@ -471,11 +471,16 @@ QComboBox#navProjectPick { } QPushButton#navSettingsBtn { background: transparent; border: none; color: $text_muted; - padding: 6px 8px; text-align: left; border-radius: ${radius}px; margin: 2px 6px 6px 6px; + padding: 6px 8px 6px 7px; text-align: left; border-radius: ${radius}px; + /* 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. */ + margin: 2px 0px 6px 0px; } QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; } QPushButton#navSettingsBtn:pressed { background: $active; } + /* ---- surfaces --------------------------------------------------------- */ QGroupBox { background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px; diff --git a/tools/capture_screens.py b/tools/capture_screens.py index 03e3fd0..b7c2a53 100644 --- a/tools/capture_screens.py +++ b/tools/capture_screens.py @@ -75,6 +75,22 @@ def _load_fonts() -> int: return loaded +def _apply_theme(app, name: str | None = None) -> str: + """Load the app's real stylesheet onto `app`. + + `MainWindow` does not style itself — `run()` calls `app.setStyleSheet` — so a + checker that builds the window directly measures a window with no padding, + no margins and no borders. Every QSS-driven layout bug is invisible there. + """ + from cowork_local import theme + from cowork_local.config import AppConfig + + name = name or AppConfig.load().theme + theme.set_active_theme(name) + app.setStyleSheet(theme.stylesheet(name)) + return name + + def _freeze_schedulers() -> None: """No-op the background engines so nothing is executed while we capture.""" from cowork_local.core.task_scheduler import TaskScheduler diff --git a/tools/check_co4e.py b/tools/check_co4e.py index 4864bce..ac3a87b 100644 --- a/tools/check_co4e.py +++ b/tools/check_co4e.py @@ -12,6 +12,8 @@ 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 @@ -19,7 +21,7 @@ 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 _isolate_home, _load_fonts # noqa: E402 +from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402 # Every control the sidebar and the flow area had before these changes. EXPECTED = [ @@ -40,6 +42,7 @@ def main() -> int: app = QApplication([]) _load_fonts() + _apply_theme(app) # measure the styled widget, not a bare one from cowork_local.config import AppConfig, CONFIG_DIR assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" diff --git a/tools/check_controls_alive.py b/tools/check_controls_alive.py index 6f4cb81..c961320 100644 --- a/tools/check_controls_alive.py +++ b/tools/check_controls_alive.py @@ -16,6 +16,8 @@ from __future__ import annotations import json import os import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") from pathlib import Path REPO = Path(__file__).resolve().parent.parent @@ -23,7 +25,7 @@ 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 _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 +from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 # controls.json lists every control in a FILE, and several files hold more than # one class (schedule_task_tab.py alone has the tab plus three dialogs). Only @@ -125,6 +127,7 @@ def main() -> int: _load_fonts() _freeze_schedulers() + _apply_theme(app) # measure the styled window, not a bare one from cowork_local.config import AppConfig, CONFIG_DIR assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" diff --git a/tools/check_dashboard.py b/tools/check_dashboard.py index 94230d6..0c856a2 100644 --- a/tools/check_dashboard.py +++ b/tools/check_dashboard.py @@ -10,6 +10,8 @@ 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 @@ -17,7 +19,7 @@ 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 _isolate_home, _load_fonts # noqa: E402 +from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402 HEADER = ["_title", "chart_prev_btn", "_chart_period_lbl", "chart_next_btn", "gran_combo", "metric_combo", "currency_lbl", "currency_combo", @@ -31,6 +33,7 @@ def main() -> int: app = QApplication([]) _load_fonts() + _apply_theme(app) # measure the styled widget, not a bare one from cowork_local.config import AppConfig, CONFIG_DIR assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" diff --git a/tools/check_design_parity.py b/tools/check_design_parity.py index d97419b..37ef0d8 100644 --- a/tools/check_design_parity.py +++ b/tools/check_design_parity.py @@ -1,363 +1,366 @@ -"""Compare the running app against every proposal on the audit page. - -The checklist is not written here — it is read from build_audit_page.ANALYSIS, -so a proposal cannot be quietly dropped from the audit and from this check at -the same time. Each item has a probe against a real MainWindow built offscreen. - -Verdicts: - OK the probe passes - CHUA not implemented - KHAC implemented differently on purpose (reason printed) - TAY cannot be probed mechanically — inspect by eye - -Run: python tools/check_design_parity.py -""" -from __future__ import annotations - -import os -import sys -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 _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 - - -def page_proposals(): - """The 'Thay đổi' bullets as they appear ON THE PAGE, per section. - - Read from docs/ui-audit.html rather than build_audit_page.ANALYSIS: eight - sections are hand-written, and for those two the generator's text is NOT - what the page shows. Checking against ANALYSIS reported Settings and the - Task editor as matching the design when the page asked for something else - (and, for the Task editor, the opposite). - """ - import re - html = (REPO / "docs" / "ui-audit.html").read_text(encoding="utf-8") - html = re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", html) - out: dict[str, list[str]] = {} - for m in re.finditer(r'
(.*?)
', html, re.S): - block = re.search(r'Thay đổi', m.group(2), re.S) - if not block: - continue - out[m.group(1)] = [ - re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", li)).strip() - for li in re.findall(r"
  • (.*?)
  • ", block.group(1), re.S)] - return out - - -def build(): - sandbox = _isolate_home() - from PySide6.QtWidgets import QApplication - - app = QApplication.instance() or QApplication([]) - _load_fonts() - _freeze_schedulers() - - from cowork_local.config import AppConfig, 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.i18n import set_language - from cowork_local.state import AppContext - - set_language("vi") - win = MainWindow(AppContext(AppConfig.load()), user_name="local") - win.resize(1600, 900) - win.show() - for _ in range(8): - app.processEvents() - return app, win - - -def main() -> int: - app, win = build() - ws = win.workspace - - def goto(sub): - win._goto(win._ROW_WORKSPACE, sub) - for _ in range(6): - app.processEvents() - - def page(row): - win._goto(row, None) - for _ in range(6): - app.processEvents() - return win._page_widgets[row] - - import cowork_local.ui.co4e_tab as co4e_mod - co4e = win.findChildren(co4e_mod.Co4ETab)[0] - dash = page(win._ROW_DASHBOARD) - mon = page(win._ROW_MONITORING) - sched = page(win._ROW_SCHEDULE) - goto(ws._cowork_tab_idx) - chat = ws._cowork - dock = win.help_agent - - def rows_of(widget, names): - """How many distinct y-bands the named widgets occupy.""" - bands = set() - for n in names: - w = getattr(widget, n, None) - if w is not None: - bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12)) - return len(bands) - - # (slug, proposal, verdict, evidence) - R: list[tuple[str, str, str, str]] = [] - - def add(slug, text, ok, ev, other=None): - R.append((slug, text, other or ("OK" if ok else "CHUA"), ev)) - - # --- 1 Dashboard --- - n_rows = rows_of(dash, ["_title", "chart_prev_btn", "gran_combo", "refresh_btn", - "currency_combo"]) - add("dashboard", "Header tách 2 hàng", n_rows == 2, f"{n_rows} hàng") - # Taller than the small tiles AND a bigger number = it reads as the headline. - taller = dash.card_cost.height() > dash.card_total.height() * 1.5 - bigger = "34px" in dash.card_cost.value_lbl.styleSheet() - add("dashboard", "Chi phí làm thẻ chính", taller and bigger, - f"cao {dash.card_cost.height()}px vs thẻ phụ {dash.card_total.height()}px · " - f"cỡ số {'34px' if bigger else 'như cũ'}") - - # --- 2 Schedule Kanban --- - lanes = len(getattr(sched, "_lane_widgets", getattr(sched, "lanes", []) or [])) - if not lanes: - from cowork_local.core.tasks import STATUSES - lanes = len(STATUSES) - add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane") - has_combo = getattr(sched, "view_combo", None) is not None - add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo, - "vẫn là combo" if has_combo else "đã thành tab") - # The lane is only outlined while it actually holds something — seed data - # may leave it empty, so drop a card in and read the style back. - run_col = sched.columns.get("running") - styled = "" - if run_col is not None: - from PySide6.QtWidgets import QListWidgetItem - run_col.addItem(QListWidgetItem("probe")) - sched.column_headers["running"].setStyleSheet("") - sched.refresh() - app.processEvents() - styled = run_col.styleSheet() - add("schedule-kanban", "Lane Running có viền cảnh báo", "border" in styled, - styled or "không có viền") - - # --- 4/5 Workspace --- - add("workspace-project", "History lên sidebar thành RECENTS", - win.nav_recents.topLevelItemCount() > 0, - f"{win.nav_recents.topLevelItemCount()} dòng trên rail") - add("workspace-project", "Thanh chọn project dùng chung mọi màn", - win.nav_project.count() > 0, f"{win.nav_project.count()} mục, ở rail") - goto(ws._co4e_tab_idx) - hdr_off = ws._header.isHidden() - goto(ws._project_tab_idx) - hdr_on = not ws._header.isHidden() - add("workspace-project", "Header đổi theo màn", hdr_off and hdr_on, - "chỉ hiện ở màn Project" if (hdr_off and hdr_on) else "vẫn hiện mọi màn") - # The design's own wireframes draw the rail on every screen and a different - # in-page pane per screen, so "the fixed left pane" is the rail — which now - # carries the project picker and RECENTS on all of them. - fixed = (win.nav_project.isVisible() or not win._nav_collapsed) and \ - win.nav_recents.topLevelItemCount() > 0 - add("workspace-project", "Pane trái cố định, không đổi danh tính", fixed, - "rail (project + RECENTS) không đổi theo màn") - add("workspace-cowork", "Bộ chọn project + '+ Đoạn chat mới' cạnh nhau ở đầu sidebar", - win.nav_new_chat is not None and win.nav_project is not None, "cả hai ở đầu rail") - add("workspace-cowork", "History gom theo project + 'Tất cả project…'", - any((win.nav_recents.topLevelItem(i).data(0, 0x0100) or {}).get("all") - for i in range(win.nav_recents.topLevelItemCount())), - "có dòng 'Tất cả project…'") - # The extras are added to the composer by ChatPanel/CoworkTab via - # add_bottom_right/left, so counting attributes on the composer itself said - # "clean" while the row underneath was full. Count the row instead. - # The design keeps agent / routing / usage / folder — it draws them as a - # status line under the typing box, not inside it. So the test is that the - # TYPING row holds only input + attach/send/stop, and the rest sits in its - # own strip below. Demanding an empty strip would mean deleting features. - composer = getattr(chat, "composer", None) - bar = getattr(composer, "extra_bar", None) - from PySide6.QtWidgets import QPlainTextEdit, QTextEdit - typing = composer.input - in_typing_row = typing.parentWidget() is composer - below = bar is not None and bar.objectName() == "composerStatus" - usage = getattr(chat, "_usage_total_lbl", None) - usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage) - add("workspace-cowork", "Usage/cost thành dải trạng thái; vùng gõ chỉ nhập·đính kèm·gửi", - below and usage_in_bar, - f"dải riêng={below} · usage nằm trong dải={usage_in_bar} · " - f"{bar.layout().count() if bar else 0} mục") - - # --- 6 Co4E --- - add("workspace-co4e", "Bỏ dải tab flow", - not (co4e.flow_scroll.isVisible() or co4e.flow_add_btn.isVisible()), "đã ẩn") - add("workspace-co4e", "3 tab icon → 3 mục có nhãn cùng danh sách", - len(co4e._sections) >= 3, f"{len(co4e._sections)} mục xếp chồng") - add("workspace-co4e", "Còn 2 lớp: chọn trái → sửa phải", - not co4e.flow_scroll.isVisible(), "nav → cột trái → panel phải") - - # --- 7 Folder / 8 GraphRAG --- - folder = ws.tabs.widget(ws._folder_tab_idx) - title_lbl = getattr(folder, "path_lbl", None) - add("workspace-folder", "Path bar gộp vào tiêu đề", - title_lbl is not None and getattr(folder, "path_edit", None) is None, - f"tiêu đề = {title_lbl.text()[:40]!r}" if title_lbl is not None else "vẫn là ô nhập") - # "Thin bar at the bottom" = the terminal is the last thing in the column - # and starts collapsed; the AI panel is a hideable right-hand pane. - # Geometry is meaningless for a page that has never been shown, so ask the - # widgets what state they are in instead of how tall they currently are. - term = getattr(folder, "terminal", None) - lay = folder.layout() - last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None - collapsed = term is not None and term._body.isHidden() - at_bottom = term is not None and last is term - add("workspace-folder", "Terminal thanh mỏng đáy; panel AI ẩn được", - collapsed and at_bottom, - f"gập sẵn={collapsed} · nằm cuối cột={at_bottom}") - graph = ws.tabs.widget(ws._graphrag_tab_idx) - # One row = the path box and Export share a y-band. - def band(w): - return round(w.mapTo(graph, w.rect().topLeft()).y() / 10) - one_row = band(graph.path_edit) == band(graph._export_btn) - add("workspace-graphrag", "Gộp hai hàng toolbar thành một", one_row, - f"path y≈{band(graph.path_edit) * 10} · Export y≈{band(graph._export_btn) * 10}") - # The toggle is _msgs_toggle_btn (the audit's MOVES table calls it - # _msg_btn — a stale name); while it exists, this is still one button whose - # label flips, not a pair of tabs. - toggle = getattr(graph, "_msgs_toggle_btn", None) - add("workspace-graphrag", "Messages/Graph thành cặp tab", toggle is None, - "vẫn là 1 nút đổi nhãn" if toggle is not None else "đã thành tab") - - # --- 9/15 Monitoring --- - from PySide6.QtWidgets import QHBoxLayout, QScrollArea - from PySide6.QtWidgets import QSpinBox as QSpinBoxT - ov = mon.findChildren(QScrollArea)[0].widget() - one_col = not isinstance(ov.layout(), QHBoxLayout) - add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col, - "cột dọc" if one_col else "vẫn 2 cột") - # Its own section = it is a direct child of the single column, not sharing a - # row with the resource meters as it used to. - own = ov.layout().indexOf(mon.ov_pricing_group) >= 0 - add("monitoring-tổng-quan", "Bảng giá model tách mục riêng", own, - f"là mục riêng trong cột, rộng {mon.ov_pricing_group.width()}px") - strip = not mon.tabs.tabBar().isHidden() - add("monitoring-công-cụ", "Bỏ ẩn dải tab Monitoring → 8 tab một hàng", - strip and mon.tabs.count() == 8, f"dải tab hiện={strip}, {mon.tabs.count()} tab") - - # --- 17/18 dialogs --- - from cowork_local.ui.settings_dialog import SettingsDialog - from cowork_local.ui.task_editor_dialog import TaskEditorDialog - s = SettingsDialog(win.ctx) - add("dialog-settings", "Thêm cột mục lục bên trái", - s.section_list.count() == 5, f"{s.section_list.count()} mục") - # The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider - # left as its own group — not everything merged together. - from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch - in_general = s._general_box.isAncestorOf(s.language_combo) and \ - s._general_box.isAncestorOf(s.theme_combo) - prov_apart = not s._general_box.isAncestorOf(s.provider_combo) - add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng", - in_general and prov_apart, - f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}") - n_switch = len(s.findChildren(ToggleSwitch)) - n_seg = len(s.findChildren(SegmentedControl)) - steppers = [w for w in s.findChildren(QSpinBoxT) if w.buttonSymbols() != 2] - add("dialog-settings", - "Field theo đúng loại: checkbox→toggle, dropdown 2–4 giá trị→segmented, số→stepper", - n_switch >= 6 and n_seg >= 2 and len(steppers) >= 1, - f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper") - s.close() - t = TaskEditorDialog(ctx=win.ctx) - rows = [t.section_list.item(i).text() for i in range(t.section_list.count())] - like_settings = (t.section_list.count() == 5 - and t.section_stack.count() == 5 - and not hasattr(t, "step_tabs")) - add("dialog-task-editor", - "Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group", - like_settings, " · ".join(rows)) - t.close() - - # --- 27 help dock --- - add("overlay-help-panel", "Một chấm 26px, không chữ", - dock.width() <= 30 and not dock.launcher.text().strip(), f"{dock.width()}px") - from cowork_local.i18n import tr - dock.launcher._set_open(True) - app.processEvents() - add("overlay-help-panel", "Rê chuột mới hiện chữ 'AI Assistant'", - tr("help_agent.badge") in dock.launcher.text(), dock.launcher.text().strip()) - dock.launcher._set_open(False) - items = [a.text() for a in dock.more_btn.menu().actions()] - add("overlay-help-panel", "'Ẩn trợ lý' dời vào menu ⋯", - tr("help_agent.hide_tooltip") in items, str(items)) - add("overlay-help-panel", "2 trạng thái thường ngày", True, "đóng ↔ mở, ẩn hẳn là tuỳ chọn") - dock._hide_to_edge() - app.processEvents() - add("overlay-help-panel", "Tab mép 16px → 28px", dock.width() >= 28, f"{dock.width()}px") - dock._show_launcher() - app.processEvents() - goto(ws._cowork_tab_idx) - comp = chat.composer - dock_top = dock.mapTo(win, dock.rect().topLeft()).y() - comp_top = comp.mapTo(win, comp.rect().topLeft()).y() - add("overlay-help-panel", "Ở Cowork chấm nâng lên trên hàng nhập", - dock_top + dock.height() <= comp_top, - f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}") - - # --- coverage: is every bullet ON THE PAGE actually probed? ------------- - proposals = page_proposals() - probed = {} - for slug, text, _v, _e in R: - probed.setdefault(slug, 0) - probed[slug] += 1 - gaps = [] - for slug, bullets in proposals.items(): - n_probe = probed.get(slug, 0) - if len(bullets) > n_probe: - for extra in bullets[n_probe:]: - gaps.append((slug, extra)) - - # --- report --- - order = ["OK", "KHAC", "CHUA", "TAY"] - counts = {k: 0 for k in order} - cur = None - for slug, text, verdict, ev in R: - counts[verdict] = counts.get(verdict, 0) + 1 - if slug != cur: - print(f"\n{slug}") - cur = slug - print(f" [{verdict:4}] {text}") - print(f" {ev}") - if gaps: - print() - print(f"*** DE XUAT TREN TRANG CHUA CO PHEP DO: {len(gaps)} ***") - for slug, text in gaps: - print(f" {slug}") - print(f" {text[:160]}") - print() - print(f"de xuat tren trang : {sum(len(v) for v in proposals.values())}" - f" · da co phep do : {len(R)}") - print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order)) - print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})") - print(f" KHAC = co y lam khac, da ghi ly do") - print(f" CHUA = chua lam") - # This used to return 0 unconditionally — a report, not a check. Every probe - # in it was therefore unable to fail, so a regression would have been shown - # on screen and still exited green for any script that only reads the code. - return 1 if counts.get("CHUA") else 0 - - -if __name__ == "__main__": - _rc = main() - # Qt (WebEngine especially) crashes during interpreter teardown with - # 0xC0000409 AFTER the work is done, which would mask the real result — - # and check_probes_bite reads these exit codes to decide whether a probe - # caught its mutation. Leave immediately with the verdict instead. - sys.stdout.flush() - sys.stderr.flush() - os._exit(_rc) +"""Compare the running app against every proposal on the audit page. + +The checklist is not written here — it is read from build_audit_page.ANALYSIS, +so a proposal cannot be quietly dropped from the audit and from this check at +the same time. Each item has a probe against a real MainWindow built offscreen. + +Verdicts: + OK the probe passes + CHUA not implemented + KHAC implemented differently on purpose (reason printed) + TAY cannot be probed mechanically — inspect by eye + +Run: python tools/check_design_parity.py +""" +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 _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 + + +def page_proposals(): + """The 'Thay đổi' bullets as they appear ON THE PAGE, per section. + + Read from docs/ui-audit.html rather than build_audit_page.ANALYSIS: eight + sections are hand-written, and for those two the generator's text is NOT + what the page shows. Checking against ANALYSIS reported Settings and the + Task editor as matching the design when the page asked for something else + (and, for the Task editor, the opposite). + """ + import re + html = (REPO / "docs" / "ui-audit.html").read_text(encoding="utf-8") + html = re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", html) + out: dict[str, list[str]] = {} + for m in re.finditer(r'
    (.*?)
    ', html, re.S): + block = re.search(r'Thay đổi', m.group(2), re.S) + if not block: + continue + out[m.group(1)] = [ + re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", li)).strip() + for li in re.findall(r"
  • (.*?)
  • ", block.group(1), re.S)] + return out + + +def build(): + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() or QApplication([]) + _load_fonts() + _freeze_schedulers() + + _apply_theme(app) # measure the styled window, not a bare one + from cowork_local.config import AppConfig, 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.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1600, 900) + win.show() + for _ in range(8): + app.processEvents() + return app, win + + +def main() -> int: + app, win = build() + ws = win.workspace + + def goto(sub): + win._goto(win._ROW_WORKSPACE, sub) + for _ in range(6): + app.processEvents() + + def page(row): + win._goto(row, None) + for _ in range(6): + app.processEvents() + return win._page_widgets[row] + + import cowork_local.ui.co4e_tab as co4e_mod + co4e = win.findChildren(co4e_mod.Co4ETab)[0] + dash = page(win._ROW_DASHBOARD) + mon = page(win._ROW_MONITORING) + sched = page(win._ROW_SCHEDULE) + goto(ws._cowork_tab_idx) + chat = ws._cowork + dock = win.help_agent + + def rows_of(widget, names): + """How many distinct y-bands the named widgets occupy.""" + bands = set() + for n in names: + w = getattr(widget, n, None) + if w is not None: + bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12)) + return len(bands) + + # (slug, proposal, verdict, evidence) + R: list[tuple[str, str, str, str]] = [] + + def add(slug, text, ok, ev, other=None): + R.append((slug, text, other or ("OK" if ok else "CHUA"), ev)) + + # --- 1 Dashboard --- + n_rows = rows_of(dash, ["_title", "chart_prev_btn", "gran_combo", "refresh_btn", + "currency_combo"]) + add("dashboard", "Header tách 2 hàng", n_rows == 2, f"{n_rows} hàng") + # Taller than the small tiles AND a bigger number = it reads as the headline. + taller = dash.card_cost.height() > dash.card_total.height() * 1.5 + bigger = "34px" in dash.card_cost.value_lbl.styleSheet() + add("dashboard", "Chi phí làm thẻ chính", taller and bigger, + f"cao {dash.card_cost.height()}px vs thẻ phụ {dash.card_total.height()}px · " + f"cỡ số {'34px' if bigger else 'như cũ'}") + + # --- 2 Schedule Kanban --- + lanes = len(getattr(sched, "_lane_widgets", getattr(sched, "lanes", []) or [])) + if not lanes: + from cowork_local.core.tasks import STATUSES + lanes = len(STATUSES) + add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane") + has_combo = getattr(sched, "view_combo", None) is not None + add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo, + "vẫn là combo" if has_combo else "đã thành tab") + # The lane is only outlined while it actually holds something — seed data + # may leave it empty, so drop a card in and read the style back. + run_col = sched.columns.get("running") + styled = "" + if run_col is not None: + from PySide6.QtWidgets import QListWidgetItem + run_col.addItem(QListWidgetItem("probe")) + sched.column_headers["running"].setStyleSheet("") + sched.refresh() + app.processEvents() + styled = run_col.styleSheet() + add("schedule-kanban", "Lane Running có viền cảnh báo", "border" in styled, + styled or "không có viền") + + # --- 4/5 Workspace --- + add("workspace-project", "History lên sidebar thành RECENTS", + win.nav_recents.topLevelItemCount() > 0, + f"{win.nav_recents.topLevelItemCount()} dòng trên rail") + add("workspace-project", "Thanh chọn project dùng chung mọi màn", + win.nav_project.count() > 0, f"{win.nav_project.count()} mục, ở rail") + goto(ws._co4e_tab_idx) + hdr_off = ws._header.isHidden() + goto(ws._project_tab_idx) + hdr_on = not ws._header.isHidden() + add("workspace-project", "Header đổi theo màn", hdr_off and hdr_on, + "chỉ hiện ở màn Project" if (hdr_off and hdr_on) else "vẫn hiện mọi màn") + # The design's own wireframes draw the rail on every screen and a different + # in-page pane per screen, so "the fixed left pane" is the rail — which now + # carries the project picker and RECENTS on all of them. + fixed = (win.nav_project.isVisible() or not win._nav_collapsed) and \ + win.nav_recents.topLevelItemCount() > 0 + add("workspace-project", "Pane trái cố định, không đổi danh tính", fixed, + "rail (project + RECENTS) không đổi theo màn") + add("workspace-cowork", "Bộ chọn project + '+ Đoạn chat mới' cạnh nhau ở đầu sidebar", + win.nav_new_chat is not None and win.nav_project is not None, "cả hai ở đầu rail") + add("workspace-cowork", "History gom theo project + 'Tất cả project…'", + any((win.nav_recents.topLevelItem(i).data(0, 0x0100) or {}).get("all") + for i in range(win.nav_recents.topLevelItemCount())), + "có dòng 'Tất cả project…'") + # The extras are added to the composer by ChatPanel/CoworkTab via + # add_bottom_right/left, so counting attributes on the composer itself said + # "clean" while the row underneath was full. Count the row instead. + # The design keeps agent / routing / usage / folder — it draws them as a + # status line under the typing box, not inside it. So the test is that the + # TYPING row holds only input + attach/send/stop, and the rest sits in its + # own strip below. Demanding an empty strip would mean deleting features. + composer = getattr(chat, "composer", None) + bar = getattr(composer, "extra_bar", None) + from PySide6.QtWidgets import QPlainTextEdit, QTextEdit + typing = composer.input + in_typing_row = typing.parentWidget() is composer + below = bar is not None and bar.objectName() == "composerStatus" + usage = getattr(chat, "_usage_total_lbl", None) + usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage) + add("workspace-cowork", "Usage/cost thành dải trạng thái; vùng gõ chỉ nhập·đính kèm·gửi", + below and usage_in_bar, + f"dải riêng={below} · usage nằm trong dải={usage_in_bar} · " + f"{bar.layout().count() if bar else 0} mục") + + # --- 6 Co4E --- + add("workspace-co4e", "Bỏ dải tab flow", + not (co4e.flow_scroll.isVisible() or co4e.flow_add_btn.isVisible()), "đã ẩn") + add("workspace-co4e", "3 tab icon → 3 mục có nhãn cùng danh sách", + len(co4e._sections) >= 3, f"{len(co4e._sections)} mục xếp chồng") + add("workspace-co4e", "Còn 2 lớp: chọn trái → sửa phải", + not co4e.flow_scroll.isVisible(), "nav → cột trái → panel phải") + + # --- 7 Folder / 8 GraphRAG --- + folder = ws.tabs.widget(ws._folder_tab_idx) + title_lbl = getattr(folder, "path_lbl", None) + add("workspace-folder", "Path bar gộp vào tiêu đề", + title_lbl is not None and getattr(folder, "path_edit", None) is None, + f"tiêu đề = {title_lbl.text()[:40]!r}" if title_lbl is not None else "vẫn là ô nhập") + # "Thin bar at the bottom" = the terminal is the last thing in the column + # and starts collapsed; the AI panel is a hideable right-hand pane. + # Geometry is meaningless for a page that has never been shown, so ask the + # widgets what state they are in instead of how tall they currently are. + term = getattr(folder, "terminal", None) + lay = folder.layout() + last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None + collapsed = term is not None and term._body.isHidden() + at_bottom = term is not None and last is term + add("workspace-folder", "Terminal thanh mỏng đáy; panel AI ẩn được", + collapsed and at_bottom, + f"gập sẵn={collapsed} · nằm cuối cột={at_bottom}") + graph = ws.tabs.widget(ws._graphrag_tab_idx) + # One row = the path box and Export share a y-band. + def band(w): + return round(w.mapTo(graph, w.rect().topLeft()).y() / 10) + one_row = band(graph.path_edit) == band(graph._export_btn) + add("workspace-graphrag", "Gộp hai hàng toolbar thành một", one_row, + f"path y≈{band(graph.path_edit) * 10} · Export y≈{band(graph._export_btn) * 10}") + # The toggle is _msgs_toggle_btn (the audit's MOVES table calls it + # _msg_btn — a stale name); while it exists, this is still one button whose + # label flips, not a pair of tabs. + toggle = getattr(graph, "_msgs_toggle_btn", None) + add("workspace-graphrag", "Messages/Graph thành cặp tab", toggle is None, + "vẫn là 1 nút đổi nhãn" if toggle is not None else "đã thành tab") + + # --- 9/15 Monitoring --- + from PySide6.QtWidgets import QHBoxLayout, QScrollArea + from PySide6.QtWidgets import QSpinBox as QSpinBoxT + ov = mon.findChildren(QScrollArea)[0].widget() + one_col = not isinstance(ov.layout(), QHBoxLayout) + add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col, + "cột dọc" if one_col else "vẫn 2 cột") + # Its own section = it is a direct child of the single column, not sharing a + # row with the resource meters as it used to. + own = ov.layout().indexOf(mon.ov_pricing_group) >= 0 + add("monitoring-tổng-quan", "Bảng giá model tách mục riêng", own, + f"là mục riêng trong cột, rộng {mon.ov_pricing_group.width()}px") + strip = not mon.tabs.tabBar().isHidden() + add("monitoring-công-cụ", "Bỏ ẩn dải tab Monitoring → 8 tab một hàng", + strip and mon.tabs.count() == 8, f"dải tab hiện={strip}, {mon.tabs.count()} tab") + + # --- 17/18 dialogs --- + from cowork_local.ui.settings_dialog import SettingsDialog + from cowork_local.ui.task_editor_dialog import TaskEditorDialog + s = SettingsDialog(win.ctx) + add("dialog-settings", "Thêm cột mục lục bên trái", + s.section_list.count() == 5, f"{s.section_list.count()} mục") + # The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider + # left as its own group — not everything merged together. + from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch + in_general = s._general_box.isAncestorOf(s.language_combo) and \ + s._general_box.isAncestorOf(s.theme_combo) + prov_apart = not s._general_box.isAncestorOf(s.provider_combo) + add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng", + in_general and prov_apart, + f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}") + n_switch = len(s.findChildren(ToggleSwitch)) + n_seg = len(s.findChildren(SegmentedControl)) + steppers = [w for w in s.findChildren(QSpinBoxT) if w.buttonSymbols() != 2] + add("dialog-settings", + "Field theo đúng loại: checkbox→toggle, dropdown 2–4 giá trị→segmented, số→stepper", + n_switch >= 6 and n_seg >= 2 and len(steppers) >= 1, + f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper") + s.close() + t = TaskEditorDialog(ctx=win.ctx) + rows = [t.section_list.item(i).text() for i in range(t.section_list.count())] + like_settings = (t.section_list.count() == 5 + and t.section_stack.count() == 5 + and not hasattr(t, "step_tabs")) + add("dialog-task-editor", + "Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group", + like_settings, " · ".join(rows)) + t.close() + + # --- 27 help dock --- + add("overlay-help-panel", "Một chấm 26px, không chữ", + dock.width() <= 30 and not dock.launcher.text().strip(), f"{dock.width()}px") + from cowork_local.i18n import tr + dock.launcher._set_open(True) + app.processEvents() + add("overlay-help-panel", "Rê chuột mới hiện chữ 'AI Assistant'", + tr("help_agent.badge") in dock.launcher.text(), dock.launcher.text().strip()) + dock.launcher._set_open(False) + items = [a.text() for a in dock.more_btn.menu().actions()] + add("overlay-help-panel", "'Ẩn trợ lý' dời vào menu ⋯", + tr("help_agent.hide_tooltip") in items, str(items)) + add("overlay-help-panel", "2 trạng thái thường ngày", True, "đóng ↔ mở, ẩn hẳn là tuỳ chọn") + dock._hide_to_edge() + app.processEvents() + add("overlay-help-panel", "Tab mép 16px → 28px", dock.width() >= 28, f"{dock.width()}px") + dock._show_launcher() + app.processEvents() + goto(ws._cowork_tab_idx) + comp = chat.composer + dock_top = dock.mapTo(win, dock.rect().topLeft()).y() + comp_top = comp.mapTo(win, comp.rect().topLeft()).y() + add("overlay-help-panel", "Ở Cowork chấm nâng lên trên hàng nhập", + dock_top + dock.height() <= comp_top, + f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}") + + # --- coverage: is every bullet ON THE PAGE actually probed? ------------- + proposals = page_proposals() + probed = {} + for slug, text, _v, _e in R: + probed.setdefault(slug, 0) + probed[slug] += 1 + gaps = [] + for slug, bullets in proposals.items(): + n_probe = probed.get(slug, 0) + if len(bullets) > n_probe: + for extra in bullets[n_probe:]: + gaps.append((slug, extra)) + + # --- report --- + order = ["OK", "KHAC", "CHUA", "TAY"] + counts = {k: 0 for k in order} + cur = None + for slug, text, verdict, ev in R: + counts[verdict] = counts.get(verdict, 0) + 1 + if slug != cur: + print(f"\n{slug}") + cur = slug + print(f" [{verdict:4}] {text}") + print(f" {ev}") + if gaps: + print() + print(f"*** DE XUAT TREN TRANG CHUA CO PHEP DO: {len(gaps)} ***") + for slug, text in gaps: + print(f" {slug}") + print(f" {text[:160]}") + print() + print(f"de xuat tren trang : {sum(len(v) for v in proposals.values())}" + f" · da co phep do : {len(R)}") + print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order)) + print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})") + print(f" KHAC = co y lam khac, da ghi ly do") + print(f" CHUA = chua lam") + # This used to return 0 unconditionally — a report, not a check. Every probe + # in it was therefore unable to fail, so a regression would have been shown + # on screen and still exited green for any script that only reads the code. + return 1 if counts.get("CHUA") else 0 + + +if __name__ == "__main__": + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_dialogs.py b/tools/check_dialogs.py index 72be70f..47ad800 100644 --- a/tools/check_dialogs.py +++ b/tools/check_dialogs.py @@ -11,6 +11,8 @@ 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 @@ -18,7 +20,7 @@ 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 _isolate_home, _load_fonts # noqa: E402 +from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402 # Every field each dialog must still offer after the move. SETTINGS_FIELDS = [ @@ -78,6 +80,7 @@ def main() -> int: app = QApplication([]) _load_fonts() + _apply_theme(app) # measure the styled widget, not a bare one from cowork_local.config import AppConfig, CONFIG_DIR assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" diff --git a/tools/check_help_dock.py b/tools/check_help_dock.py index d3da0d8..150b881 100644 --- a/tools/check_help_dock.py +++ b/tools/check_help_dock.py @@ -11,6 +11,8 @@ 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 @@ -18,7 +20,7 @@ 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 _isolate_home, _load_fonts # noqa: E402 +from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402 def main() -> int: @@ -28,6 +30,7 @@ def main() -> int: app = QApplication([]) _load_fonts() + _apply_theme(app) # measure the styled widget, not a bare one from cowork_local.config import AppConfig, CONFIG_DIR assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" diff --git a/tools/check_help_i18n.py b/tools/check_help_i18n.py new file mode 100644 index 0000000..adc9f4e --- /dev/null +++ b/tools/check_help_i18n.py @@ -0,0 +1,80 @@ +"""The help panel must follow a language switch — transcript included. + +retranslate() re-labelled the chrome but not the rendered HTML transcript, so +the greeting and the "AI Assistant" speaker label stayed in the language the +panel happened to be built in. +""" +from __future__ import annotations + +import os +import re +import sys +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) + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + sys.stdout.reconfigure(encoding="utf-8") # ja/vi text on a cp932 console + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _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() + + panel = win.help_agent + fails = [] + for lang in ("en", "ja", "vi"): + win._set_language(lang) if hasattr(win, "_set_language") else set_language(lang) + if not hasattr(win, "_set_language"): + panel.retranslate() + app.processEvents() + + # The panel greets by the signed-in name, not the placeholder. + want = panel._greeting() + shown = re.sub("<[^>]+>", " ", panel.log.toHtml()) + shown = " ".join(shown.split()) + # compare on the stable half of the sentence, the name is substituted + probe = " ".join(want.split())[:28] + state = "ok" if probe and probe in shown else "MISSING" + print(f"{lang}: title={panel.title.text()!r} greeting={state}") + if state != "ok": + print(f" muon: {probe!r}") + print(f" thay: {shown[:160]!r}") + if state != "ok": + fails.append(f"{lang}: transcript still shows another language") + + print() + print("PASS panel follows the language" if not fails + else "\n".join(f"FAIL {f}" for f in fails)) + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_layout_geometry.py b/tools/check_layout_geometry.py index 22ebe5d..abb9edc 100644 --- a/tools/check_layout_geometry.py +++ b/tools/check_layout_geometry.py @@ -1,203 +1,206 @@ -"""Round 2: does the built layout have the SHAPE the wireframes draw? - -Round 1 asks "does the feature exist". A screen can pass that and still be laid -out wrongly — right widgets, wrong order, wrong side, wrong proportions. This -round measures real geometry against what the audit page's wireframes depict: -reading order of the rail, section order down Monitoring, which side each pane -is on, and the size relationships the design calls out (hero card, 26px dot). - -Run: python tools/check_layout_geometry.py -""" -from __future__ import annotations - -import os -import sys -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 _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 - -# The rail, top to bottom, as the audit page's rail() helper draws it. -RAIL_ORDER = ["Project", "Cowork", "Co4E", "Thư mục", "GraphRAG", "Schedule Task"] -RAIL_BOTTOM = ["Dashboard", "Giám sát"] -# Monitoring ▸ Tổng quan, in the order the wireframe stacks it: what it cost → -# what the machine is doing → what the agent may touch → per-model prices → -# what actually happened. -MON_ORDER = ["ov_usage_group", "ov_resource_group", "ov_sandbox_details_group", - "ov_pricing_group", "ov_activity_group", "ov_audit_group"] - - -def main() -> int: - sandbox = _isolate_home() - from PySide6.QtWidgets import QApplication - - app = QApplication([]) - _load_fonts() - _freeze_schedulers() - - from cowork_local.config import AppConfig, 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.i18n import set_language - from cowork_local.state import AppContext - - set_language("vi") - win = MainWindow(AppContext(AppConfig.load()), user_name="local") - win.resize(1600, 950) - win.show() - for _ in range(8): - app.processEvents() - ws = win.workspace - fails: list[str] = [] - - def top_of(w, ref): - return w.mapTo(ref, w.rect().topLeft()).y() - - def left_of(w, ref): - return w.mapTo(ref, w.rect().topLeft()).x() - - # --- 1. rail: reading order, and the rail is on the LEFT --------------- - rows = [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())] - bottom = [win.nav_bottom.topLevelItem(i).text(0) - for i in range(win.nav_bottom.topLevelItemCount())] - print(f"thanh menu : {rows}") - print(f"nhom day : {bottom}") - if rows != RAIL_ORDER: - fails.append(f"thu tu thanh menu lech: {rows} != {RAIL_ORDER}") - if bottom != RAIL_BOTTOM: - fails.append(f"thu tu nhom day lech: {bottom} != {RAIL_BOTTOM}") - rail_x = left_of(win._nav_wrap, win) - content_x = left_of(win.pages, win) - print(f"rail x={rail_x} · noi dung x={content_x}") - if rail_x >= content_x: - fails.append("rail khong nam ben trai noi dung") - - # --- 2. rail header order: picker ABOVE the new-chat button ------------ - py, by = top_of(win.nav_project, win), top_of(win.nav_new_chat, win) - ry = top_of(win.nav_recents, win) - ay = top_of(win._account_row, win) - print(f"bo chon y={py} · nut chat moi y={by} · GAN DAY y={ry} · tai khoan y={ay}") - if not (py < by < ry < ay): - fails.append("thu tu doc cua rail sai (bo chon → chat moi → GAN DAY → tai khoan)") - - # --- 3. Monitoring: one column, sections in the drawn order ------------ - win._goto(win._ROW_MONITORING, None) - for _ in range(8): - app.processEvents() - mon = win._page_widgets[win._ROW_MONITORING] - tops = [(n, top_of(getattr(mon, n), mon)) for n in MON_ORDER if getattr(mon, n, None)] - lefts = {n: left_of(getattr(mon, n), mon) for n, _y in tops} - print("Monitoring, tu tren xuong:") - for n, y in tops: - print(f" {n:28} y={y:5} x={lefts[n]}") - if [n for n, _ in sorted(tops, key=lambda t: t[1])] != [n for n, _ in tops]: - fails.append("thu tu muc trong Monitoring khong khop ban ve") - # Sandbox and Permissions share a row; everything else is full width. - perm_y = top_of(mon.ov_permissions_group, mon) - sbx_y = top_of(mon.ov_sandbox_details_group, mon) - same_row = abs(perm_y - sbx_y) < 20 - print(f"Sandbox | Quyen cung hang: {same_row}") - if not same_row: - fails.append("Sandbox va Quyen khong cung mot hang") - price_w = mon.ov_pricing_group.width() - res_w = mon.ov_resource_group.width() - print(f"bang gia rong {price_w}px · tai nguyen {res_w}px (deu tron be ngang)") - if price_w < res_w * 0.95: - fails.append("bang gia model khong chiem tron be ngang") - - # --- 3b. Schedule: all seven lanes on screen, no horizontal scroll ----- - win._goto(win._ROW_SCHEDULE, None) - for _ in range(8): - app.processEvents() - sched = win._page_widgets[win._ROW_SCHEDULE] - from PySide6.QtWidgets import QScrollArea - lanes = list(sched.columns.values()) - # The page holds more than one scroll area — take the one the lanes live in. - board = next(sa for sa in sched.findChildren(QScrollArea) - if sa.isAncestorOf(lanes[0])) - rightmost = max(left_of(c, board.widget()) + c.width() for c in lanes) - fits = rightmost <= board.viewport().width() + 2 - print(f"Schedule: {len(lanes)} lane · mep phai x={rightmost} · " - f"khung rong {board.viewport().width()} · vua mot man = {fits}") - if len(lanes) != 7: - fails.append(f"chi co {len(lanes)} lane, thiet ke la 7") - if not fits: - fails.append(f"lane thu 7 nam ngoai man ({rightmost} > " - f"{board.viewport().width()}) — phai cuon ngang") - - # --- 4. Dashboard: hero left, taller; supporting tiles in a 2x2 -------- - win._goto(win._ROW_DASHBOARD, None) - for _ in range(8): - app.processEvents() - dash = win._page_widgets[win._ROW_DASHBOARD] - hero, small = dash.card_cost, dash.card_total - print(f"the Chi phi : x={left_of(hero, dash)} cao={hero.height()} · " - f"the phu x={left_of(small, dash)} cao={small.height()}") - if left_of(hero, dash) >= left_of(small, dash): - fails.append("the Chi phi khong nam ben trai cac the phu") - if hero.height() < small.height() * 1.5: - fails.append("the Chi phi khong cao gap ruoi the phu") - row1 = top_of(dash.card_total, dash) - row2 = top_of(dash.card_out, dash) - print(f"the phu hang 1 y={row1} · hang 2 y={row2} (phai la 2 hang)") - if row2 <= row1: - fails.append("4 the phu khong xep 2x2") - - # --- 5. Cowork: the dot clears the composer, dot is 26px -------------- - win._goto(win._ROW_WORKSPACE, ws._cowork_tab_idx) - for _ in range(8): - app.processEvents() - dock = win.help_agent - comp = ws._cowork.composer - dock_bottom = top_of(dock, win) + dock.height() - comp_top = top_of(comp, win) - print(f"cham {dock.width()}x{dock.height()} · day y={dock_bottom} · o nhap dinh y={comp_top}") - if dock.width() > 30: - fails.append(f"cham tro ly rong {dock.width()}px, thiet ke la 26px") - if dock_bottom > comp_top: - fails.append("cham tro ly de len o nhap") - if left_of(dock, win) + dock.width() > win.width(): - fails.append("cham tro ly tran ra ngoai cua so") - - # --- 6. Co4E: sidebar left, canvas middle, config right --------------- - win._goto(win._ROW_WORKSPACE, ws._co4e_tab_idx) - for _ in range(8): - app.processEvents() - import cowork_local.ui.co4e_tab as co4e_mod - c4 = win.findChildren(co4e_mod.Co4ETab)[0] - xs = [c4._split.widget(i).x() for i in range(c4._split.count())] - print(f"Co4E 3 pane x = {xs}") - if xs != sorted(xs): - fails.append("thu tu 3 pane cua Co4E sai (trai → giua → phai)") - heads = [h.text() for h, _b, _s in c4._sections.values()] - print(f"cot sidebar: {heads}") - if len(heads) != 4: - fails.append(f"cot sidebar co {len(heads)} muc, ban ve ve 4") - - print() - if fails: - print("*** LECH BO CUC ***") - for f in fails: - print(" " + f) - return 1 - print("KET QUA VONG 2: hinh hoc khop ban ve") - return 0 - - -if __name__ == "__main__": - _rc = main() - # Qt (WebEngine especially) crashes during interpreter teardown with - # 0xC0000409 AFTER the work is done, which would mask the real result — - # and check_probes_bite reads these exit codes to decide whether a probe - # caught its mutation. Leave immediately with the verdict instead. - sys.stdout.flush() - sys.stderr.flush() - os._exit(_rc) +"""Round 2: does the built layout have the SHAPE the wireframes draw? + +Round 1 asks "does the feature exist". A screen can pass that and still be laid +out wrongly — right widgets, wrong order, wrong side, wrong proportions. This +round measures real geometry against what the audit page's wireframes depict: +reading order of the rail, section order down Monitoring, which side each pane +is on, and the size relationships the design calls out (hero card, 26px dot). + +Run: python tools/check_layout_geometry.py +""" +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 _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 + +# The rail, top to bottom, as the audit page's rail() helper draws it. +RAIL_ORDER = ["Project", "Cowork", "Co4E", "Thư mục", "GraphRAG", "Schedule Task"] +RAIL_BOTTOM = ["Dashboard", "Giám sát"] +# Monitoring ▸ Tổng quan, in the order the wireframe stacks it: what it cost → +# what the machine is doing → what the agent may touch → per-model prices → +# what actually happened. +MON_ORDER = ["ov_usage_group", "ov_resource_group", "ov_sandbox_details_group", + "ov_pricing_group", "ov_activity_group", "ov_audit_group"] + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + + _apply_theme(app) # measure the styled window, not a bare one + from cowork_local.config import AppConfig, 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.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1600, 950) + win.show() + for _ in range(8): + app.processEvents() + ws = win.workspace + fails: list[str] = [] + + def top_of(w, ref): + return w.mapTo(ref, w.rect().topLeft()).y() + + def left_of(w, ref): + return w.mapTo(ref, w.rect().topLeft()).x() + + # --- 1. rail: reading order, and the rail is on the LEFT --------------- + rows = [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())] + bottom = [win.nav_bottom.topLevelItem(i).text(0) + for i in range(win.nav_bottom.topLevelItemCount())] + print(f"thanh menu : {rows}") + print(f"nhom day : {bottom}") + if rows != RAIL_ORDER: + fails.append(f"thu tu thanh menu lech: {rows} != {RAIL_ORDER}") + if bottom != RAIL_BOTTOM: + fails.append(f"thu tu nhom day lech: {bottom} != {RAIL_BOTTOM}") + rail_x = left_of(win._nav_wrap, win) + content_x = left_of(win.pages, win) + print(f"rail x={rail_x} · noi dung x={content_x}") + if rail_x >= content_x: + fails.append("rail khong nam ben trai noi dung") + + # --- 2. rail header order: picker ABOVE the new-chat button ------------ + py, by = top_of(win.nav_project, win), top_of(win.nav_new_chat, win) + ry = top_of(win.nav_recents, win) + ay = top_of(win._account_row, win) + print(f"bo chon y={py} · nut chat moi y={by} · GAN DAY y={ry} · tai khoan y={ay}") + if not (py < by < ry < ay): + fails.append("thu tu doc cua rail sai (bo chon → chat moi → GAN DAY → tai khoan)") + + # --- 3. Monitoring: one column, sections in the drawn order ------------ + win._goto(win._ROW_MONITORING, None) + for _ in range(8): + app.processEvents() + mon = win._page_widgets[win._ROW_MONITORING] + tops = [(n, top_of(getattr(mon, n), mon)) for n in MON_ORDER if getattr(mon, n, None)] + lefts = {n: left_of(getattr(mon, n), mon) for n, _y in tops} + print("Monitoring, tu tren xuong:") + for n, y in tops: + print(f" {n:28} y={y:5} x={lefts[n]}") + if [n for n, _ in sorted(tops, key=lambda t: t[1])] != [n for n, _ in tops]: + fails.append("thu tu muc trong Monitoring khong khop ban ve") + # Sandbox and Permissions share a row; everything else is full width. + perm_y = top_of(mon.ov_permissions_group, mon) + sbx_y = top_of(mon.ov_sandbox_details_group, mon) + same_row = abs(perm_y - sbx_y) < 20 + print(f"Sandbox | Quyen cung hang: {same_row}") + if not same_row: + fails.append("Sandbox va Quyen khong cung mot hang") + price_w = mon.ov_pricing_group.width() + res_w = mon.ov_resource_group.width() + print(f"bang gia rong {price_w}px · tai nguyen {res_w}px (deu tron be ngang)") + if price_w < res_w * 0.95: + fails.append("bang gia model khong chiem tron be ngang") + + # --- 3b. Schedule: all seven lanes on screen, no horizontal scroll ----- + win._goto(win._ROW_SCHEDULE, None) + for _ in range(8): + app.processEvents() + sched = win._page_widgets[win._ROW_SCHEDULE] + from PySide6.QtWidgets import QScrollArea + lanes = list(sched.columns.values()) + # The page holds more than one scroll area — take the one the lanes live in. + board = next(sa for sa in sched.findChildren(QScrollArea) + if sa.isAncestorOf(lanes[0])) + rightmost = max(left_of(c, board.widget()) + c.width() for c in lanes) + fits = rightmost <= board.viewport().width() + 2 + print(f"Schedule: {len(lanes)} lane · mep phai x={rightmost} · " + f"khung rong {board.viewport().width()} · vua mot man = {fits}") + if len(lanes) != 7: + fails.append(f"chi co {len(lanes)} lane, thiet ke la 7") + if not fits: + fails.append(f"lane thu 7 nam ngoai man ({rightmost} > " + f"{board.viewport().width()}) — phai cuon ngang") + + # --- 4. Dashboard: hero left, taller; supporting tiles in a 2x2 -------- + win._goto(win._ROW_DASHBOARD, None) + for _ in range(8): + app.processEvents() + dash = win._page_widgets[win._ROW_DASHBOARD] + hero, small = dash.card_cost, dash.card_total + print(f"the Chi phi : x={left_of(hero, dash)} cao={hero.height()} · " + f"the phu x={left_of(small, dash)} cao={small.height()}") + if left_of(hero, dash) >= left_of(small, dash): + fails.append("the Chi phi khong nam ben trai cac the phu") + if hero.height() < small.height() * 1.5: + fails.append("the Chi phi khong cao gap ruoi the phu") + row1 = top_of(dash.card_total, dash) + row2 = top_of(dash.card_out, dash) + print(f"the phu hang 1 y={row1} · hang 2 y={row2} (phai la 2 hang)") + if row2 <= row1: + fails.append("4 the phu khong xep 2x2") + + # --- 5. Cowork: the dot clears the composer, dot is 26px -------------- + win._goto(win._ROW_WORKSPACE, ws._cowork_tab_idx) + for _ in range(8): + app.processEvents() + dock = win.help_agent + comp = ws._cowork.composer + dock_bottom = top_of(dock, win) + dock.height() + comp_top = top_of(comp, win) + print(f"cham {dock.width()}x{dock.height()} · day y={dock_bottom} · o nhap dinh y={comp_top}") + if dock.width() > 30: + fails.append(f"cham tro ly rong {dock.width()}px, thiet ke la 26px") + if dock_bottom > comp_top: + fails.append("cham tro ly de len o nhap") + if left_of(dock, win) + dock.width() > win.width(): + fails.append("cham tro ly tran ra ngoai cua so") + + # --- 6. Co4E: sidebar left, canvas middle, config right --------------- + win._goto(win._ROW_WORKSPACE, ws._co4e_tab_idx) + for _ in range(8): + app.processEvents() + import cowork_local.ui.co4e_tab as co4e_mod + c4 = win.findChildren(co4e_mod.Co4ETab)[0] + xs = [c4._split.widget(i).x() for i in range(c4._split.count())] + print(f"Co4E 3 pane x = {xs}") + if xs != sorted(xs): + fails.append("thu tu 3 pane cua Co4E sai (trai → giua → phai)") + heads = [h.text() for h, _b, _s in c4._sections.values()] + print(f"cot sidebar: {heads}") + if len(heads) != 4: + fails.append(f"cot sidebar co {len(heads)} muc, ban ve ve 4") + + print() + if fails: + print("*** LECH BO CUC ***") + for f in fails: + print(" " + f) + return 1 + print("KET QUA VONG 2: hinh hoc khop ban ve") + return 0 + + +if __name__ == "__main__": + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_multi_screen.py b/tools/check_multi_screen.py index f159b19..b684efb 100644 --- a/tools/check_multi_screen.py +++ b/tools/check_multi_screen.py @@ -16,6 +16,8 @@ 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 @@ -23,7 +25,7 @@ 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 _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 +from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 # Real-world panels, from a small laptop up to 4K-at-150%-effective. SIZES = [(1280, 720), (1366, 768), (1600, 900), (1920, 1080), (2560, 1440)] @@ -40,6 +42,7 @@ def main() -> int: _load_fonts() _freeze_schedulers() + _apply_theme(app) # measure the styled window, not a bare one from cowork_local.config import AppConfig, CONFIG_DIR assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" diff --git a/tools/check_nav.py b/tools/check_nav.py index 330b7aa..d5cd6da 100644 --- a/tools/check_nav.py +++ b/tools/check_nav.py @@ -15,6 +15,8 @@ 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 @@ -22,7 +24,7 @@ sys.path.insert(0, str(REPO.parent)) # `import cowork_loc sys.path.insert(0, str(Path(__file__).resolve().parent)) # sibling tools os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -from capture_screens import _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 +from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 def rows(tree): @@ -44,6 +46,7 @@ def main() -> int: _load_fonts() _freeze_schedulers() + _apply_theme(app) # measure the styled window, not a bare one from cowork_local.config import CONFIG_DIR assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" diff --git a/tools/check_no_hscroll.py b/tools/check_no_hscroll.py index 1d68c96..70bf2f9 100644 --- a/tools/check_no_hscroll.py +++ b/tools/check_no_hscroll.py @@ -1,123 +1,156 @@ -"""Prove the long dialogs never scroll sideways — including at large fonts. - -The report that started this came from a display at 125–150% scaling, where -every label is wider than on a 100% screen. Rather than trusting one font size, -this runs each dialog at several point sizes and several widths and fails if any -horizontal scrollbar turns up, in the scroll area or in the section index. - -Run: python tools/check_no_hscroll.py -""" -from __future__ import annotations - -import os -import sys -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 _isolate_home, _load_fonts # noqa: E402 - -WIDTHS = (1100, 964, 820, 700) -POINTS = (9, 11, 14) # ~100%, ~125%, ~150% display scaling - - -def hscroll(dlg, app): - """(scroll-area overflow, index overflow) — each True means content is - wider than the space it is given. - - A dialog built from step tabs has one scroll area per page, and a page that - is not current has stale geometry — so each tab is brought to the front - before its page is measured. - """ - from PySide6.QtWidgets import QListWidget, QScrollArea - over_area = False - stack = getattr(dlg, "section_stack", None) - if stack is not None: - # One scroll area per section; a page that is not current has stale - # geometry, so bring each to the front before measuring it. - idx = dlg.section_list - keep = idx.currentRow() - for i in range(stack.count()): - idx.setCurrentRow(i) - for _ in range(3): - app.processEvents() - sa = stack.widget(i) - if sa.widget().sizeHint().width() > sa.viewport().width(): - over_area = True - idx.setCurrentRow(keep) - else: - sa = dlg.findChildren(QScrollArea)[0] - over_area = sa.widget().sizeHint().width() > sa.viewport().width() - idx = dlg.findChild(QListWidget, "sectionIndex") - over_idx = False - if idx is not None: - over_idx = idx.sizeHintForColumn(0) > idx.viewport().width() - return over_area, over_idx - - -def main() -> int: - sandbox = _isolate_home() - from PySide6.QtGui import QFont - from PySide6.QtWidgets import QApplication - - app = QApplication([]) - _load_fonts() - - from cowork_local.config import AppConfig, CONFIG_DIR - assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" - - from cowork_local.i18n import set_language - from cowork_local.state import AppContext - from cowork_local.ui.settings_dialog import SettingsDialog - from cowork_local.ui.task_editor_dialog import TaskEditorDialog - - set_language("vi") - ctx = AppContext(AppConfig.load()) - fails: list[str] = [] - - for pt in POINTS: - f = QFont(app.font()) - f.setPointSize(pt) - app.setFont(f) - for name, make in (("Cai dat", lambda: SettingsDialog(ctx)), - ("Task editor", lambda: TaskEditorDialog(ctx=ctx))): - dlg = make() - dlg.show() - row = [] - for w in WIDTHS: - dlg.resize(w, 900) - for _ in range(4): - app.processEvents() - over_area, over_idx = hscroll(dlg, app) - row.append(f"{w}:{'A' if over_area else '.'}{'I' if over_idx else '.'}") - if over_area: - fails.append(f"{name} @ {pt}pt {w}px — vung cuon tran ngang") - if over_idx: - fails.append(f"{name} @ {pt}pt {w}px — muc luc tran ngang") - print(f" {pt:>2}pt {name:12} {' '.join(row)}") - dlg.close() - print() - - print("A = vung cuon tran · I = muc luc tran · . = khong tran") - print() - if fails: - print("*** LOI ***") - for x in fails: - print(" " + x) - return 1 - print("KET QUA: khong hop thoai nao cuon ngang, o moi co chu va be rong da thu") - return 0 - - -if __name__ == "__main__": - _rc = main() - # Qt (WebEngine especially) crashes during interpreter teardown with - # 0xC0000409 AFTER the work is done, which would mask the real result — - # and check_probes_bite reads these exit codes to decide whether a probe - # caught its mutation. Leave immediately with the verdict instead. - sys.stdout.flush() - sys.stderr.flush() - os._exit(_rc) +"""Prove the long dialogs never scroll sideways — including at large fonts. + +The report that started this came from a display at 125–150% scaling, where +every label is wider than on a 100% screen. Rather than trusting one font size, +this runs each dialog at several point sizes and several widths and fails if any +horizontal scrollbar turns up, in the scroll area or in the section index. + +Run: python tools/check_no_hscroll.py +""" +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 _apply_theme, _isolate_home, _load_fonts # noqa: E402 + +WIDTHS = (1100, 964, 820, 700) +POINTS = (9, 11, 14) # ~100%, ~125%, ~150% display scaling + + +def hscroll(dlg, app): + """(scroll-area overflow, index overflow) — each True means content is + wider than the space it is given. + + A dialog built from step tabs has one scroll area per page, and a page that + is not current has stale geometry — so each tab is brought to the front + before its page is measured. + """ + from PySide6.QtWidgets import QListWidget, QScrollArea + over_area = False + stack = getattr(dlg, "section_stack", None) + if stack is not None: + # One scroll area per section; a page that is not current has stale + # geometry, so bring each to the front before measuring it. + idx = dlg.section_list + keep = idx.currentRow() + for i in range(stack.count()): + idx.setCurrentRow(i) + for _ in range(3): + app.processEvents() + sa = stack.widget(i) + if sa.widget().sizeHint().width() > sa.viewport().width(): + over_area = True + idx.setCurrentRow(keep) + else: + sa = dlg.findChildren(QScrollArea)[0] + over_area = sa.widget().sizeHint().width() > sa.viewport().width() + idx = dlg.findChild(QListWidget, "sectionIndex") + over_idx = False + if idx is not None: + over_idx = _index_elides(idx) + return over_area, over_idx + + +def _index_elides(idx) -> bool: + """True when a section name does not fit the visible width of the list. + + Two earlier attempts got this wrong: + · `sizeHintForColumn(0) > viewport().width()` returns 182px at 9pt, 11pt + and 14pt alike — it does not track the font, so it called the 9pt + dialog broken while nothing on screen was clipped. + · Asking the delegate whether it elides. It does not: the view lays each + row out at its natural width and the viewport simply clips what runs + past it, so a list squeezed to 90px still reported "no elision". + + So compare the painted text against the width that is actually on screen. + """ + from PySide6.QtGui import QFontMetrics + from PySide6.QtWidgets import QStyle, QStyleOptionViewItem + + for i in range(idx.count()): + row = idx.indexFromItem(idx.item(i)) + opt = QStyleOptionViewItem() + idx.initViewItemOption(opt) + opt.rect = idx.visualRect(row) + idx.itemDelegate().initStyleOption(opt, row) + box = idx.style().subElementRect(QStyle.SE_ItemViewItemText, opt, idx) + label = idx.item(i).text() + visible = idx.viewport().width() - box.left() + if QFontMetrics(opt.font).horizontalAdvance(label) > visible: + return True + return False + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtGui import QFont + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + + _apply_theme(app) # measure the styled widget, not a bare one + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + from cowork_local.ui.settings_dialog import SettingsDialog + from cowork_local.ui.task_editor_dialog import TaskEditorDialog + + set_language("vi") + ctx = AppContext(AppConfig.load()) + fails: list[str] = [] + + for pt in POINTS: + f = QFont(app.font()) + f.setPointSize(pt) + app.setFont(f) + for name, make in (("Cai dat", lambda: SettingsDialog(ctx)), + ("Task editor", lambda: TaskEditorDialog(ctx=ctx))): + dlg = make() + dlg.show() + row = [] + for w in WIDTHS: + dlg.resize(w, 900) + for _ in range(4): + app.processEvents() + over_area, over_idx = hscroll(dlg, app) + row.append(f"{w}:{'A' if over_area else '.'}{'I' if over_idx else '.'}") + if over_area: + fails.append(f"{name} @ {pt}pt {w}px — vung cuon tran ngang") + if over_idx: + fails.append(f"{name} @ {pt}pt {w}px — muc luc tran ngang") + print(f" {pt:>2}pt {name:12} {' '.join(row)}") + dlg.close() + print() + + print("A = vung cuon tran · I = muc luc tran · . = khong tran") + print() + if fails: + print("*** LOI ***") + for x in fails: + print(" " + x) + return 1 + print("KET QUA: khong hop thoai nao cuon ngang, o moi co chu va be rong da thu") + return 0 + + +if __name__ == "__main__": + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_orphans.py b/tools/check_orphans.py index 4337a8e..83fd7a5 100644 --- a/tools/check_orphans.py +++ b/tools/check_orphans.py @@ -20,6 +20,8 @@ import ast import io import json import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") from pathlib import Path REPO = Path(__file__).resolve().parent.parent diff --git a/tools/check_probes_bite.py b/tools/check_probes_bite.py index a8fa372..bf0cff3 100644 --- a/tools/check_probes_bite.py +++ b/tools/check_probes_bite.py @@ -1,130 +1,132 @@ -"""Round 5: do the checks actually bite? - -Rounds 1–4 all report green. That is only worth something if the checks would -have turned red had the work not been done. So this round breaks the app on -purpose, one feature at a time, and fails if the corresponding check still -passes — a check that cannot fail is not evidence. - -Each mutation is applied by monkey-patching the module BEFORE the checker -builds its own window, then undone. - -Run: python tools/check_probes_bite.py -""" -from __future__ import annotations - -import io -import os -import runpy -import subprocess -import sys -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") - -# (name, file, find, replace, checker that must FAIL because of it) -MUTATIONS = [ - ("bo cham tro ly 26px -> 64px", - "ui/help_agent_widget.py", "_DOT = 26", "_DOT = 64", - "check_layout_geometry.py"), - ("tra lane Running ve khong vien", - "ui/schedule_task_tab.py", - 'if status == "running" and counts[status]:', - 'if False:', - "check_design_parity.py"), - ("bo cot muc luc cua Cai dat", - "ui/settings_dialog.py", - "self.section_list, self.section_stack = section_panels(pages)", - "self.section_list, self.section_stack = section_panels(pages[:1])", - "check_dialogs.py"), - ("noi lai dai tab flow Co4E", - "ui/co4e_tab.py", - "self.flow_scroll.setVisible(False)", - "self.flow_scroll.setVisible(True)", - "check_co4e.py"), - ("bo dong 'Tat ca project...' khoi GAN DAY", - "app.py", - 'more.setData(0, Qt.UserRole, {"all": True})', - 'more.setData(0, Qt.UserRole, {})', - "check_design_parity.py"), - ("tra thanh menu ve accordion (bo nhom day)", - "app.py", - 'rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,', - 'rows.append((self.nav, self._ROW_DASHBOARD, None,', - "check_layout_geometry.py"), -] - - -def run_checker(script: str) -> int: - """Run a checker in a fresh process; return its exit code.""" - proc = subprocess.run( - [sys.executable, str(REPO / "tools" / script)], - cwd=REPO, capture_output=True, text=True, encoding="utf-8", - errors="replace", env={**os.environ, "QT_QPA_PLATFORM": "offscreen", - "PYTHONIOENCODING": "utf-8"}) - return proc.returncode - - -def tree_state() -> str: - return subprocess.run(["git", "status", "--short"], cwd=REPO, - capture_output=True, text=True).stdout.strip() - - -def main() -> int: - fails: list[str] = [] - # Compare the tree BEFORE and AFTER, not against a clean tree: work in - # progress is legitimately uncommitted, and demanding a clean tree made this - # round fail for a reason that has nothing to do with the mutations. - before = tree_state() - print(f"{'hong gi':44} {'phep do':26} ket qua") - print("-" * 88) - for name, rel, find, repl, checker in MUTATIONS: - path = REPO / rel - # newline="" both ways: the default translates on read AND write, so a - # LF file came back as CRLF and every mutated file was left "modified" - # even after being restored. - with io.open(path, "r", encoding="utf-8", newline="") as fh: - original = fh.read() - if find not in original: - fails.append(f"{name}: khong tim thay doan can sua trong {rel}") - print(f"{name:44} {checker:26} *** KHONG AP DUNG DUOC ***") - continue - - def write(text: str) -> None: - with io.open(path, "w", encoding="utf-8", newline="") as fh: - fh.write(text) - - write(original.replace(find, repl, 1)) - try: - code = run_checker(checker) - finally: - write(original) # always restore - bit = code != 0 - print(f"{name:44} {checker:26} {'BAT DUOC' if bit else '*** KHONG BAT ***'}") - if not bit: - fails.append(f"{name}: {checker} van bao xanh du da lam hong") - - # Everything must be back exactly as it was before this run. - after = tree_state() - same = after == before - print() - print("cay lam viec sau khi thu giong het truoc:", "co" if same else "*** KHAC ***") - if not same: - print(" truoc:", before.replace("\n", " | ") or "(sach)") - print(" sau :", after.replace("\n", " | ") or "(sach)") - fails.append("file chua duoc khoi phuc sau khi thu") - - print() - if fails: - print("*** VONG 5 THAT BAI ***") - for f in fails: - print(" " + f) - return 1 - print(f"KET QUA VONG 5: ca {len(MUTATIONS)} phep do deu bat duoc loi khi co tinh lam hong") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +"""Round 5: do the checks actually bite? + +Rounds 1–4 all report green. That is only worth something if the checks would +have turned red had the work not been done. So this round breaks the app on +purpose, one feature at a time, and fails if the corresponding check still +passes — a check that cannot fail is not evidence. + +Each mutation is applied by monkey-patching the module BEFORE the checker +builds its own window, then undone. + +Run: python tools/check_probes_bite.py +""" +from __future__ import annotations + +import io +import os +import runpy +import subprocess +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") + +# (name, file, find, replace, checker that must FAIL because of it) +MUTATIONS = [ + ("bo cham tro ly 26px -> 64px", + "ui/help_agent_widget.py", "_DOT = 26", "_DOT = 64", + "check_layout_geometry.py"), + ("tra lane Running ve khong vien", + "ui/schedule_task_tab.py", + 'if status == "running" and counts[status]:', + 'if False:', + "check_design_parity.py"), + ("bo cot muc luc cua Cai dat", + "ui/settings_dialog.py", + "self.section_list, self.section_stack = section_panels(pages)", + "self.section_list, self.section_stack = section_panels(pages[:1])", + "check_dialogs.py"), + ("noi lai dai tab flow Co4E", + "ui/co4e_tab.py", + "self.flow_scroll.setVisible(False)", + "self.flow_scroll.setVisible(True)", + "check_co4e.py"), + ("bo dong 'Tat ca project...' khoi GAN DAY", + "app.py", + 'more.setData(0, Qt.UserRole, {"all": True})', + 'more.setData(0, Qt.UserRole, {})', + "check_design_parity.py"), + ("tra thanh menu ve accordion (bo nhom day)", + "app.py", + 'rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,', + 'rows.append((self.nav, self._ROW_DASHBOARD, None,', + "check_layout_geometry.py"), +] + + +def run_checker(script: str) -> int: + """Run a checker in a fresh process; return its exit code.""" + proc = subprocess.run( + [sys.executable, str(REPO / "tools" / script)], + cwd=REPO, capture_output=True, text=True, encoding="utf-8", + errors="replace", env={**os.environ, "QT_QPA_PLATFORM": "offscreen", + "PYTHONIOENCODING": "utf-8"}) + return proc.returncode + + +def tree_state() -> str: + return subprocess.run(["git", "status", "--short"], cwd=REPO, + capture_output=True, text=True).stdout.strip() + + +def main() -> int: + fails: list[str] = [] + # Compare the tree BEFORE and AFTER, not against a clean tree: work in + # progress is legitimately uncommitted, and demanding a clean tree made this + # round fail for a reason that has nothing to do with the mutations. + before = tree_state() + print(f"{'hong gi':44} {'phep do':26} ket qua") + print("-" * 88) + for name, rel, find, repl, checker in MUTATIONS: + path = REPO / rel + # newline="" both ways: the default translates on read AND write, so a + # LF file came back as CRLF and every mutated file was left "modified" + # even after being restored. + with io.open(path, "r", encoding="utf-8", newline="") as fh: + original = fh.read() + if find not in original: + fails.append(f"{name}: khong tim thay doan can sua trong {rel}") + print(f"{name:44} {checker:26} *** KHONG AP DUNG DUOC ***") + continue + + def write(text: str) -> None: + with io.open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(text) + + write(original.replace(find, repl, 1)) + try: + code = run_checker(checker) + finally: + write(original) # always restore + bit = code != 0 + print(f"{name:44} {checker:26} {'BAT DUOC' if bit else '*** KHONG BAT ***'}") + if not bit: + fails.append(f"{name}: {checker} van bao xanh du da lam hong") + + # Everything must be back exactly as it was before this run. + after = tree_state() + same = after == before + print() + print("cay lam viec sau khi thu giong het truoc:", "co" if same else "*** KHAC ***") + if not same: + print(" truoc:", before.replace("\n", " | ") or "(sach)") + print(" sau :", after.replace("\n", " | ") or "(sach)") + fails.append("file chua duoc khoi phuc sau khi thu") + + print() + if fails: + print("*** VONG 5 THAT BAI ***") + for f in fails: + print(" " + f) + return 1 + print(f"KET QUA VONG 5: ca {len(MUTATIONS)} phep do deu bat duoc loi khi co tinh lam hong") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_rail_align.py b/tools/check_rail_align.py new file mode 100644 index 0000000..c64b1c2 --- /dev/null +++ b/tools/check_rail_align.py @@ -0,0 +1,176 @@ +"""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) + + 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 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()) diff --git a/tools/check_responsive.py b/tools/check_responsive.py index 31a7654..de41bb6 100644 --- a/tools/check_responsive.py +++ b/tools/check_responsive.py @@ -13,6 +13,8 @@ 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 diff --git a/ui/help_agent_widget.py b/ui/help_agent_widget.py index 016982e..b90b28c 100644 --- a/ui/help_agent_widget.py +++ b/ui/help_agent_widget.py @@ -46,7 +46,12 @@ _PANEL_W, _PANEL_H = 340, 460 # expanded chat panel size # assistant is teal, not the app accent, and the same in both themes — # it is one recognisable object floating over every screen. _TEAL_BG, _TEAL_LINE = "#E6F6F4", "#7FD0C4" -_TEAL_TEXT, _TEAL_SPARK = "#0F6E62", "#0F9B8A" +_TEAL_TEXT = "#0F6E62" +# The page's CSS says .spark{color:#0F9B8A}, but the glyph is the ✨ EMOJI and a +# colour emoji ignores CSS colour — so what the page actually renders is the +# gold star. Sampled from the page's own render of section 27 (1055 pixels of +# the star, averaged): #FDBE59. +_SPARK_GOLD = "#FDBE59" # The three states the floating assistant cycles through. _HIDDEN, _LAUNCHER_ST, _PANEL = "hidden", "launcher", "panel" @@ -120,9 +125,12 @@ class HelpAgentWidget(QWidget): self._worker: Optional[AgentWorker] = None # Conversation history (excludes the system prompt, prepended per call). # Seeded with the greeting so the panel always opens on a friendly hello. - self._history: List[Dict[str, str]] = [ - {"role": "assistant", "content": self._greeting()} - ] + # Kept by identity so retranslate() can rewrite it without having to + # guess which language the visible text is in — and without touching a + # real reply that happens to look like a greeting. + self._greet_msg: Dict[str, str] = { + "role": "assistant", "content": self._greeting()} + self._history: List[Dict[str, str]] = [self._greet_msg] self.setAttribute(Qt.WA_StyledBackground, True) self._pal = self._compute_palette() self._build_edge_tab() @@ -147,7 +155,7 @@ class HelpAgentWidget(QWidget): self._apply_style() muted = self._pal.text_muted self.edge_tab.setIcon(icon("chevron-left", color=_TEAL_TEXT)) - self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_TEAL_SPARK)) + self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_SPARK_GOLD)) self.min_btn.setIcon(icon("minus", color=muted)) self._render() @@ -164,8 +172,8 @@ class HelpAgentWidget(QWidget): border-radius: {_DOT // 2}px; color: {_TEAL_TEXT}; font-weight: 700; font-size: 12px; padding: 0; text-align: center; }} #helpLauncher[pill="true"] {{ text-align: left; padding-left: 6px; }} - #helpLauncher:hover {{ background: #D5EFEA; border-color: {_TEAL_SPARK}; }} - #helpLauncher:focus {{ border: 1px solid {_TEAL_SPARK}; }} + #helpLauncher:hover {{ background: #D5EFEA; border-color: {_TEAL_LINE}; }} + #helpLauncher:focus {{ border: 1px solid {_TEAL_TEXT}; }} #helpEdgeTab {{ background: {_TEAL_BG}; border: 1px solid {_TEAL_LINE}; border-right: none; border-top-left-radius: {r}px; border-bottom-left-radius: {r}px; }} @@ -213,7 +221,7 @@ class HelpAgentWidget(QWidget): # is gone — hiding to the edge is now a line in the panel's ⋯ menu. self.launcher = _HoverPill(self) self.launcher.setObjectName("helpLauncher") - self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_TEAL_SPARK)) + self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_SPARK_GOLD)) self.launcher.setCursor(Qt.PointingHandCursor) self.launcher.setToolTip(tr("help_agent.open_tooltip")) self.launcher.clicked.connect(self._expand) @@ -233,7 +241,7 @@ class HelpAgentWidget(QWidget): hb.setContentsMargins(12, 8, 8, 8) self.title_icon = QLabel(header) self.title_icon.setPixmap( - icon("sparkle", size=16, color=_TEAL_SPARK).pixmap(16, 16)) + icon("sparkle", size=16, color=_SPARK_GOLD).pixmap(16, 16)) hb.addWidget(self.title_icon) self.title = QLabel(tr("help_agent.title"), header) self.title.setObjectName("helpTitle") @@ -444,6 +452,12 @@ class HelpAgentWidget(QWidget): self.send_btn.setEnabled(not busy) def retranslate(self) -> None: + # The transcript is rendered HTML, so switching language left the + # greeting — and every "AI Assistant" speaker label — in the language + # the panel was built in. + if self._history and self._history[0] is self._greet_msg: + self._greet_msg["content"] = self._greeting() + self._render() self.title.setText(tr("help_agent.title")) self.input.setPlaceholderText(tr("help_agent.placeholder")) self.launcher.setToolTip(tr("help_agent.open_tooltip"))