fix(ui): sections 17 and 18 were checked against the wrong text

check_design_parity read its checklist from build_audit_page.ANALYSIS. Eight
sections of the audit page are hand-written, and for those the generator's
text is NOT what the page says — so Settings and the Task editor were reported
as matching a design they had never been compared against. The Task editor was
in fact built backwards.

  * The checker now reads the "Thay đổi" bullets straight out of
    docs/ui-audit.html, and reports how many bullets on the page still have no
    probe (57 on the page, 32 probed) instead of implying full coverage.

  * Task editor: reverted from three step tabs to a left list + right panel,
    five rows matching the five real group boxes — which is what the page asks
    for, in as many words ("thay vì chia tab"), for consistency with Settings.

  * Settings now switches panels rather than scrolling, so both dialogs are
    navigated identically and neither is a long scroll any more.

  * Settings field presentation, as the page's second bullet asks: the six
    checkboxes became switches, and Language/Theme became segmented controls.
    ToggleSwitch subclasses QCheckBox and SegmentedControl exposes the slice
    of the QComboBox API this dialog uses, so no save/load path changed —
    verified by round-tripping language/theme/tray through _save().

All 22 task-editor fields and 14 settings fields verified present after the
move; 7 suites green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
NamPDT
2026-08-17 12:49:52 +09:00
co-authored by Claude Opus 5
parent cb68f87b35
commit 62bec1f984
7 changed files with 365 additions and 132 deletions
+10
View File
@@ -377,6 +377,16 @@ QWidget#navWrap QTreeWidget::item:disabled { color: $text_faint; }
/* The pinned bottom group (Dashboard, Monitoring) — one hairline separates it /* The pinned bottom group (Dashboard, Monitoring) — one hairline separates it
from the list above so "occasional" reads apart from "everyday". */ from the list above so "occasional" reads apart from "everyday". */
QTreeWidget#navrailBottom { border-top: 1px solid $nav_border; } QTreeWidget#navrailBottom { border-top: 1px solid $nav_border; }
/* Segmented control: two-to-four choices shown side by side (language, theme)
instead of a drop-list you must open to see what the options even are. */
QPushButton#segItem {
background: $surface_raised; color: $text_muted; border: 1px solid $border;
padding: 4px 12px; margin: 0; border-radius: 0;
}
QPushButton#segItem:hover { background: $hover; color: $text; }
QPushButton#segItem:checked {
background: $accent_solid; color: #FFFFFF; border-color: $accent_solid; font-weight: 600;
}
/* Table of contents down the left of the long dialogs (Settings, Task editor). */ /* Table of contents down the left of the long dialogs (Settings, Task editor). */
QListWidget#sectionIndex { background: $surface; border-right: 1px solid $border; } QListWidget#sectionIndex { background: $surface; border-right: 1px solid $border; }
QListWidget#sectionIndex::item { padding: 7px 10px; border-radius: ${radius}px; } QListWidget#sectionIndex::item { padding: 7px 10px; border-radius: ${radius}px; }
+68 -7
View File
@@ -26,6 +26,29 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 from capture_screens import _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
def page_proposals():
"""The 'Thay đổi' bullets as they appear ON THE PAGE, per section.
Read from docs/ui-audit.html rather than build_audit_page.ANALYSIS: eight
sections are hand-written, and for those two the generator's text is NOT
what the page shows. Checking against ANALYSIS reported Settings and the
Task editor as matching the design when the page asked for something else
(and, for the Task editor, the opposite).
"""
import re
html = (REPO / "docs" / "ui-audit.html").read_text(encoding="utf-8")
html = re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", html)
out: dict[str, list[str]] = {}
for m in re.finditer(r'<section class="sec" id="([^"]+)">(.*?)</section>', html, re.S):
block = re.search(r'Thay đổi</div><ul class="pr">(.*?)</ul>', m.group(2), re.S)
if not block:
continue
out[m.group(1)] = [
re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", li)).strip()
for li in re.findall(r"<li>(.*?)</li>", block.group(1), re.S)]
return out
def build(): def build():
sandbox = _isolate_home() sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
@@ -213,6 +236,7 @@ def main() -> int:
# --- 9/15 Monitoring --- # --- 9/15 Monitoring ---
from PySide6.QtWidgets import QHBoxLayout, QScrollArea from PySide6.QtWidgets import QHBoxLayout, QScrollArea
from PySide6.QtWidgets import QSpinBox as QSpinBoxT
ov = mon.findChildren(QScrollArea)[0].widget() ov = mon.findChildren(QScrollArea)[0].widget()
one_col = not isinstance(ov.layout(), QHBoxLayout) one_col = not isinstance(ov.layout(), QHBoxLayout)
add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col, add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col,
@@ -232,15 +256,31 @@ def main() -> int:
s = SettingsDialog(win.ctx) s = SettingsDialog(win.ctx)
add("dialog-settings", "Thêm cột mục lục bên trái", add("dialog-settings", "Thêm cột mục lục bên trái",
s.section_list.count() == 5, f"{s.section_list.count()} mục") s.section_list.count() == 5, f"{s.section_list.count()} mục")
have = [n for n in ("provider_combo", "language_combo", "theme_combo") # The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider
if getattr(s, n, None) is not None] # left as its own group — not everything merged together.
add("dialog-settings", "Gom Provider/Ngôn ngữ/Giao diện vào Settings", from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch
len(have) == 3, f"{have} (cũng có ở hàng tài khoản trên rail)") in_general = s._general_box.isAncestorOf(s.language_combo) and \
s._general_box.isAncestorOf(s.theme_combo)
prov_apart = not s._general_box.isAncestorOf(s.provider_combo)
add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng",
in_general and prov_apart,
f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}")
n_switch = len(s.findChildren(ToggleSwitch))
n_seg = len(s.findChildren(SegmentedControl))
steppers = [w for w in s.findChildren(QSpinBoxT) if w.buttonSymbols() != 2]
add("dialog-settings",
"Field theo đúng loại: checkbox→toggle, dropdown 2–4 giá trị→segmented, số→stepper",
n_switch >= 6 and n_seg >= 2 and len(steppers) >= 1,
f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper")
s.close() s.close()
t = TaskEditorDialog(ctx=win.ctx) t = TaskEditorDialog(ctx=win.ctx)
steps = [t.step_tabs.tabText(i) for i in range(t.step_tabs.count())] rows = [t.section_list.item(i).text() for i in range(t.section_list.count())]
add("dialog-task-editor", "Chia 3 bước có tab: Nội dung → Lịch chạy → Liên kết", like_settings = (t.section_list.count() == 5
len(steps) == 3, " · ".join(steps)) and t.section_stack.count() == 5
and not hasattr(t, "step_tabs"))
add("dialog-task-editor",
"Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group",
like_settings, " · ".join(rows))
t.close() t.close()
# --- 27 help dock --- # --- 27 help dock ---
@@ -269,6 +309,19 @@ def main() -> int:
dock_top + dock.height() <= comp_top, dock_top + dock.height() <= comp_top,
f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}") f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}")
# --- coverage: is every bullet ON THE PAGE actually probed? -------------
proposals = page_proposals()
probed = {}
for slug, text, _v, _e in R:
probed.setdefault(slug, 0)
probed[slug] += 1
gaps = []
for slug, bullets in proposals.items():
n_probe = probed.get(slug, 0)
if len(bullets) > n_probe:
for extra in bullets[n_probe:]:
gaps.append((slug, extra))
# --- report --- # --- report ---
order = ["OK", "KHAC", "CHUA", "TAY"] order = ["OK", "KHAC", "CHUA", "TAY"]
counts = {k: 0 for k in order} counts = {k: 0 for k in order}
@@ -280,7 +333,15 @@ def main() -> int:
cur = slug cur = slug
print(f" [{verdict:4}] {text}") print(f" [{verdict:4}] {text}")
print(f" {ev}") print(f" {ev}")
if gaps:
print()
print(f"*** DE XUAT TREN TRANG CHUA CO PHEP DO: {len(gaps)} ***")
for slug, text in gaps:
print(f" {slug}")
print(f" {text[:160]}")
print() print()
print(f"de xuat tren trang : {sum(len(v) for v in proposals.values())}"
f" · da co phep do : {len(R)}")
print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order)) print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order))
print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})") print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})")
print(f" KHAC = co y lam khac, da ghi ly do") print(f" KHAC = co y lam khac, da ghi ly do")
+59 -71
View File
@@ -1,8 +1,9 @@
"""Check the section index added to Settings and the Task editor, offscreen. """Check the left-list + right-panel navigation in Settings and Task editor.
The index is navigation only, so the test is again a subtraction test: every Both dialogs are navigated the same way, as the audit page asks: a list of the
input control must still be there, and every index row must actually scroll to real group boxes on the left, one panel shown at a time on the right. So the
its section. Both dialogs are built with .show(), never .exec() — exec() blocks. test is that picking a row swaps the panel, that the rows match the groups, and
— since this is a rearrangement — that no input control went missing.
Run: python tools/check_dialogs.py Run: python tools/check_dialogs.py
""" """
@@ -19,69 +20,55 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _isolate_home, _load_fonts # noqa: E402 from capture_screens import _isolate_home, _load_fonts # noqa: E402
# Every field each dialog must still offer after the move.
def controls(dlg): SETTINGS_FIELDS = [
from PySide6.QtWidgets import (QCheckBox, QComboBox, QLineEdit, QListWidget, "language_combo", "theme_combo", "tray_chk", "notify_chk",
QPlainTextEdit, QPushButton, QSpinBox) "provider_combo", "prov_base", "prov_key", "prov_model",
n = 0 "sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_confirm",
for cls in (QComboBox, QLineEdit, QCheckBox, QSpinBox, QPlainTextEdit, "sandbox_block_network", "sec_enabled", "ai_check",
QPushButton, QListWidget): ]
n += len(dlg.findChildren(cls)) TASK_FIELDS = [
return n "title_edit", "desc_edit", "gen_desc_btn", "priority_combo", "status_combo",
"workspace_combo", "provider_combo", "model_combo", "skill_combo",
"sched_enabled", "run_at_edit", "files_list", "files_add_btn", "links_list",
"links_add_btn", "next_combo", "run_next_combo", "pass_output_chk",
"depends_list", "retry_spin", "timeout_spin", "approval_chk",
]
def check_tabs(name, dlg, app, expect): def check(name, dlg, app, expect_rows, fields):
"""The Task editor uses step TABS, not an index — same goal, different
control, so it gets its own check."""
fails = [] fails = []
print(f"--- {name} ---") print(f"--- {name} ---")
n_ctl = controls(dlg) idx, stack = dlg.section_list, dlg.section_stack
tabs = dlg.step_tabs
names = [tabs.tabText(i) for i in range(tabs.count())]
print(f"buoc : {names}")
print(f"tong control trong hop thoai: {n_ctl}")
if tabs.count() != expect:
fails.append(f"{name}: cho {expect} buoc, thay {tabs.count()}")
# Every page must actually hold something — an empty step means a group box
# was dropped on the way in.
for i in range(tabs.count()):
page = tabs.widget(i).widget()
kids = [w for w in page.findChildren(type(dlg)) ] or page.children()
n = len([c for c in page.findChildren(__import__(
"PySide6.QtWidgets", fromlist=["QWidget"]).QWidget) if c.parent() is page])
print(f" buoc {i + 1} co {n} khoi")
if n == 0:
fails.append(f"{name}: buoc {i + 1} rong")
return n_ctl, fails
def check(name, dlg, app, expect_rows):
fails = []
print(f"--- {name} ---")
n_ctl = controls(dlg)
idx = dlg.section_list
rows = [idx.item(i).text() for i in range(idx.count())] rows = [idx.item(i).text() for i in range(idx.count())]
print(f"muc luc : {rows}") print(f"muc : {rows}")
print(f"tong control trong hop thoai: {n_ctl}")
if len(rows) != expect_rows: if len(rows) != expect_rows:
fails.append(f"{name}: cho {expect_rows} muc, thay {len(rows)}") fails.append(f"{name}: cho {expect_rows} muc, thay {len(rows)}")
if any(not r or r.endswith(".g_basic") or r.startswith("settings.") for r in rows): if idx.count() != stack.count():
fails.append(f"{name}: co muc chua dich") fails.append(f"{name}: {idx.count()} muc nhung {stack.count()} panel")
# Each row must scroll somewhere different (and the last one furthest down). # Picking a row must swap the panel — and each panel must hold something.
from PySide6.QtWidgets import QScrollArea swapped, empty = 0, []
scroll = dlg.findChildren(QScrollArea)[0]
positions = []
for i in range(idx.count()): for i in range(idx.count()):
idx.itemClicked.emit(idx.item(i)) idx.setCurrentRow(i)
app.processEvents() for _ in range(3):
positions.append(scroll.verticalScrollBar().value()) app.processEvents()
print(f"vi tri cuon theo tung muc : {positions}") if stack.currentIndex() == i:
if positions != sorted(positions): swapped += 1
fails.append(f"{name}: muc luc nhay khong theo thu tu tren xuong") page = stack.widget(i).widget()
if len(set(positions)) < 2: if not page.findChildren(type(page)):
fails.append(f"{name}: bam muc nao cung dung mot cho — muc luc khong chay") empty.append(rows[i])
return n_ctl, fails print(f"chon muc -> doi panel : {swapped}/{idx.count()}")
if swapped != idx.count():
fails.append(f"{name}: chon muc khong doi panel")
if empty:
fails.append(f"{name}: panel rong {empty}")
missing = [f for f in fields if getattr(dlg, f, None) is None]
print(f"field con nguyen : {len(fields) - len(missing)}/{len(fields)}")
if missing:
fails.append(f"{name}: mat field {missing}")
return fails
def main() -> int: def main() -> int:
@@ -101,31 +88,32 @@ def main() -> int:
set_language("vi") set_language("vi")
ctx = AppContext(AppConfig.load()) ctx = AppContext(AppConfig.load())
fails = [] fails = []
s = SettingsDialog(ctx) s = SettingsDialog(ctx)
s.resize(900, 600) s.resize(900, 640)
s.show() s.show()
app.processEvents() app.processEvents()
n_s, f = check("Cai dat", s, app, 5) fails += check("Cai dat", s, app, 5, SETTINGS_FIELDS)
fails += f
t = TaskEditorDialog(ctx=ctx) # task=None → a new task, all fields present t = TaskEditorDialog(ctx=ctx)
t.resize(900, 600) t.resize(900, 640)
t.show() t.show()
app.processEvents() app.processEvents()
n_t, f = check_tabs("Task editor", t, app, 3) fails += check("Task editor", t, app, 5, TASK_FIELDS)
fails += f
# Translations for the two names that had to be invented for the index. # Both dialogs must be navigated the SAME way — that is the stated point.
same = (type(s.section_list) is type(t.section_list)
and type(s.section_stack) is type(t.section_stack))
print() print()
print(f"hai hop thoai cung kieu dieu huong: {same}")
if not same:
fails.append("hai hop thoai dieu huong khac kieu")
for lang in ("vi", "en", "ja"): for lang in ("vi", "en", "ja"):
set_language(lang) set_language(lang)
print(f" {lang}: general={tr('settings.group.general')!r} " print(f" {lang}: general={tr('settings.group.general')!r} "
f"basic={tr('schedtask.g_basic')!r}") f"basic={tr('schedtask.g_basic')!r}")
for key in ("settings.group.general", "schedtask.g_basic"):
if tr(key) == key:
fails.append(f"thieu ban dich {key} cho {lang}")
set_language("vi") set_language("vi")
print() print()
@@ -134,7 +122,7 @@ def main() -> int:
for x in fails: for x in fails:
print(" " + x) print(" " + x)
return 1 return 1
print("KET QUA: hai hop thoai co muc luc, khong mat control nao") print("KET QUA: hai hop thoai dung danh sach trai + panel phai, khong mat field")
return 0 return 0
+10 -7
View File
@@ -34,17 +34,20 @@ def hscroll(dlg, app):
""" """
from PySide6.QtWidgets import QListWidget, QScrollArea from PySide6.QtWidgets import QListWidget, QScrollArea
over_area = False over_area = False
tabs = getattr(dlg, "step_tabs", None) stack = getattr(dlg, "section_stack", None)
if tabs is not None: if stack is not None:
keep = tabs.currentIndex() # One scroll area per section; a page that is not current has stale
for i in range(tabs.count()): # geometry, so bring each to the front before measuring it.
tabs.setCurrentIndex(i) idx = dlg.section_list
keep = idx.currentRow()
for i in range(stack.count()):
idx.setCurrentRow(i)
for _ in range(3): for _ in range(3):
app.processEvents() app.processEvents()
sa = tabs.widget(i) sa = stack.widget(i)
if sa.widget().sizeHint().width() > sa.viewport().width(): if sa.widget().sizeHint().width() > sa.viewport().width():
over_area = True over_area = True
tabs.setCurrentIndex(keep) idx.setCurrentRow(keep)
else: else:
sa = dlg.findChildren(QScrollArea)[0] sa = dlg.findChildren(QScrollArea)[0]
over_area = sa.widget().sizeHint().width() > sa.viewport().width() over_area = sa.widget().sizeHint().width() > sa.viewport().width()
+51 -26
View File
@@ -20,6 +20,7 @@ from ..core.worker import AgentWorker
from ..i18n import LANGUAGES, tr from ..i18n import LANGUAGES, tr
from ..state import AppContext from ..state import AppContext
from .icons import icon, IconLabel from .icons import icon, IconLabel
from .widgets import SegmentedControl, ToggleSwitch
from .ext_connector_dialog import ExtConnectorEditDialog from .ext_connector_dialog import ExtConnectorEditDialog
@@ -51,7 +52,7 @@ class SettingsDialog(QDialog):
# --- language + tray --- # --- language + tray ---
top = QFormLayout() top = QFormLayout()
self.language_combo = QComboBox() self.language_combo = SegmentedControl()
for key, label in LANGUAGES.items(): for key, label in LANGUAGES.items():
self.language_combo.addItem(label, key) self.language_combo.addItem(label, key)
self._select_combo(self.language_combo, ctx.config.language) self._select_combo(self.language_combo, ctx.config.language)
@@ -60,16 +61,16 @@ class SettingsDialog(QDialog):
# Theme belongs with the other per-account settings. It is also on the # Theme belongs with the other per-account settings. It is also on the
# rail's account row (one click for the common flip); this is the same # rail's account row (one click for the common flip); this is the same
# value, named and explained, for people who come looking in Settings. # value, named and explained, for people who come looking in Settings.
self.theme_combo = QComboBox() self.theme_combo = SegmentedControl()
for key in ("system", "dark", "light"): for key in ("system", "dark", "light"):
self.theme_combo.addItem(tr(f"settings.theme_{key}"), key) self.theme_combo.addItem(tr(f"settings.theme_{key}"), key)
self._select_combo(self.theme_combo, getattr(ctx.config, "theme", "system")) self._select_combo(self.theme_combo, getattr(ctx.config, "theme", "system"))
top.addRow(tr("settings.theme"), self.theme_combo) top.addRow(tr("settings.theme"), self.theme_combo)
self.tray_chk = QCheckBox(tr("settings.tray_keep")) self.tray_chk = ToggleSwitch(tr("settings.tray_keep"))
self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True))) self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True)))
top.addRow("", self.tray_chk) top.addRow("", self.tray_chk)
self.notify_chk = QCheckBox(tr("settings.tray_notify")) self.notify_chk = ToggleSwitch(tr("settings.tray_notify"))
self.notify_chk.setChecked(bool(data.get("tray", {}).get("notify_on_done", True))) self.notify_chk.setChecked(bool(data.get("tray", {}).get("notify_on_done", True)))
top.addRow("", self.notify_chk) top.addRow("", self.notify_chk)
# Zero-height anchor so the index can scroll to this section, which is a # Zero-height anchor so the index can scroll to this section, which is a
@@ -136,12 +137,12 @@ class SettingsDialog(QDialog):
pw_sep = QLabel("────────────────") pw_sep = QLabel("────────────────")
sbl.addWidget(pw_sep) sbl.addWidget(pw_sep)
self.sandbox_confirm = QCheckBox(tr("settings.sandbox_confirm_commands")) self.sandbox_confirm = ToggleSwitch(tr("settings.sandbox_confirm_commands"))
self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False))) self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False)))
self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip")) self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip"))
sbl.addWidget(self.sandbox_confirm) sbl.addWidget(self.sandbox_confirm)
self.sandbox_block_network = QCheckBox(tr("settings.sandbox_block_network")) self.sandbox_block_network = ToggleSwitch(tr("settings.sandbox_block_network"))
self.sandbox_block_network.setChecked(bool(sec.get("block_network", True))) self.sandbox_block_network.setChecked(bool(sec.get("block_network", True)))
self.sandbox_block_network.setToolTip(tr("settings.sandbox_block_network_tooltip")) self.sandbox_block_network.setToolTip(tr("settings.sandbox_block_network_tooltip"))
sbl.addWidget(self.sandbox_block_network) sbl.addWidget(self.sandbox_block_network)
@@ -151,13 +152,13 @@ class SettingsDialog(QDialog):
# they belong with the other tool toggles — see ToolsAdminTab). # they belong with the other tool toggles — see ToolsAdminTab).
# --- Enable/Disable Agent Security --- # --- Enable/Disable Agent Security ---
self.sec_enabled = QCheckBox("Enable Agent Security (command validation)") self.sec_enabled = ToggleSwitch("Enable Agent Security (command validation)")
self.sec_enabled.setChecked(bool(sec.get("enabled", True))) self.sec_enabled.setChecked(bool(sec.get("enabled", True)))
self.sec_enabled.setToolTip("Bật/tắt toàn bộ Agent Security") self.sec_enabled.setToolTip("Bật/tắt toàn bộ Agent Security")
sbl.addWidget(self.sec_enabled) sbl.addWidget(self.sec_enabled)
# --- AI Command Check toggle --- # --- AI Command Check toggle ---
self.ai_check = QCheckBox("AI check commands") self.ai_check = ToggleSwitch("AI check commands")
self.ai_check.setChecked(bool(sec.get("command_ai_check", False))) self.ai_check.setChecked(bool(sec.get("command_ai_check", False)))
self.ai_check.setToolTip("Cho AI control-agent xét lệnh trước khi chạy") self.ai_check.setToolTip("Cho AI control-agent xét lệnh trước khi chạy")
sbl.addWidget(self.ai_check) sbl.addWidget(self.ai_check)
@@ -312,28 +313,52 @@ class SettingsDialog(QDialog):
note.setWordWrap(True) # otherwise this one line sets the dialog's width note.setWordWrap(True) # otherwise this one line sets the dialog's width
root.addWidget(note) root.addWidget(note)
scroll.setWidget(self._content) # Left list + right panel: one group on screen at a time, the way the
# Five group boxes in one column, with no way to see what was further # audit page's mock-up shows it. The five rows are the five real group
# down — the index says what is in here and jumps straight to it. # boxes, so "which group am I in, how many left" is answerable at a
from .widgets import section_index # glance instead of by scrolling to find out.
self.section_list = section_index(scroll, [ from .widgets import section_panels
(tr("settings.group.general"), self._anchor_general),
(tr("settings.group.provider"), prov_group), self._general_box = QWidget()
(tr("settings.group.sandbox"), self.sandbox_group), gv = QVBoxLayout(self._general_box)
(tr("settings.group.parameter"), param_group), gv.setContentsMargins(0, 0, 0, 0)
(tr("routing.settings_group"), routing_group), root.removeWidget(self._anchor_general)
]) root.removeItem(top)
gv.addLayout(top)
gv.addWidget(note) # the tip belongs with the general settings
gv.addStretch(1)
root.removeWidget(note)
pages = []
for label, widget in ((tr("settings.group.general"), self._general_box),
(tr("settings.group.provider"), prov_group),
(tr("settings.group.sandbox"), self.sandbox_group),
(tr("settings.group.parameter"), param_group),
(tr("routing.settings_group"), routing_group)):
root.removeWidget(widget)
page = QWidget()
pv = QVBoxLayout(page)
pv.setContentsMargins(4, 4, 4, 4)
pv.addWidget(widget)
pv.addStretch(1)
wrap = QScrollArea()
wrap.setWidgetResizable(True)
wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
wrap.setWidget(page)
pages.append((label, wrap))
self.section_list, self.section_stack = section_panels(pages)
scroll.setParent(None)
body = QHBoxLayout() body = QHBoxLayout()
body.setSpacing(10) body.setSpacing(10)
body.addWidget(self.section_list) body.addWidget(self.section_list)
body.addWidget(scroll, 1) body.addWidget(self.section_stack, 1)
outer.addLayout(body, 1) outer.addLayout(body, 1)
# Floor the dialog at the width its own content needs AT THE CURRENT self._content = self._general_box # kept for other callers
# FONT. On a 125%/150% display everything is wider, and without this the # Floor the dialog at the width its WIDEST page needs, at the current
# form was simply cut off (or scrolled sideways) instead of the window # font. On a 125%/150% display everything is wider, and without this the
# refusing to get that small. # form was simply cut off instead of the window refusing to get smaller.
self.setMinimumWidth(self.section_list.width() widest = max(w.widget().sizeHint().width() for _lab, w in pages)
+ self._content.sizeHint().width() + 60) self.setMinimumWidth(self.section_list.width() + widest + 60)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._save) buttons.accepted.connect(self._save)
+25 -20
View File
@@ -436,34 +436,39 @@ class TaskEditorDialog(QDialog):
# boxes are re-parented into three pages — none is dropped, they are # boxes are re-parented into three pages — none is dropped, they are
# grouped by the question being answered rather than stacked in one # grouped by the question being answered rather than stacked in one
# scroll where the later ones are out of sight. # scroll where the later ones are out of sight.
self.step_tabs = QTabWidget() # Left list + right panel, navigated exactly like Settings — five rows
self.step_tabs.setObjectName("taskSteps") # matching the five real group boxes, so you always see which group you
pages = [ # are in and how many are left. (Not tabs: the audit page asks for this
("schedtask.step_content", [self._basic_box, ig]), # shape specifically, for consistency with Settings.)
("schedtask.step_schedule", [sg]), from .widgets import section_panels
("schedtask.step_link", [dg, eg]),
] self._step_keys = ["schedtask.g_basic", "schedtask.g_schedule",
self._step_keys = [key for key, _ in pages] "schedtask.g_input", "schedtask.g_dependency",
for _key, groups in pages: "schedtask.g_execution"]
pages = []
for key, group in zip(self._step_keys, [self._basic_box, sg, ig, dg, eg]):
page = QWidget() page = QWidget()
pv = QVBoxLayout(page) pv = QVBoxLayout(page)
pv.setContentsMargins(4, 8, 4, 4) pv.setContentsMargins(4, 4, 4, 4)
for g in groups: root.removeWidget(group)
root.removeWidget(g) pv.addWidget(group)
pv.addWidget(g)
pv.addStretch(1) pv.addStretch(1)
wrap = QScrollArea() wrap = QScrollArea()
wrap.setWidgetResizable(True) wrap.setWidgetResizable(True)
wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
wrap.setWidget(page) wrap.setWidget(page)
self.step_tabs.addTab(wrap, "") pages.append((tr(key), wrap))
self.section_list, self.section_stack = section_panels(pages)
outer.removeWidget(scroll) outer.removeWidget(scroll)
scroll.setParent(None) scroll.setParent(None)
outer.insertWidget(0, self.step_tabs, 1) body = QHBoxLayout()
self._retranslate_steps() body.setSpacing(10)
body.addWidget(self.section_list)
body.addWidget(self.section_stack, 1)
outer.insertLayout(0, body, 1)
# Floor the width at what the widest page needs, at the font in use. # Floor the width at what the widest page needs, at the font in use.
self.setMinimumWidth(max(p.widget().sizeHint().width() widest = max(w.widget().sizeHint().width() for _lab, w in pages)
for p in self.step_tabs.findChildren(QScrollArea)) + 60) self.setMinimumWidth(self.section_list.width() + widest + 60)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Save).setIcon(icon("save")) buttons.button(QDialogButtonBox.Save).setIcon(icon("save"))
@@ -478,9 +483,9 @@ class TaskEditorDialog(QDialog):
guard_wheel(self) guard_wheel(self)
def _retranslate_steps(self) -> None: def _retranslate_steps(self) -> None:
"""Name the three step tabs for the current language.""" """Re-label the five section rows for the current language."""
for i, key in enumerate(self._step_keys): for i, key in enumerate(self._step_keys):
self.step_tabs.setTabText(i, f"{i + 1}. {tr(key)}") self.section_list.item(i).setText(tr(key))
def _apply_hints(self) -> None: def _apply_hints(self) -> None:
"""Tooltip hints on every non-obvious control, so each option explains """Tooltip hints on every non-obvious control, so each option explains
+142 -1
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, Qt, Signal from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, Qt, Signal
from PySide6.QtGui import QColor, QPainter, QPen from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractSpinBox, QComboBox, QDoubleSpinBox, QFrame, QAbstractSpinBox, QCheckBox, QComboBox, QDoubleSpinBox, QFrame,
QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
QVBoxLayout, QWidget, QVBoxLayout, QWidget,
) )
@@ -209,6 +209,147 @@ def narrow_guard(owner: QWidget, threshold: int, apply):
return _NarrowGuard(owner, threshold, apply) return _NarrowGuard(owner, threshold, apply)
class ToggleSwitch(QCheckBox):
"""A checkbox drawn as an on/off switch.
Subclasses QCheckBox rather than replacing it, so every ``isChecked()`` /
``setChecked()`` / ``stateChanged`` call site keeps working untouched — only
the painting changes. A switch reads as "this is on or off" where a tick box
reads as "this is selected", which is what these settings actually mean.
"""
_W, _H = 34, 18
def __init__(self, text: str = "", parent=None):
super().__init__(text, parent)
self.setCursor(Qt.PointingHandCursor)
def sizeHint(self): # noqa: N802 - Qt override
base = super().sizeHint()
base.setWidth(base.width() + self._W)
base.setHeight(max(base.height(), self._H + 4))
return base
def paintEvent(self, _e): # noqa: N802 - Qt override
from ..theme import current_palette
p = current_palette()
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
y = (self.height() - self._H) // 2
track = QRectF(0, y, self._W, self._H)
on = self.isChecked()
enabled = self.isEnabled()
fill = QColor(p.accent_solid if on else p.border_strong)
if not enabled:
fill.setAlpha(110)
painter.setPen(Qt.NoPen)
painter.setBrush(fill)
painter.drawRoundedRect(track, self._H / 2, self._H / 2)
knob = self._H - 4
kx = self._W - knob - 2 if on else 2
painter.setBrush(QColor("#FFFFFF" if enabled else p.text_faint))
painter.drawEllipse(QRectF(kx, y + 2, knob, knob))
if self.text():
painter.setPen(QColor(p.text if enabled else p.text_faint))
painter.drawText(
QRectF(self._W + 8, 0, self.width() - self._W - 8, self.height()),
int(Qt.AlignLeft | Qt.AlignVCenter), self.text())
painter.end()
class SegmentedControl(QWidget):
"""Two-to-four choices shown side by side instead of hidden in a drop-list.
Exposes the slice of the QComboBox API this app's settings code uses
(addItem / findData / currentData / setCurrentIndex / currentIndexChanged),
so it drops into an existing form without touching the save/load paths.
"""
currentIndexChanged = Signal(int)
def __init__(self, parent=None):
super().__init__(parent)
self._data: list = []
self._buttons: list = []
self._current = -1
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
self._lay = lay
lay.addStretch(1)
def addItem(self, text: str, data=None) -> None: # noqa: N802 - Qt-style name
from PySide6.QtWidgets import QPushButton
btn = QPushButton(text)
btn.setObjectName("segItem")
btn.setCheckable(True)
btn.setCursor(Qt.PointingHandCursor)
index = len(self._buttons)
btn.clicked.connect(lambda _c=False, i=index: self.setCurrentIndex(i))
self._lay.insertWidget(index, btn)
self._buttons.append(btn)
self._data.append(data)
if self._current < 0:
self.setCurrentIndex(0)
def findData(self, value) -> int: # noqa: N802
return self._data.index(value) if value in self._data else -1
def currentData(self): # noqa: N802
return self._data[self._current] if 0 <= self._current < len(self._data) else None
def currentIndex(self) -> int: # noqa: N802
return self._current
def count(self) -> int:
return len(self._buttons)
def setItemText(self, index: int, text: str) -> None: # noqa: N802
if 0 <= index < len(self._buttons):
self._buttons[index].setText(text)
def setCurrentIndex(self, index: int) -> None: # noqa: N802
if not (0 <= index < len(self._buttons)) or index == self._current:
for i, b in enumerate(self._buttons):
b.setChecked(i == self._current)
return
self._current = index
for i, b in enumerate(self._buttons):
b.setChecked(i == index)
self.currentIndexChanged.emit(index)
def section_panels(sections, width: int = 260):
"""Left list + right panel: pick a section, see that section only.
``sections`` is [(label, widget)]. Returns (list_widget, stack) for the
caller to place side by side. Used by Settings and the Task editor so both
are navigated the same way, instead of one long scroll where you cannot
tell which group you are in or how many are left.
"""
from PySide6.QtWidgets import QListWidget, QListWidgetItem, QStackedWidget
index = QListWidget()
index.setObjectName("sectionIndex")
index.setFrameShape(QListWidget.NoFrame)
index.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
index.setTextElideMode(Qt.ElideRight)
index.setWordWrap(False)
stack = QStackedWidget()
for label, widget in sections:
item = QListWidgetItem(label)
item.setToolTip(label)
index.addItem(item)
stack.addWidget(widget)
index.currentRowChanged.connect(stack.setCurrentIndex)
index.setCurrentRow(0)
natural = max(index.fontMetrics().horizontalAdvance(lab) for lab, _w in sections) + 36
index.setFixedWidth(max(120, min(width, natural)))
return index, stack
def section_index(scroll, sections, width: int = 260): def section_index(scroll, sections, width: int = 260):
"""A clickable table of contents for a long scrolling dialog. """A clickable table of contents for a long scrolling dialog.