From dbb7e227232c3d81479a7725a0c2d5176849c122 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:18:32 +0200 Subject: [PATCH] feat(settings): notice when turning on the high resolution cursor; 0.29.0 Turning the High resolution cursor on shows a short notice first: it is experimental, Windows' smooth scaling can make the view and pointer shake slightly while zooming, and Wind limits some features (the release glide stops a little early). Turn on / Cancel, with "Don't show this again" saved as the global UI-only key uiHighResNoticeOff (never reloads the core, never travels with a profile). Turning it off never asks; the search results behave the same. Closes #441 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/02-tick-loop.md | 9 +- docs/architecture/08-config-profiles.md | 12 +- docs/architecture/09-settings-ui.md | 5 + src/config.cpp | 5 +- src/config.h | 5 +- src/profiles.cpp | 5 +- src/profiles.h | 3 +- src/version.h | 4 +- tests/test_config.cpp | 5 + tests/test_profiles.cpp | 9 +- ui/src/Settings.svelte | 21 ++++ ui/src/controls/HighRes.svelte | 12 +- ui/src/prompts/Prompt.svelte | 15 ++- ui/src/session.js | 3 +- ui/tests/highres-notice.spec.js | 143 ++++++++++++++++++++++++ ui/tests/session.spec.js | 3 +- ui/tests/settings.spec.js | 3 +- 17 files changed, 237 insertions(+), 25 deletions(-) create mode 100644 ui/tests/highres-notice.spec.js diff --git a/docs/architecture/02-tick-loop.md b/docs/architecture/02-tick-loop.md index 7a550764..5b32e5a0 100644 --- a/docs/architecture/02-tick-loop.md +++ b/docs/architecture/02-tick-loop.md @@ -108,10 +108,11 @@ There is no settings IPC. `WindConfig.exe` writes `magnifier.ini` and the core n **UI-only writes never reload.** A reload rebuilds `ZoomController` and `CursorMapper`, which is wasted work and risks a visible hitch mid-zoom (the live level and mapper centre are carried over -since #234, so the view no longer collapses to 1x). `StripUiOnlyKeys` (`src/config.cpp`) drops `uiTheme`, `uiPalette`, `showAdvanced`, -`onboarded`, `trayPinned` and the five tray layout keys (`trayPerf`, `traySliders`, `traySliderOrder`, -`trayToggles`, `trayToggleOrder`), and the result is compared with the fingerprint of the last applied config -(`t.lastCoreIni`). An identical fingerprint skips the reload. The fingerprint is seeded at startup; +since #234, so the view no longer collapses to 1x). `StripUiOnlyKeys` (`src/config.cpp`) drops +`uiTheme`, `uiPalette`, +`showAdvanced`, `onboarded`, `trayPinned`, `uiHighResNoticeOff` and the five tray layout keys +(`trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`), and the result is compared with the fingerprint of the last applied +config (`t.lastCoreIni`). An identical fingerprint skips the reload. The fingerprint is seeded at startup; an empty one would make the first Settings write of a session reload. A real reload re-binds the hook's buttons and swallowed keys (`g_input.setButtonBinds`/`setKeys`), diff --git a/docs/architecture/08-config-profiles.md b/docs/architecture/08-config-profiles.md index 086b8903..dfdbc47d 100644 --- a/docs/architecture/08-config-profiles.md +++ b/docs/architecture/08-config-profiles.md @@ -73,8 +73,9 @@ as an initializer, so a missing or malformed key keeps the default; parsing neve ## Hot-reload and the UI-only fingerprint The reload mechanics are in [02](02-tick-loop.md). A reload rebuilds `ZoomController`, so it -collapses an active zoom; therefore `StripUiOnlyKeys` removes the ten keys the core never reads -(`uiTheme`, `uiPalette`, `showAdvanced`, `onboarded`, `trayPinned` and the five tray layout keys `trayPerf`, +collapses an active zoom; therefore `StripUiOnlyKeys` removes the eleven keys the core never reads +(`uiTheme`, `uiPalette`, `showAdvanced`, `onboarded`, `trayPinned`, `uiHighResNoticeOff` and +the five tray layout keys `trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`) and the reload is skipped when the stripped text is unchanged. `profile` stays in the fingerprint, so a profile switch reloads. @@ -99,7 +100,8 @@ A profile is a named full snapshot of the settings, keybinds included, stored as in `src/profiles.*` (tested); I/O in `src/profiles_io.h`. **Global keys never travel with a profile.** `IsGlobalProfileKey` covers `profile`, `onboarded`, -`uiTheme`, `uiPalette`, `showAdvanced`, `trayPinned` and the five tray layout keys (`trayPerf`, `traySliders`, +`uiTheme`, `uiPalette`, `showAdvanced`, `trayPinned`, `uiHighResNoticeOff` and the five tray +layout keys (`trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`). `MakeProfileText` strips them from profile files; `MakeLiveText` carries them over from the old live text. Both work line by line and keep comments and order. @@ -203,7 +205,9 @@ Every key works in the ini whether or not Settings shows it. Keys hot-reload unl **Global and UI.** `profile`, `onboarded`, `uiPalette` (`grey`, `ember`, `ocean`, `hicon`), `showAdvanced`, `trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`, -`trayPinned` (1 = keep the tray icon on the taskbar, 0 = hidden-icons overflow; applied by WindTray). +`trayPinned` (1 = keep the tray icon on the taskbar, 0 = hidden-icons overflow; applied by WindTray), +`uiHighResNoticeOff` (1 = the High resolution cursor notice is never shown; set by its +"Don't show this again" box, #441). `uiTheme` is a legacy key, ignored. **Transform and diagnostics.** Hot unless noted; the full text is on the `Config` field. diff --git a/docs/architecture/09-settings-ui.md b/docs/architecture/09-settings-ui.md index c54ec601..50fa51ed 100644 --- a/docs/architecture/09-settings-ui.md +++ b/docs/architecture/09-settings-ui.md @@ -129,6 +129,11 @@ The live ini is the session and the profile file is the saved state ([08](08-con - The ini is shared with the tray flyout and hand edits, so the page re-reads it when the window regains focus and after a `configWriteFailed`, and reloads only if something differs. - Global keys are written directly and never count as unsaved. +- **High resolution cursor notice** (#441): `change()` in `Settings.svelte` holds back + `txSamplingMode` 0 to 1 and shows a `Prompt` (with its optional `checkLabel`/`bind:checked` + checkbox) unless the global UI-only key `uiHighResNoticeOff=1` is set. Turn on writes the + opt-out (when ticked) and then `txSamplingMode=1`; Cancel and Esc write nothing, and + `HighRes.svelte` resets the switch to the prop. Search results use the same `change()`. - **Prompts** (`ui/src/prompts/Prompt.svelte`, focus-trapped by `lib/dialog.js`): closing with unsaved changes offers Save, Discard or Keep for this session; switching profile offers Save, Discard or Cancel. Tray Quit decides from the files (`ConfirmQuit` in diff --git a/src/config.cpp b/src/config.cpp index 255a2da2..ebd77d3c 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -398,7 +398,7 @@ std::string StripUiOnlyKeys(const std::string& iniText) { // Keep in step with IsGlobalProfileKey (src/profiles.cpp) minus "profile": a test pins it. for (const char* k : { "uiTheme=", "uiPalette=", "showAdvanced=", "onboarded=", "trayPerf=", "traySliders=", "traySliderOrder=", "trayToggles=", - "trayToggleOrder=", "trayPinned=" }) { + "trayToggleOrder=", "trayPinned=", "uiHighResNoticeOff=" }) { if (line.compare(b, strlen(k), k) == 0) { uiOnly = true; break; } } } @@ -533,6 +533,9 @@ std::string DefaultIniText() { "; to Windows, normally the hidden icons overflow (default). Applied by WindTray, never\n" "; reloads the core\n" "trayPinned=0\n" + "; uiHighResNoticeOff: 1=never show the High resolution cursor notice again (its Don't\n" + "; show this again box). UI only, never reloads the core\n" + "uiHighResNoticeOff=0\n" "; cursorVisibility: auto=hide our cursor when the focused app hides its own (games);\n" "; always=always draw it; never=never draw it\n" "cursorVisibility=auto\n" diff --git a/src/config.h b/src/config.h index 709a3e5c..875f9a7c 100644 --- a/src/config.h +++ b/src/config.h @@ -523,8 +523,9 @@ bool IsForbiddenBindVk(int vk); // True when exeName (bare file name, any case) appears in a comma-separated list. Used for the // Auto/hybrid transform exclusion (fullscreen browser video must stay on the render engine). bool IsExeInList(const std::string& exeName, const std::string& list); -// The ini text with UI-ONLY lines removed (uiTheme, uiPalette, showAdvanced, onboarded and the five -// tray layout keys trayPerf, traySliders, traySliderOrder, trayToggles, trayToggleOrder): the +// The ini text with UI-ONLY lines removed (uiTheme, uiPalette, showAdvanced, onboarded, trayPinned, +// uiHighResNoticeOff and the five tray layout keys trayPerf, traySliders, traySliderOrder, +// trayToggles, trayToggleOrder): the // settings app and the tray own those keys and the core never consumes them, yet every write // hot-reloads the core - and the reload resets the ZoomController, so toggling the app theme or // editing the tray layout while zoomed collapsed the zoom to 1x (field report). The core compares this stripped form across reloads and skips diff --git a/src/profiles.cpp b/src/profiles.cpp index 60d060a5..97fe103a 100644 --- a/src/profiles.cpp +++ b/src/profiles.cpp @@ -18,7 +18,8 @@ static std::string trim(const std::string& s) { bool IsGlobalProfileKey(const std::string& key) { return key == "profile" || key == "onboarded" || key == "uiTheme" || key == "uiPalette" || key == "showAdvanced" || key == "trayPerf" || key == "traySliders" || key == "traySliderOrder" || - key == "trayToggles" || key == "trayToggleOrder" || key == "trayPinned"; + key == "trayToggles" || key == "trayToggleOrder" || key == "trayPinned" || + key == "uiHighResNoticeOff"; } std::string ProfileNameError(const std::string& name) { if (name.empty()) return "Name cannot be empty"; @@ -68,7 +69,7 @@ std::string MakeLiveText(const std::string& profileText, const std::string& oldL std::string out = StripGlobalKeyLines(profileText); auto oldVals = ReadIniValues(oldLiveText); for (const char* k : {"onboarded", "uiTheme", "uiPalette", "showAdvanced", "trayPerf", "traySliders", - "traySliderOrder", "trayToggles", "trayToggleOrder", "trayPinned"}) { + "traySliderOrder", "trayToggles", "trayToggleOrder", "trayPinned", "uiHighResNoticeOff"}) { auto it = oldVals.find(k); if (it != oldVals.end()) out = UpdateIniText(out, k, it->second); } diff --git a/src/profiles.h b/src/profiles.h index 45fbafe4..180e3465 100644 --- a/src/profiles.h +++ b/src/profiles.h @@ -6,7 +6,8 @@ #include namespace wind { // Machine/app state that never travels with a profile: the active-profile pointer itself, -// the onboarding flag, the UI-only theme/palette/advanced toggles (uiTheme, uiPalette, showAdvanced), and the five tray-menu keys (#313). +// the onboarding flag, the UI-only theme/palette/advanced toggles (uiTheme, uiPalette, showAdvanced), the five tray-menu keys (#313), +// trayPinned (#436) and uiHighResNoticeOff (#441). bool IsGlobalProfileKey(const std::string& key); // "" when the (already-trimmed) name is a valid profile name, else a short user-facing reason. std::string ProfileNameError(const std::string& name); diff --git a/src/version.h b/src/version.h index b5bf7e53..3437c2b5 100644 --- a/src/version.h +++ b/src/version.h @@ -3,8 +3,8 @@ #pragma once #define WIND_VER_MAJOR 0 -#define WIND_VER_MINOR 28 +#define WIND_VER_MINOR 29 #define WIND_VER_PATCH 0 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.28.0" +#define WIND_VERSION_STR "0.29.0" diff --git a/tests/test_config.cpp b/tests/test_config.cpp index ddc72747..cf0d8149 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -709,6 +709,11 @@ TEST_CASE("ParseConfig clamps trackGlideMs and txMaxStepPct (review #72)") { CHECK(ParseConfig("txMaxStepPct=-5\n").txMaxStepPct == 0); CHECK(ParseConfig("txMaxStepPct=99999\n").txMaxStepPct == 1000); } +TEST_CASE("uiHighResNoticeOff is UI-only: never reloads the core and never travels with a profile (#441)") { + CHECK(StripUiOnlyKeys("a=1\nuiHighResNoticeOff=0\nb=2\n") == "a=1\nb=2\n"); + CHECK(StripUiOnlyKeys("a=1\nuiHighResNoticeOff=0\n") == StripUiOnlyKeys("a=1\nuiHighResNoticeOff=1\n")); + CHECK(StripUiOnlyKeys("uiHighResNoticeOffX=1\n") == "uiHighResNoticeOffX=1\n"); +} TEST_CASE("trayPinned ships off, clamps to 0..1 and is UI-only: never reloads the core (#436)") { CHECK(ParseConfig("").trayPinned == 0); CHECK(ParseConfig("trayPinned=0\n").trayPinned == 0); diff --git a/tests/test_profiles.cpp b/tests/test_profiles.cpp index bf0d8afd..2ac1d418 100644 --- a/tests/test_profiles.cpp +++ b/tests/test_profiles.cpp @@ -214,9 +214,16 @@ TEST_CASE("ParseIniTmpName recognises WriteTextFileAtomic's leftover temp names" CHECK_FALSE(ParseIniTmpName(L".ini.1234.tmp", pid)); // empty stem CHECK_FALSE(ParseIniTmpName(L"x.ini.99999999999.tmp", pid)); // pid out of range } +TEST_CASE("uiHighResNoticeOff is global: stripped from profiles, carried into the live text (#441)") { + CHECK(IsGlobalProfileKey("uiHighResNoticeOff")); + CHECK(MakeProfileText("a=1\nuiHighResNoticeOff=1\n") == "a=1\n"); + const std::string live = MakeLiveText("a=2\n", "a=1\nuiHighResNoticeOff=1\n", "P"); + CHECK(live.find("uiHighResNoticeOff=1") != std::string::npos); + CHECK(live.find("a=2") != std::string::npos); +} TEST_CASE("StripUiOnlyKeys strips every global key except profile (tray edits must not reload the core)") { const char* globals[] = { "onboarded", "uiTheme", "uiPalette", "showAdvanced", "trayPerf", "traySliders", - "traySliderOrder", "trayToggles", "trayToggleOrder", "trayPinned" }; + "traySliderOrder", "trayToggles", "trayToggleOrder", "trayPinned", "uiHighResNoticeOff" }; for (const char* k : globals) { REQUIRE(IsGlobalProfileKey(k)); const std::string line = std::string(k) + "=1\n"; diff --git a/ui/src/Settings.svelte b/ui/src/Settings.svelte index 01be5fef..07290135 100644 --- a/ui/src/Settings.svelte +++ b/ui/src/Settings.svelte @@ -77,7 +77,20 @@ } // --- Changes -------------------------------------------------------------------------------- + // High resolution cursor notice (#441): turning it ON asks first, unless the user opted out + // (uiHighResNoticeOff, a global UI-only key). Every row that renders the key (page and search) comes here. + let highResPrompt = $state(false); + let highResSkip = $state(false); + function acceptHighRes() { + if (highResSkip) { values = { ...values, uiHighResNoticeOff: '1' }; setConfig('uiHighResNoticeOff', '1'); } + highResPrompt = false; highResSkip = false; + values = { ...values, txSamplingMode: 1 }; + setConfig('txSamplingMode', 1); + } + function cancelHighRes() { highResPrompt = false; highResSkip = false; } function change(key, val) { + if (key === 'txSamplingMode' && Number(val) === 1 && Number(values.txSamplingMode) !== 1 && + String(values.uiHighResNoticeOff) !== '1') { highResSkip = false; highResPrompt = true; return; } if (key === 'model') announce('Magnifier model set to ' + val + '. Some display options changed.'); if (key === 'uiPalette') announce('Theme ' + (themes.find((t) => t.id === val)?.label ?? val)); values = { ...values, [key]: val }; @@ -301,6 +314,14 @@
{announcement}
+ {#if highResPrompt} + + {/if} {#if closePrompt} // High resolution cursor: the toggle drives txSamplingMode. It applies live (#369), so no restart tag. + // Turning it on may be held back by the page (the experimental notice, #441), in which case `value` + // does not change and the switch must not stay drawn as on: reset it to the prop after each flip. + import { tick } from 'svelte'; import Toggle from './Toggle.svelte'; let { value = 0, onChange = () => {}, disabled = false, labelledby, describedby } = $props(); + let root = $state(); + function flip(v) { + onChange(v); + tick().then(() => { const i = root && root.querySelector('input'); if (i) i.checked = Number(value) === 1; }); + } -
- +
+