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.