From bea54523634e22c27cd94cbc3b2209529f896d79 Mon Sep 17 00:00:00 2001 From: FlintWave Date: Tue, 2 Jun 2026 00:54:12 -0700 Subject: [PATCH] fix(gui): center the wizard, and show returning users only the new feature (26.06.01) Fix a 26.06.00 regression where the setup wizard could open off-center, and stop showing existing users the whole first-run flow after an update. - onboarding_dialog.py: center the wizard over the main window on first show, falling back to the active screen when the parent is not yet mapped (the cause of the off-center open). - A returning user (re-onboarded after an ONBOARDING_VERSION bump) now sees ONLY the new feature's page, with its activation toggle, so they can review and enable it, instead of replaying welcome/privacy/browser/etc. New installs get the personalization opt-in as the LAST setup step. - Document the convention in CONTRIBUTING (bump ONBOARDING_VERSION + add the page when a feature adds an opt-in setting users should review), so future settings-bearing features surface to existing users at release. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++ CONTRIBUTING.md | 17 ++++++ pyproject.toml | 2 +- .../gui/onboarding_dialog.py | 56 +++++++++++-------- src/searchmob_desktop/version.py | 2 +- tests/gui/test_onboarding_dialog.py | 15 +++++ 6 files changed, 76 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d687dc..d5575ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to SearchMob Desktop are documented here. The version scheme is Ubuntu-style `YY.MM.VV` and releases are tagged `vYY.MM.VV`. +## 26.06.01 — 2026-06-02 + +### Fixed +- **Setup wizard now opens centered over the main window** again, with a screen-centered fallback if + the main window is not yet on screen, fixing a 26.06.00 regression where it could open off-center. +- **The "what's new" update prompt is no longer the first thing existing users see.** After an update + that adds a new opt-in setting, the wizard now shows returning users **only** the new feature's page + (with its toggle) so they can review and enable it, instead of replaying the whole first-run setup. + New installs see the personalization opt-in as the **last** step of setup rather than an early one. + ## 26.06.00 — 2026-06-01 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 083f3d6..f2fc1fc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,23 @@ searchmob-desktop --help # invoke the CLI - No em dashes (— –) anywhere in source, comments, docs, or UI strings; use plain punctuation. - Type-annotated where it helps the reader; `mypy --strict` runs in CI. +## Surfacing new opt-in settings to existing users + +When a feature adds a new **opt-in setting that users should review and choose** (a privacy or +ranking toggle, a new data-storing option, anything they would want to know exists), make the setup +wizard show it to people who already onboarded, not just fresh installs: + +1. Add the feature's page to the wizard (`gui/onboarding_dialog.py`) and bump `ONBOARDING_VERSION` + in the same change. +2. The wizard re-appears **once** for any user whose saved `onboarding_version` is behind, showing + **only** the new feature's page (with its activation toggle) so they can review and enable it. + New installs see the same page as the last step of first-run setup. +3. Keep the toggle **off by default** and persist it the moment it is changed, so nothing is enabled + unless the user actually opts in. + +Treat this as part of "done" for any settings-bearing feature, and confirm it during release review. +The Android app mirrors this with `ONBOARDING_VERSION` in `ui/onboarding/OnboardingState.kt`. + ## Releases (maintainers) Releases follow Ubuntu-style `YY.MM.VV` versioning. Bump `__version__` in diff --git a/pyproject.toml b/pyproject.toml index e454fc3..f951580 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,7 @@ follow_imports = "skip" [tool.briefcase] project_name = "SearchMob Desktop" bundle = "com.flintwave" -version = "26.06.00" +version = "26.06.01" url = "https://github.com/FlintWave/SearchMob-Desktop" author = "FlintWave" author_email = "flintwave@tuta.com" diff --git a/src/searchmob_desktop/gui/onboarding_dialog.py b/src/searchmob_desktop/gui/onboarding_dialog.py index 7ab8a20..a98845a 100644 --- a/src/searchmob_desktop/gui/onboarding_dialog.py +++ b/src/searchmob_desktop/gui/onboarding_dialog.py @@ -11,7 +11,7 @@ from dataclasses import replace -from PySide6.QtGui import QShowEvent +from PySide6.QtGui import QGuiApplication, QShowEvent from PySide6.QtWidgets import ( QCheckBox, QDialog, @@ -50,11 +50,13 @@ def __init__( self._prefs_store = prefs_store self._server_controller = server_controller self._prefs = prefs_store.load() - # A returning user (already onboarded once) is being re-shown the wizard after an update, so - # the welcome copy frames it as "what's new" rather than a first-time setup. + # A returning user (already onboarded once) is re-shown the wizard after an update only to + # surface the new opt-in feature, so they get just that page, not the whole setup again. self._returning = self._prefs.onboarding_completed - self.setWindowTitle("Welcome to SearchMob") + self.setWindowTitle( + "What's new in SearchMob" if self._returning else "Welcome to SearchMob" + ) self.setModal(True) self.resize(640, 560) @@ -72,15 +74,19 @@ def __init__( top.addWidget(skip) outer.addLayout(top) - # Build the page list. The service page is included only on platforms that support one, so - # the wizard never shows steps a user cannot act on. + # Build the page list. A returning user (re-onboarded after an update) sees only the new + # feature; a first-run user gets the full setup with the personalization opt-in as the last + # step (not an early interruption). The service page is included only where supported. self._stack = QStackedWidget(self) - self._stack.addWidget(self._welcome_page()) - self._stack.addWidget(self._privacy_page()) - self._stack.addWidget(self._personalize_page()) - self._stack.addWidget(self._browser_page()) - if service.is_supported(): - self._stack.addWidget(self._service_page()) + if self._returning: + self._stack.addWidget(self._personalize_page()) + else: + self._stack.addWidget(self._welcome_page()) + self._stack.addWidget(self._privacy_page()) + self._stack.addWidget(self._browser_page()) + if service.is_supported(): + self._stack.addWidget(self._service_page()) + self._stack.addWidget(self._personalize_page()) outer.addWidget(self._stack, stretch=1) nav = QHBoxLayout() @@ -101,15 +107,24 @@ def showEvent(self, event: QShowEvent) -> None: """Center the wizard over the main window on first show. A parented modal is not reliably centered by every window manager (it can open in a corner), - so we position it explicitly over the parent's frame the first time it appears. + so we position it explicitly the first time it appears: over the parent's frame when the + parent is actually on screen, otherwise over the active screen as a fallback (the parent may + not be mapped yet when the wizard is shown right after launch). """ super().showEvent(event) + if self._centered: + return + self._centered = True + geo = self.frameGeometry() parent = self.parentWidget() - if not self._centered and parent is not None: - geo = self.frameGeometry() + if parent is not None and parent.isVisible(): geo.moveCenter(parent.frameGeometry().center()) - self.move(geo.topLeft()) - self._centered = True + else: + screen = self.screen() or QGuiApplication.primaryScreen() + if screen is None: + return + geo.moveCenter(screen.availableGeometry().center()) + self.move(geo.topLeft()) # --- Page builders ----------------------------------------------------------------------- @@ -132,13 +147,6 @@ def _page(heading: str, body: str, extra: QWidget | None = None) -> QWidget: return page def _welcome_page(self) -> QWidget: - if self._returning: - return self._page( - "What's new in SearchMob", - "There is a new feature worth a look: SearchMob can now learn from your clicks to " - "personalize ranking, privately and on-device. Step through to turn it on, or skip " - "to keep things as they are. Your existing settings are unchanged.", - ) return self._page( "Welcome to SearchMob", "Private, on-device metasearch. Your searches are aggregated from several engines " diff --git a/src/searchmob_desktop/version.py b/src/searchmob_desktop/version.py index d03cbc4..2b4a50e 100644 --- a/src/searchmob_desktop/version.py +++ b/src/searchmob_desktop/version.py @@ -3,4 +3,4 @@ # Ubuntu-style scheme matching the Android app: YY.MM.VV (two-digit year, # month, and per-month build). First desktop release is 26.05.00. Bump # manually each release; hatchling reads __version__ from this file. -__version__ = "26.06.00" +__version__ = "26.06.01" diff --git a/tests/gui/test_onboarding_dialog.py b/tests/gui/test_onboarding_dialog.py index 1c13c62..f946ca9 100644 --- a/tests/gui/test_onboarding_dialog.py +++ b/tests/gui/test_onboarding_dialog.py @@ -60,6 +60,21 @@ def test_personalize_page_toggle_persists_immediately(qapp: object, tmp_path: Pa assert store.load().personalization_enabled is True +def test_returning_user_sees_only_the_new_feature_page(qapp: object, tmp_path: Path) -> None: + # A re-onboarded user (already completed onboarding before the version bump) gets just the + # personalization page so they can activate the new feature, not the whole first-run setup. + from dataclasses import replace + + store = _store(tmp_path) + store.save(replace(store.load(), onboarding_completed=True)) + dialog = OnboardingDialog(prefs_store=store) + assert dialog._stack.count() == 1 + # The single page carries the activation toggle (so they can turn the feature on). + assert dialog._personalize_check is not None + dialog._personalize_check.setChecked(True) + assert store.load().personalization_enabled is True + + def test_skip_also_persists_completed_flag(qapp: object, tmp_path: Path) -> None: # Skip is wired to the same _finish handler; a skipped wizard must not reappear. store = _store(tmp_path)