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