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:
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user