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
from the list above so "occasional" reads apart from "everyday". */
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). */
QListWidget#sectionIndex { background: $surface; border-right: 1px solid $border; }
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
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():
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
@@ -213,6 +236,7 @@ def main() -> int:
# --- 9/15 Monitoring ---
from PySide6.QtWidgets import QHBoxLayout, QScrollArea
from PySide6.QtWidgets import QSpinBox as QSpinBoxT
ov = mon.findChildren(QScrollArea)[0].widget()
one_col = not isinstance(ov.layout(), QHBoxLayout)
add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col,
@@ -232,15 +256,31 @@ def main() -> int:
s = SettingsDialog(win.ctx)
add("dialog-settings", "Thêm cột mục lục bên trái",
s.section_list.count() == 5, f"{s.section_list.count()} mục")
have = [n for n in ("provider_combo", "language_combo", "theme_combo")
if getattr(s, n, None) is not None]
add("dialog-settings", "Gom Provider/Ngôn ngữ/Giao diện vào Settings",
len(have) == 3, f"{have} (cũng có ở hàng tài khoản trên rail)")
# The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider
# left as its own group — not everything merged together.
from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch
in_general = s._general_box.isAncestorOf(s.language_combo) and \
s._general_box.isAncestorOf(s.theme_combo)
prov_apart = not s._general_box.isAncestorOf(s.provider_combo)
add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng",
in_general and prov_apart,
f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}")
n_switch = len(s.findChildren(ToggleSwitch))
n_seg = len(s.findChildren(SegmentedControl))
steppers = [w for w in s.findChildren(QSpinBoxT) if w.buttonSymbols() != 2]
add("dialog-settings",
"Field theo đúng loại: checkbox→toggle, dropdown 2–4 giá trị→segmented, số→stepper",
n_switch >= 6 and n_seg >= 2 and len(steppers) >= 1,
f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper")
s.close()
t = TaskEditorDialog(ctx=win.ctx)
steps = [t.step_tabs.tabText(i) for i in range(t.step_tabs.count())]
add("dialog-task-editor", "Chia 3 bước có tab: Nội dung → Lịch chạy → Liên kết",
len(steps) == 3, " · ".join(steps))
rows = [t.section_list.item(i).text() for i in range(t.section_list.count())]
like_settings = (t.section_list.count() == 5
and t.section_stack.count() == 5
and not hasattr(t, "step_tabs"))
add("dialog-task-editor",
"Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group",
like_settings, " · ".join(rows))
t.close()
# --- 27 help dock ---
@@ -269,6 +309,19 @@ def main() -> int:
dock_top + dock.height() <= comp_top,
f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}")
# --- coverage: is every bullet ON THE PAGE actually probed? -------------
proposals = page_proposals()
probed = {}
for slug, text, _v, _e in R:
probed.setdefault(slug, 0)
probed[slug] += 1
gaps = []
for slug, bullets in proposals.items():
n_probe = probed.get(slug, 0)
if len(bullets) > n_probe:
for extra in bullets[n_probe:]:
gaps.append((slug, extra))
# --- report ---
order = ["OK", "KHAC", "CHUA", "TAY"]
counts = {k: 0 for k in order}
@@ -280,7 +333,15 @@ def main() -> int:
cur = slug
print(f" [{verdict:4}] {text}")
print(f" {ev}")
if gaps:
print()
print(f"*** DE XUAT TREN TRANG CHUA CO PHEP DO: {len(gaps)} ***")
for slug, text in gaps:
print(f" {slug}")
print(f" {text[:160]}")
print()
print(f"de xuat tren trang : {sum(len(v) for v in proposals.values())}"
f" · da co phep do : {len(R)}")
print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order))
print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})")
print(f" KHAC = co y lam khac, da ghi ly do")
+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
input control must still be there, and every index row must actually scroll to
its section. Both dialogs are built with .show(), never .exec() — exec() blocks.
Both dialogs are navigated the same way, as the audit page asks: a list of the
real group boxes on the left, one panel shown at a time on the right. So the
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
"""
@@ -19,69 +20,55 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _isolate_home, _load_fonts # noqa: E402
def controls(dlg):
from PySide6.QtWidgets import (QCheckBox, QComboBox, QLineEdit, QListWidget,
QPlainTextEdit, QPushButton, QSpinBox)
n = 0
for cls in (QComboBox, QLineEdit, QCheckBox, QSpinBox, QPlainTextEdit,
QPushButton, QListWidget):
n += len(dlg.findChildren(cls))
return n
# Every field each dialog must still offer after the move.
SETTINGS_FIELDS = [
"language_combo", "theme_combo", "tray_chk", "notify_chk",
"provider_combo", "prov_base", "prov_key", "prov_model",
"sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_confirm",
"sandbox_block_network", "sec_enabled", "ai_check",
]
TASK_FIELDS = [
"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):
"""The Task editor uses step TABS, not an index — same goal, different
control, so it gets its own check."""
def check(name, dlg, app, expect_rows, fields):
fails = []
print(f"--- {name} ---")
n_ctl = controls(dlg)
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
idx, stack = dlg.section_list, dlg.section_stack
rows = [idx.item(i).text() for i in range(idx.count())]
print(f"muc luc : {rows}")
print(f"tong control trong hop thoai: {n_ctl}")
print(f"muc : {rows}")
if len(rows) != expect_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):
fails.append(f"{name}: co muc chua dich")
if idx.count() != stack.count():
fails.append(f"{name}: {idx.count()} muc nhung {stack.count()} panel")
# Each row must scroll somewhere different (and the last one furthest down).
from PySide6.QtWidgets import QScrollArea
scroll = dlg.findChildren(QScrollArea)[0]
positions = []
# Picking a row must swap the panel — and each panel must hold something.
swapped, empty = 0, []
for i in range(idx.count()):
idx.itemClicked.emit(idx.item(i))
app.processEvents()
positions.append(scroll.verticalScrollBar().value())
print(f"vi tri cuon theo tung muc : {positions}")
if positions != sorted(positions):
fails.append(f"{name}: muc luc nhay khong theo thu tu tren xuong")
if len(set(positions)) < 2:
fails.append(f"{name}: bam muc nao cung dung mot cho — muc luc khong chay")
return n_ctl, fails
idx.setCurrentRow(i)
for _ in range(3):
app.processEvents()
if stack.currentIndex() == i:
swapped += 1
page = stack.widget(i).widget()
if not page.findChildren(type(page)):
empty.append(rows[i])
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:
@@ -101,31 +88,32 @@ def main() -> int:
set_language("vi")
ctx = AppContext(AppConfig.load())
fails = []
s = SettingsDialog(ctx)
s.resize(900, 600)
s.resize(900, 640)
s.show()
app.processEvents()
n_s, f = check("Cai dat", s, app, 5)
fails += f
fails += check("Cai dat", s, app, 5, SETTINGS_FIELDS)
t = TaskEditorDialog(ctx=ctx) # task=None → a new task, all fields present
t.resize(900, 600)
t = TaskEditorDialog(ctx=ctx)
t.resize(900, 640)
t.show()
app.processEvents()
n_t, f = check_tabs("Task editor", t, app, 3)
fails += f
fails += check("Task editor", t, app, 5, TASK_FIELDS)
# 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(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"):
set_language(lang)
print(f" {lang}: general={tr('settings.group.general')!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")
print()
@@ -134,7 +122,7 @@ def main() -> int:
for x in fails:
print(" " + x)
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
+10 -7
View File
@@ -34,17 +34,20 @@ def hscroll(dlg, app):
"""
from PySide6.QtWidgets import QListWidget, QScrollArea
over_area = False
tabs = getattr(dlg, "step_tabs", None)
if tabs is not None:
keep = tabs.currentIndex()
for i in range(tabs.count()):
tabs.setCurrentIndex(i)
stack = getattr(dlg, "section_stack", None)
if stack is not None:
# One scroll area per section; a page that is not current has stale
# geometry, so bring each to the front before measuring it.
idx = dlg.section_list
keep = idx.currentRow()
for i in range(stack.count()):
idx.setCurrentRow(i)
for _ in range(3):
app.processEvents()
sa = tabs.widget(i)
sa = stack.widget(i)
if sa.widget().sizeHint().width() > sa.viewport().width():
over_area = True
tabs.setCurrentIndex(keep)
idx.setCurrentRow(keep)
else:
sa = dlg.findChildren(QScrollArea)[0]
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 ..state import AppContext
from .icons import icon, IconLabel
from .widgets import SegmentedControl, ToggleSwitch
from .ext_connector_dialog import ExtConnectorEditDialog
@@ -51,7 +52,7 @@ class SettingsDialog(QDialog):
# --- language + tray ---
top = QFormLayout()
self.language_combo = QComboBox()
self.language_combo = SegmentedControl()
for key, label in LANGUAGES.items():
self.language_combo.addItem(label, key)
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
# 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.
self.theme_combo = QComboBox()
self.theme_combo = SegmentedControl()
for key in ("system", "dark", "light"):
self.theme_combo.addItem(tr(f"settings.theme_{key}"), key)
self._select_combo(self.theme_combo, getattr(ctx.config, "theme", "system"))
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)))
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)))
top.addRow("", self.notify_chk)
# Zero-height anchor so the index can scroll to this section, which is a
@@ -136,12 +137,12 @@ class SettingsDialog(QDialog):
pw_sep = QLabel("────────────────")
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.setToolTip(tr("settings.sandbox_confirm_commands_tooltip"))
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.setToolTip(tr("settings.sandbox_block_network_tooltip"))
sbl.addWidget(self.sandbox_block_network)
@@ -151,13 +152,13 @@ class SettingsDialog(QDialog):
# they belong with the other tool toggles — see ToolsAdminTab).
# --- 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.setToolTip("Bật/tắt toàn bộ Agent Security")
sbl.addWidget(self.sec_enabled)
# --- 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.setToolTip("Cho AI control-agent xét lệnh trước khi chạy")
sbl.addWidget(self.ai_check)
@@ -312,28 +313,52 @@ class SettingsDialog(QDialog):
note.setWordWrap(True) # otherwise this one line sets the dialog's width
root.addWidget(note)
scroll.setWidget(self._content)
# Five group boxes in one column, with no way to see what was further
# down — the index says what is in here and jumps straight to it.
from .widgets import section_index
self.section_list = section_index(scroll, [
(tr("settings.group.general"), self._anchor_general),
(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),
])
# Left list + right panel: one group on screen at a time, the way the
# audit page's mock-up shows it. The five rows are the five real group
# boxes, so "which group am I in, how many left" is answerable at a
# glance instead of by scrolling to find out.
from .widgets import section_panels
self._general_box = QWidget()
gv = QVBoxLayout(self._general_box)
gv.setContentsMargins(0, 0, 0, 0)
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.setSpacing(10)
body.addWidget(self.section_list)
body.addWidget(scroll, 1)
body.addWidget(self.section_stack, 1)
outer.addLayout(body, 1)
# Floor the dialog at the width its own content needs AT THE CURRENT
# FONT. On a 125%/150% display everything is wider, and without this the
# form was simply cut off (or scrolled sideways) instead of the window
# refusing to get that small.
self.setMinimumWidth(self.section_list.width()
+ self._content.sizeHint().width() + 60)
self._content = self._general_box # kept for other callers
# Floor the dialog at the width its WIDEST page needs, at the current
# font. On a 125%/150% display everything is wider, and without this the
# form was simply cut off instead of the window refusing to get smaller.
widest = max(w.widget().sizeHint().width() for _lab, w in pages)
self.setMinimumWidth(self.section_list.width() + widest + 60)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
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
# grouped by the question being answered rather than stacked in one
# scroll where the later ones are out of sight.
self.step_tabs = QTabWidget()
self.step_tabs.setObjectName("taskSteps")
pages = [
("schedtask.step_content", [self._basic_box, ig]),
("schedtask.step_schedule", [sg]),
("schedtask.step_link", [dg, eg]),
]
self._step_keys = [key for key, _ in pages]
for _key, groups in pages:
# Left list + right panel, navigated exactly like Settings — five rows
# matching the five real group boxes, so you always see which group you
# are in and how many are left. (Not tabs: the audit page asks for this
# shape specifically, for consistency with Settings.)
from .widgets import section_panels
self._step_keys = ["schedtask.g_basic", "schedtask.g_schedule",
"schedtask.g_input", "schedtask.g_dependency",
"schedtask.g_execution"]
pages = []
for key, group in zip(self._step_keys, [self._basic_box, sg, ig, dg, eg]):
page = QWidget()
pv = QVBoxLayout(page)
pv.setContentsMargins(4, 8, 4, 4)
for g in groups:
root.removeWidget(g)
pv.addWidget(g)
pv.setContentsMargins(4, 4, 4, 4)
root.removeWidget(group)
pv.addWidget(group)
pv.addStretch(1)
wrap = QScrollArea()
wrap.setWidgetResizable(True)
wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
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)
scroll.setParent(None)
outer.insertWidget(0, self.step_tabs, 1)
self._retranslate_steps()
body = QHBoxLayout()
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.
self.setMinimumWidth(max(p.widget().sizeHint().width()
for p in self.step_tabs.findChildren(QScrollArea)) + 60)
widest = max(w.widget().sizeHint().width() for _lab, w in pages)
self.setMinimumWidth(self.section_list.width() + widest + 60)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Save).setIcon(icon("save"))
@@ -478,9 +483,9 @@ class TaskEditorDialog(QDialog):
guard_wheel(self)
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):
self.step_tabs.setTabText(i, f"{i + 1}. {tr(key)}")
self.section_list.item(i).setText(tr(key))
def _apply_hints(self) -> None:
"""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.QtGui import QColor, QPainter, QPen
from PySide6.QtWidgets import (
QAbstractSpinBox, QComboBox, QDoubleSpinBox, QFrame,
QAbstractSpinBox, QCheckBox, QComboBox, QDoubleSpinBox, QFrame,
QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
QVBoxLayout, QWidget,
)
@@ -209,6 +209,147 @@ def narrow_guard(owner: QWidget, threshold: int, 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):
"""A clickable table of contents for a long scrolling dialog.