diff --git a/theme.py b/theme.py
index 1d46d90..8f488b5 100644
--- a/theme.py
+++ b/theme.py
@@ -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; }
diff --git a/tools/check_design_parity.py b/tools/check_design_parity.py
index ee68ad7..d335642 100644
--- a/tools/check_design_parity.py
+++ b/tools/check_design_parity.py
@@ -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'', html, re.S):
+ block = re.search(r'Thay đổi
', m.group(2), re.S)
+ if not block:
+ continue
+ out[m.group(1)] = [
+ re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", li)).strip()
+ for li in re.findall(r"(.*?)", block.group(1), re.S)]
+ return out
+
+
def build():
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
@@ -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")
diff --git a/tools/check_dialogs.py b/tools/check_dialogs.py
index 789558d..247c01a 100644
--- a/tools/check_dialogs.py
+++ b/tools/check_dialogs.py
@@ -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
diff --git a/tools/check_no_hscroll.py b/tools/check_no_hscroll.py
index 1cfbf58..6e92c26 100644
--- a/tools/check_no_hscroll.py
+++ b/tools/check_no_hscroll.py
@@ -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()
diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py
index 90455a3..1057a14 100644
--- a/ui/settings_dialog.py
+++ b/ui/settings_dialog.py
@@ -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)
diff --git a/ui/task_editor_dialog.py b/ui/task_editor_dialog.py
index 13919ea..3628186 100644
--- a/ui/task_editor_dialog.py
+++ b/ui/task_editor_dialog.py
@@ -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
diff --git a/ui/widgets.py b/ui/widgets.py
index 32e3882..db1f8f2 100644
--- a/ui/widgets.py
+++ b/ui/widgets.py
@@ -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.