From 047652e32cafd5c9b231ae790a09a5de4e249690 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:25:37 +0200 Subject: [PATCH 1/2] feat(tray): Pin to taskbar keeps the Wind icon next to the clock; 0.27.0 A Preferences toggle (trayPinned, default on) sets IsPromoted on WindTray's own entry under HKCU\Control Panel\NotifyIconSettings, matched by its module path (known-folder form expanded) and UID. WindTray applies it at start, after an Explorer restart and when the ini changes (folder watch, 300 ms debounce); a missing first-run entry retries for about 12 s. The key is a UI-only global, so it never reloads the magnifier or changes with a profile. Closes #436 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- build.bat | 2 +- docs/architecture/01-overview.md | 2 + docs/architecture/02-tick-loop.md | 2 +- docs/architecture/08-config-profiles.md | 9 +-- docs/architecture/09-settings-ui.md | 26 ++++++++ src/config.cpp | 7 +- src/config.h | 4 ++ src/profiles.cpp | 4 +- src/tray_app/main.cpp | 64 +++++++++++++++++- src/tray_app/tray_pin.cpp | 86 +++++++++++++++++++++++++ src/tray_app/tray_pin.h | 31 +++++++++ src/tray_app/tray_pin_logic.cpp | 37 +++++++++++ src/version.h | 6 +- tests/test_config.cpp | 12 ++++ tests/test_profiles.cpp | 5 +- tests/test_tray_pin.cpp | 37 +++++++++++ ui/src/session.js | 2 +- ui/src/settings-schema.js | 2 + ui/tests/prefs.spec.js | 19 +++++- ui/tests/schema.spec.js | 2 +- ui/tests/search.spec.js | 13 ++-- 21 files changed, 351 insertions(+), 21 deletions(-) create mode 100644 src/tray_app/tray_pin.cpp create mode 100644 src/tray_app/tray_pin.h create mode 100644 src/tray_app/tray_pin_logic.cpp create mode 100644 tests/test_tray_pin.cpp diff --git a/build.bat b/build.bat index f43bc923..22f1151d 100644 --- a/build.bat +++ b/build.bat @@ -112,7 +112,7 @@ rem --- Test build (pure-logic sources only; no ) ----------------- rem /wd5285 silences a known doctest 2.4.11 header warning under MSVC /W4. cl /nologo /std:c++17 /EHsc /W4 /wd5285 /DWIND_TESTS /I third_party ^ tests\*.cpp ^ - src\transform.cpp src\zoom_controller.cpp src\config.cpp src\profiles.cpp src\cursor_mapper.cpp src\lock_detector.cpp src\cursor_lock.cpp src\crosshair.cpp src\config_ui\ini_edit.cpp src\logging.cpp src\tray_items.cpp src\hitch_record.cpp src\render_params.cpp ^ + src\transform.cpp src\zoom_controller.cpp src\config.cpp src\profiles.cpp src\cursor_mapper.cpp src\lock_detector.cpp src\cursor_lock.cpp src\crosshair.cpp src\config_ui\ini_edit.cpp src\logging.cpp src\tray_items.cpp src\hitch_record.cpp src\render_params.cpp src\tray_app\tray_pin_logic.cpp ^ /Fe:wind_tests.exe if errorlevel 1 exit /b 1 "%ROOT%wind_tests.exe" diff --git a/docs/architecture/01-overview.md b/docs/architecture/01-overview.md index 0200b61e..0912b50a 100644 --- a/docs/architecture/01-overview.md +++ b/docs/architecture/01-overview.md @@ -59,6 +59,8 @@ overlay; an ordinary process's popups do not. - The flyout is a Direct2D window, not a menu (`src/tray_app/flyout_*`). Its content is chosen in Settings > Tray menu and stored in the global `tray*` keys (`src/tray_items.*`). Details in [09](09-settings-ui.md). +- WindTray also applies `trayPinned` (Pin to taskbar, #436): it watches the ini folder and writes + `IsPromoted` for its own icon (`src/tray_app/tray_pin.*`, [09](09-settings-ui.md#pin-to-taskbar)). - The flyout's engine dropdown writes `model` to the live ini, drops `session.keep` and restarts Wind without a prompt (`src/tray_app/engine_dropdown.cpp`). - `WindTray.exe --render-test out.png [--palette ]` renders the flyout headless for visual diff --git a/docs/architecture/02-tick-loop.md b/docs/architecture/02-tick-loop.md index 4205a71a..7a550764 100644 --- a/docs/architecture/02-tick-loop.md +++ b/docs/architecture/02-tick-loop.md @@ -109,7 +109,7 @@ 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` and the five tray layout keys (`trayPerf`, `traySliders`, `traySliderOrder`, +`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; an empty one would make the first Settings write of a session reload. diff --git a/docs/architecture/08-config-profiles.md b/docs/architecture/08-config-profiles.md index 9e0bf3ca..bb3cb40d 100644 --- a/docs/architecture/08-config-profiles.md +++ b/docs/architecture/08-config-profiles.md @@ -73,8 +73,8 @@ 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 nine keys the core never reads -(`uiTheme`, `uiPalette`, `showAdvanced`, `onboarded` and the five tray layout keys `trayPerf`, +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`, `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 +99,7 @@ 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` and the five tray layout keys (`trayPerf`, `traySliders`, +`uiTheme`, `uiPalette`, `showAdvanced`, `trayPinned` 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. @@ -202,7 +202,8 @@ Every key works in the ini whether or not Settings shows it. Keys hot-reload unl | `zorderBand` (0, restart) | 16 covers the shell in the UIAccess build, at the cost of the Snipping Tool ([04](04-render-engine.md)) | **Global and UI.** `profile`, `onboarded`, `uiPalette` (`grey`, `ember`, `ocean`, `hicon`), -`showAdvanced`, `trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`. +`showAdvanced`, `trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`, +`trayPinned` (1 = keep the tray icon on the taskbar, 0 = hidden-icons overflow; applied by WindTray). `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 170fee77..75e2c8bd 100644 --- a/docs/architecture/09-settings-ui.md +++ b/docs/architecture/09-settings-ui.md @@ -165,6 +165,32 @@ card by pointer drag, or with the keyboard: Space to pick up and drop, arrows to global, not profile keys. Spec: [../specs/2026-10-01-tray-flyout-design.md](../specs/2026-10-01-tray-flyout-design.md). +## Pin to taskbar + +The Preferences row `trayPinned` (General card, default on) keeps WindTray's icon on the taskbar +next to the clock instead of in the hidden-icons overflow. The page only writes the ini key; it is +global and UI-only (`StripUiOnlyKeys`, `IsGlobalProfileKey`), so it never reloads the core and never +travels with a profile. WindTray applies it (`src/tray_app/main.cpp`, `tray_pin.*`): + +- At startup right after `AddIcon`, after an Explorer restart (`TaskbarCreated`), and whenever the + ini changes. The tray watches the ini folder with `FindFirstChangeNotificationW` as a second wait + handle of its message loop; a 300 ms debounce timer then re-reads `trayPinned` and applies only + when the value changed. +- Windows keeps one registry key per icon: `HKCU\Control Panel\NotifyIconSettings\` with + `ExecutablePath` (plain, or `{known-folder GUID}\rest` such as `{6D809377-...}` = + FOLDERID_ProgramFilesX64), `UID` (the `NOTIFYICONDATA` uID, Wind uses 1), `InitialTooltip` and + `IsPromoted` (DWORD, 1 = taskbar, 0 or absent = overflow). WindTray touches only the entry whose + expanded `ExecutablePath` equals its own module path (case-insensitive) and whose `UID` is 1; + entries of other builds stay as they are. The matching is pure and tested + (`tray_pin_logic.cpp`, `tests/test_tray_pin.cpp`). +- Explorer creates the entry only after the icon is first added, so on a first run the write retries + once a second for up to 12 s. Explorer applies a changed `IsPromoted` within about a second. +- Wind re-applies the value at each tray start, so it overrides an unpin made in Windows Settings while + the row is on. + +Source: tested on this machine (Windows 11 26200), 2026-10-10: toggled `IsPromoted` 0 and 1 and +checked the icon position through UI Automation. The key is undocumented by Microsoft. + ## Profiles The Profile row (`prefs/ProfilePicker.svelte`) is a dropdown plus New. Each profile except the last diff --git a/src/config.cpp b/src/config.cpp index 0442eef2..edd5cacc 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -213,6 +213,7 @@ Config ParseConfig(const std::string& text) { else if (key == "panGlideMs") c.panGlideMs = ParseIntStrict(val); else if (key == "panGlideMaxPx") c.panGlideMaxPx = ParseIntStrict(val); else if (key == "cursorConstantSize") c.cursorConstantSize = ParseIntStrict(val); + else if (key == "trayPinned") c.trayPinned = ParseIntStrict(val); else if (key == "cursorVisibility") c.cursorVisibility = val; else if (key == "model") c.model = val; else if (key == "fastPan") c.fastPan = ParseIntStrict(val); @@ -304,6 +305,7 @@ Config ParseConfig(const std::string& text) { c.brightness = clampd(c.brightness, 0.5, 1.5); c.colorWarmPct = (int)clampd(c.colorWarmPct, 0, 100); c.colorDimPct = (int)clampd(c.colorDimPct, 1, 100); + c.trayPinned = (int)clampd(c.trayPinned, 0, 1); c.quickZoomDefault = clampd(c.quickZoomDefault, 1.0, 50.0); if (c.outlineThickness < 1) c.outlineThickness = 1; if (c.outlineThickness > 40) c.outlineThickness = 40; @@ -392,7 +394,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=" }) { + "trayToggleOrder=", "trayPinned=" }) { if (line.compare(b, strlen(k), k) == 0) { uiOnly = true; break; } } } @@ -524,6 +526,9 @@ std::string DefaultIniText() { "; cursorConstantSize: 0=the cursor grows with the zoom (default); 1=keep it at normal\n" "; desktop size at every zoom (render engine only). Replaces cursorScaleWithZoom (ignored).\n" "cursorConstantSize=0\n" + "; trayPinned: 1=keep the Wind tray icon on the taskbar next to the clock (default);\n" + "; 0=leave it in the hidden icons overflow. Applied by WindTray, never reloads the core\n" + "trayPinned=1\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 6a74f63b..ebb0a955 100644 --- a/src/config.h +++ b/src/config.h @@ -390,6 +390,10 @@ struct Config { // explicit 0 into every ini, so fresh installs got a tiny cursor in render and a changed // default alone could never have reached them. int cursorConstantSize = 0; + // Pin to taskbar (#436): 1 (default) = WindTray keeps its icon on the taskbar next to the clock, + // 0 = leave it in the hidden-icons overflow. Applied by WindTray alone (NotifyIconSettings + // IsPromoted); the core never reads it, so it is a UI-only key (StripUiOnlyKeys) and global. + int trayPinned = 1; // Cursor visibility while zoomed: "auto" = follow the focused app (don't draw a cursor // when a game hides its own via ShowCursor(FALSE); detected with GetCursorInfo's // CURSOR_SHOWING flag, which our own MagShowSystemCursor hide does NOT affect); diff --git a/src/profiles.cpp b/src/profiles.cpp index a167b827..60d060a5 100644 --- a/src/profiles.cpp +++ b/src/profiles.cpp @@ -18,7 +18,7 @@ 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 == "trayToggles" || key == "trayToggleOrder" || key == "trayPinned"; } std::string ProfileNameError(const std::string& name) { if (name.empty()) return "Name cannot be empty"; @@ -68,7 +68,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"}) { + "traySliderOrder", "trayToggles", "trayToggleOrder", "trayPinned"}) { auto it = oldVals.find(k); if (it != oldVals.end()) out = UpdateIniText(out, k, it->second); } diff --git a/src/tray_app/main.cpp b/src/tray_app/main.cpp index 4ad14ba8..a4ecb9ca 100644 --- a/src/tray_app/main.cpp +++ b/src/tray_app/main.cpp @@ -7,6 +7,10 @@ #include "tray_app.h" #include "../logging.h" #include "../tray_ipc.h" +#include "../config.h" +#include "../config_path.h" +#include "../profiles_io.h" +#include "tray_pin.h" #include #include #include @@ -33,6 +37,40 @@ using namespace wind; static UINT g_taskbarCreated = 0; +// Pin to taskbar (#436). The tray never learned about ini changes before (the flyout reads the ini when +// it opens), so main watches the ini folder: a change re-reads trayPinned after a short debounce and +// writes IsPromoted only when the wanted value changed. See src/tray_app/tray_pin.h. +static constexpr UINT_PTR kPinTimerId = 1; // retry: Explorer creates our entry only after the icon is added +static constexpr UINT_PTR kCfgTimerId = 2; // debounce for an ini change +static constexpr int kPinMaxAttempts = 12; // x 1 s +static int g_pinWanted = -1; // -1 = not read yet +static int g_pinAttempts = 0; +static bool g_cfgPending = false; // a debounce timer is already running + +static void ApplyPin(HWND h) { + const TrayPin::Result r = TrayPin::ApplyPinned(g_pinWanted != 0, 1); // uID 1 is the id AddIcon uses + if (r == TrayPin::Result::NoEntry && g_pinAttempts < kPinMaxAttempts) { + ++g_pinAttempts; + SetTimer(h, kPinTimerId, 1000, nullptr); + } else { + KillTimer(h, kPinTimerId); + if (r == TrayPin::Result::NoEntry || r == TrayPin::Result::Error) + wind::Log(wind::LogLevel::Warn, "tray", "taskbar pin: not applied (%s)", + r == TrayPin::Result::Error ? "registry error" : "no icon entry"); + } +} + +// force: apply even when the value is unchanged (the icon was added again). +static void RefreshPin(HWND h, bool force) { + int want = g_pinWanted < 0 ? 1 : g_pinWanted; // an unreadable ini keeps what we had + std::string text; + if (wind::ReadLiveIni(wind::ResolveIniPath(), text)) want = wind::ParseConfig(text).trayPinned; + if (!force && want == g_pinWanted) return; + g_pinWanted = want; + g_pinAttempts = 0; + ApplyPin(h); +} + static LRESULT CALLBACK TrayWndProc(HWND h, UINT m, WPARAM w, LPARAM l) { if (m == TrayApp::WM_TRAY && (l == WM_RBUTTONUP || l == WM_LBUTTONUP)) { TrayApp::ToggleFlyout(); @@ -42,8 +80,11 @@ static LRESULT CALLBACK TrayWndProc(HWND h, UINT m, WPARAM w, LPARAM l) { // Explorer restarted: the shell forgot every icon. AddIcon deletes before adding, so this // can never leave two. TrayApp::AddIcon(h, GetModuleHandleW(nullptr)); + RefreshPin(h, true); return 0; } + if (m == WM_TIMER && w == kPinTimerId) { KillTimer(h, kPinTimerId); ApplyPin(h); return 0; } + if (m == WM_TIMER && w == kCfgTimerId) { KillTimer(h, kCfgTimerId); g_cfgPending = false; RefreshPin(h, false); return 0; } if (m == WM_DESTROY) { PostQuitMessage(0); return 0; } return DefWindowProcW(h, m, w, l); } @@ -121,12 +162,32 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR cmdLine, int) { g_taskbarCreated = RegisterWindowMessageW(L"TaskbarCreated"); // Explorer runs at the same integrity, but allow the broadcast explicitly in case it does not. ChangeWindowMessageFilterEx(hwnd, g_taskbarCreated, MSGFLT_ALLOW, nullptr); + // Watch the ini folder (Settings replaces the file atomically, so the name changes too). + HANDLE hCfg = INVALID_HANDLE_VALUE; + { + std::wstring dir = wind::ResolveIniPath(); + const size_t cut = dir.find_last_of(L"\\/"); + if (cut != std::wstring::npos) { + dir.resize(cut); + hCfg = FindFirstChangeNotificationW(dir.c_str(), FALSE, + FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_SIZE); + } + } + TrayApp::AddIcon(hwnd, hInst); + RefreshPin(hwnd, true); wind::Log(wind::LogLevel::Info, "tray", "serving Wind pid=%lu", windPid); bool running = true; while (running) { - const DWORD w = MsgWaitForMultipleObjects(1, &hWind, FALSE, INFINITE, QS_ALLINPUT); + HANDLE waits[2] = { hWind, hCfg }; + const DWORD nWaits = hCfg != INVALID_HANDLE_VALUE ? 2 : 1; + const DWORD w = MsgWaitForMultipleObjects(nWaits, waits, FALSE, INFINITE, QS_ALLINPUT); + if (w == WAIT_OBJECT_0 + 1 && nWaits == 2) { + FindNextChangeNotification(hCfg); + if (!g_cfgPending) { g_cfgPending = true; SetTimer(hwnd, kCfgTimerId, 300, nullptr); } // fixed delay, cannot be starved by a busy folder + continue; + } if (w == WAIT_OBJECT_0) { wind::Log(wind::LogLevel::Info, "tray", "Wind exited; removing the icon"); break; @@ -144,6 +205,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR cmdLine, int) { // "open". TrayApp::CloseFlyout(); TrayApp::RemoveIcon(); + if (hCfg != INVALID_HANDLE_VALUE) FindCloseChangeNotification(hCfg); SetTrayMenuOpen(TrayApp::g_block, false); wind::LogShutdown(); ExitProcess(0); diff --git a/src/tray_app/tray_pin.cpp b/src/tray_app/tray_pin.cpp new file mode 100644 index 00000000..377f15ea --- /dev/null +++ b/src/tray_app/tray_pin.cpp @@ -0,0 +1,86 @@ +#include "tray_pin.h" +#include "../logging.h" +#include +#include +#include + +namespace wind { namespace TrayPin { + +static bool ResolveKnownFolder(const std::wstring& guidText, std::wstring& out) { + GUID id{}; + if (FAILED(CLSIDFromString(guidText.c_str(), &id))) return false; + PWSTR p = nullptr; + if (FAILED(SHGetKnownFolderPath(id, 0, nullptr, &p)) || !p) { CoTaskMemFree(p); return false; } + out = p; + CoTaskMemFree(p); + return true; +} + +static bool ReadString(HKEY key, const wchar_t* name, std::wstring& out) { + wchar_t buf[1024]; + DWORD cb = sizeof(buf); + if (RegGetValueW(key, nullptr, name, RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, nullptr, buf, &cb) != ERROR_SUCCESS) + return false; + out = buf; + return true; +} + +static bool ReadDword(HKEY key, const wchar_t* name, DWORD& out) { + DWORD cb = sizeof(out); + return RegGetValueW(key, nullptr, name, RRF_RT_REG_DWORD, nullptr, &out, &cb) == ERROR_SUCCESS; +} + +Result ApplyPinned(bool pinned, unsigned long uid) { + wchar_t self[MAX_PATH]; + const DWORD n = GetModuleFileNameW(nullptr, self, MAX_PATH); + if (n == 0 || n >= MAX_PATH) return Result::Error; + + HKEY root = nullptr; + const LONG rc = RegOpenKeyExW(HKEY_CURRENT_USER, L"Control Panel\\NotifyIconSettings", 0, + KEY_READ, &root); + if (rc == ERROR_FILE_NOT_FOUND || rc == ERROR_PATH_NOT_FOUND) return Result::NoEntry; + if (rc != ERROR_SUCCESS) return Result::Error; + + Result res = Result::NoEntry; + for (DWORD i = 0;; ++i) { + wchar_t name[256]; + DWORD len = ARRAYSIZE(name); + const LONG er = RegEnumKeyExW(root, i, name, &len, nullptr, nullptr, nullptr, nullptr); + if (er == ERROR_NO_MORE_ITEMS) break; + if (er != ERROR_SUCCESS) continue; + HKEY sub = nullptr; + if (RegOpenKeyExW(root, name, 0, KEY_QUERY_VALUE, &sub) != ERROR_SUCCESS) continue; + std::wstring exe; + DWORD entryUid = 0; + const bool mine = ReadString(sub, L"ExecutablePath", exe) && ReadDword(sub, L"UID", entryUid) && + EntryMatches(exe, entryUid, self, uid, ResolveKnownFolder); + if (mine) { + DWORD cur = 0; + ReadDword(sub, L"IsPromoted", cur); // absent = Windows default = overflow + const DWORD want = pinned ? 1 : 0; + if ((cur != 0) == pinned) { + res = Result::AlreadyOk; + } else { + HKEY w = nullptr; + if (RegOpenKeyExW(root, name, 0, KEY_SET_VALUE, &w) == ERROR_SUCCESS) { + if (RegSetValueExW(w, L"IsPromoted", 0, REG_DWORD, + reinterpret_cast(&want), sizeof(want)) == ERROR_SUCCESS) { + wind::Log(wind::LogLevel::Info, "tray", "taskbar pin: IsPromoted=%lu", want); + res = Result::Applied; + } else { + res = Result::Error; + } + RegCloseKey(w); + } else { + res = Result::Error; + } + } + } + RegCloseKey(sub); + if (mine) break; + } + RegCloseKey(root); + return res; +} + +}} // namespace wind::TrayPin diff --git a/src/tray_app/tray_pin.h b/src/tray_app/tray_pin.h new file mode 100644 index 00000000..9f3de36d --- /dev/null +++ b/src/tray_app/tray_pin.h @@ -0,0 +1,31 @@ +#pragma once +// "Pin to taskbar" (#436): keeps WindTray's notification-area icon on the taskbar next to the clock +// instead of in the hidden-icons overflow. Windows stores the choice per icon under +// HKCU\Control Panel\NotifyIconSettings\ (IsPromoted: 1 = taskbar, 0 = overflow); see +// docs/architecture/09-settings-ui.md. The matching logic below is pure (no ) and tested. +#include +#include + +namespace wind { namespace TrayPin { + +// Maps a braced known-folder GUID such as "{6D809377-6AF0-444B-8957-A3773F02200E}" to its folder path. +using GuidResolver = std::function; + +// "{GUID}\rest" -> "\rest"; any other text is returned unchanged. A leading GUID the resolver +// cannot map gives an empty string (an entry we cannot place never matches). +std::wstring ExpandPath(const std::wstring& raw, const GuidResolver& resolve); + +// Case-insensitive path equality; slash and backslash are the same, a trailing separator is ignored. +bool SamePath(const std::wstring& a, const std::wstring& b); + +// True when a NotifyIconSettings entry (its ExecutablePath and UID values) belongs to this process's icon. +bool EntryMatches(const std::wstring& rawExePath, unsigned long entryUid, + const std::wstring& selfExePath, unsigned long selfUid, const GuidResolver& resolve); + +enum class Result { Applied, AlreadyOk, NoEntry, Error }; + +// Registry side (tray_pin.cpp, Windows only). Writes IsPromoted on our own entry only when it differs. +// NoEntry: Explorer has not created the entry yet (first run, right after NIM_ADD); the caller retries. +Result ApplyPinned(bool pinned, unsigned long uid); + +}} // namespace wind::TrayPin diff --git a/src/tray_app/tray_pin_logic.cpp b/src/tray_app/tray_pin_logic.cpp new file mode 100644 index 00000000..15345316 --- /dev/null +++ b/src/tray_app/tray_pin_logic.cpp @@ -0,0 +1,37 @@ +#include "tray_pin.h" +#include + +namespace wind { namespace TrayPin { + +std::wstring ExpandPath(const std::wstring& raw, const GuidResolver& resolve) { + if (raw.empty() || raw[0] != L'{') return raw; + const size_t close = raw.find(L'}'); + if (close == std::wstring::npos) return raw; + std::wstring dir; + if (!resolve || !resolve(raw.substr(0, close + 1), dir) || dir.empty()) return std::wstring(); + std::wstring rest = raw.substr(close + 1); + while (!dir.empty() && (dir.back() == L'\\' || dir.back() == L'/')) dir.pop_back(); + if (!rest.empty() && rest[0] != L'\\' && rest[0] != L'/') dir += L'\\'; + return dir + rest; +} + +static std::wstring Norm(const std::wstring& s) { + std::wstring o; + o.reserve(s.size()); + for (wchar_t c : s) o += (c == L'/') ? L'\\' : (wchar_t)std::towlower(c); + while (!o.empty() && o.back() == L'\\') o.pop_back(); + return o; +} + +bool SamePath(const std::wstring& a, const std::wstring& b) { + const std::wstring na = Norm(a); + return !na.empty() && na == Norm(b); +} + +bool EntryMatches(const std::wstring& rawExePath, unsigned long entryUid, + const std::wstring& selfExePath, unsigned long selfUid, const GuidResolver& resolve) { + if (entryUid != selfUid) return false; + return SamePath(ExpandPath(rawExePath, resolve), selfExePath); +} + +}} // namespace wind::TrayPin diff --git a/src/version.h b/src/version.h index fa8f0731..77acf730 100644 --- a/src/version.h +++ b/src/version.h @@ -3,8 +3,8 @@ #pragma once #define WIND_VER_MAJOR 0 -#define WIND_VER_MINOR 26 -#define WIND_VER_PATCH 1 +#define WIND_VER_MINOR 27 +#define WIND_VER_PATCH 0 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.26.1" +#define WIND_VERSION_STR "0.27.0" diff --git a/tests/test_config.cpp b/tests/test_config.cpp index f944bb3f..3ef01fad 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -463,6 +463,7 @@ TEST_CASE("the first-run ini template parses to the struct defaults (issue #274) CHECK(t.panGlideMs == d.panGlideMs); CHECK(t.panGlideMaxPx == d.panGlideMaxPx); CHECK(t.cursorConstantSize == d.cursorConstantSize); + CHECK(t.trayPinned == d.trayPinned); CHECK(t.cursorVisibility == d.cursorVisibility); CHECK(t.bilinear == d.bilinear); CHECK(t.sharpness == doctest::Approx(d.sharpness)); @@ -711,3 +712,14 @@ 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("trayPinned ships on, clamps to 0..1 and is UI-only: never reloads the core (#436)") { + CHECK(ParseConfig("").trayPinned == 1); + CHECK(ParseConfig("trayPinned=0\n").trayPinned == 0); + CHECK(ParseConfig("trayPinned=1\n").trayPinned == 1); + CHECK(ParseConfig("trayPinned=7\n").trayPinned == 1); + CHECK(ParseConfig("trayPinned=-3\n").trayPinned == 0); + CHECK(ParseConfig("trayPinned=abc\n").trayPinned == 1); // strict parser: junk keeps the default + CHECK(StripUiOnlyKeys("a=1\ntrayPinned=0\nb=2\n") == "a=1\nb=2\n"); + CHECK(StripUiOnlyKeys("a=1\ntrayPinned=0\n") == StripUiOnlyKeys("a=1\ntrayPinned=1\n")); + CHECK(StripUiOnlyKeys("trayPinnedX=1\n") == "trayPinnedX=1\n"); +} diff --git a/tests/test_profiles.cpp b/tests/test_profiles.cpp index 49cd878f..bf0d8afd 100644 --- a/tests/test_profiles.cpp +++ b/tests/test_profiles.cpp @@ -216,12 +216,15 @@ TEST_CASE("ParseIniTmpName recognises WriteTextFileAtomic's leftover temp names" } 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" }; + "traySliderOrder", "trayToggles", "trayToggleOrder", "trayPinned" }; for (const char* k : globals) { REQUIRE(IsGlobalProfileKey(k)); const std::string line = std::string(k) + "=1\n"; CHECK(StripUiOnlyKeys("maxLevel=8\n" + line) == "maxLevel=8\n"); CHECK(StripUiOnlyKeys("maxLevel=8\n" + std::string(k) + "=2\n") == StripUiOnlyKeys("maxLevel=8\n" + line)); + // The carry-over list in MakeLiveText keeps the live value across a profile switch; profiles never store it. + CHECK(ReadIniValues(MakeLiveText("", std::string(k) + "=0\n", "X"))[k] == "0"); + CHECK(MakeProfileText("maxLevel=8\n" + line).find(std::string(k) + "=") == std::string::npos); } // profile stays IN the fingerprint: a profile switch must still reload the core. CHECK(StripUiOnlyKeys("profile=A\n") != StripUiOnlyKeys("profile=B\n")); diff --git a/tests/test_tray_pin.cpp b/tests/test_tray_pin.cpp new file mode 100644 index 00000000..421ff1d7 --- /dev/null +++ b/tests/test_tray_pin.cpp @@ -0,0 +1,37 @@ +#include "doctest.h" +#include "../src/tray_app/tray_pin.h" +using namespace wind::TrayPin; + +static const GuidResolver kResolver = [](const std::wstring& g, std::wstring& out) { + if (g == L"{6D809377-6AF0-444B-8957-A3773F02200E}") { out = L"C:\\Program Files"; return true; } + if (g == L"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}") { out = L"C:\\Windows\\System32\\"; return true; } + return false; +}; + +TEST_CASE("tray pin: ExpandPath expands a leading known-folder GUID and leaves plain paths alone") { + CHECK(ExpandPath(L"{6D809377-6AF0-444B-8957-A3773F02200E}\\Wind\\WindTray.exe", kResolver) == + L"C:\\Program Files\\Wind\\WindTray.exe"); + CHECK(ExpandPath(L"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\x.exe", kResolver) == L"C:\\Windows\\System32\\x.exe"); + CHECK(ExpandPath(L"C:\\Dev\\WindTray.exe", kResolver) == L"C:\\Dev\\WindTray.exe"); + CHECK(ExpandPath(L"", kResolver) == L""); + CHECK(ExpandPath(L"{00000000-0000-0000-0000-000000000000}\\a.exe", kResolver) == L""); // unknown GUID: never matches + CHECK(ExpandPath(L"{6D809377-6AF0-444B-8957-A3773F02200E}\\a.exe", nullptr) == L""); + CHECK(ExpandPath(L"{unterminated\\a.exe", kResolver) == L"{unterminated\\a.exe"); +} + +TEST_CASE("tray pin: SamePath ignores case, slash direction and a trailing separator") { + CHECK(SamePath(L"C:\\Program Files\\Wind\\WindTray.exe", L"c:/program files/wind/windtray.EXE")); + CHECK(SamePath(L"C:\\Dir\\", L"C:\\Dir")); + CHECK_FALSE(SamePath(L"C:\\Dir\\a.exe", L"C:\\Dir\\b.exe")); + CHECK_FALSE(SamePath(L"", L"")); // two empty paths are not a match +} + +TEST_CASE("tray pin: EntryMatches needs this exe path AND this uid") { + const std::wstring self = L"C:\\Program Files\\Wind\\WindTray.exe"; + const std::wstring guidForm = L"{6D809377-6AF0-444B-8957-A3773F02200E}\\Wind\\WindTray.exe"; + CHECK(EntryMatches(guidForm, 1, self, 1, kResolver)); + CHECK(EntryMatches(self, 1, self, 1, kResolver)); + CHECK_FALSE(EntryMatches(guidForm, 2, self, 1, kResolver)); // other icon of the same exe + CHECK_FALSE(EntryMatches(L"C:\\Dev\\Wind\\WindTray.exe", 1, self, 1, kResolver)); // a dev build + CHECK_FALSE(EntryMatches(L"{00000000-0000-0000-0000-000000000000}\\Wind\\WindTray.exe", 1, self, 1, kResolver)); +} diff --git a/ui/src/session.js b/ui/src/session.js index 0dde4648..b316279f 100644 --- a/ui/src/session.js +++ b/ui/src/session.js @@ -5,7 +5,7 @@ import { groups, groupRows, bindKeys } from './settings-schema.js'; // Mirrors IsGlobalProfileKey in src/profiles.cpp: these are never part of a profile, so they never // count as an unsaved change. The five tray keys (#313) live in the live ini only. export const GLOBAL_KEYS = new Set(['profile', 'onboarded', 'uiTheme', 'uiPalette', 'showAdvanced', - 'trayPerf', 'traySliders', 'traySliderOrder', 'trayToggles', 'trayToggleOrder']); + 'trayPerf', 'traySliders', 'traySliderOrder', 'trayToggles', 'trayToggleOrder', 'trayPinned']); // Must match the core's shipped defaults (src/config.h): every keybind ships unbound except Quick // zoom. Seeding anything else here would invent a binding the user never chose. diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index 26151cb5..259368ea 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -133,6 +133,8 @@ export const groups = [ // Global UI-only key (uiPalette): the built-in theme, shared by Settings and the tray menu. One row of mini window cards, right-aligned like the other controls. { key: 'uiPalette', type: 'palette', label: 'Theme', keywords: ['palette', 'colour', 'color', 'colours', 'colors', 'appearance', 'look', 'dark', 'grey', 'gray', 'ember', 'ocean', 'high contrast', 'hicon', 'skin', 'style', 'accent', 'ui', 'interface'], desc: 'Set your preferred look.', def: 'grey' }, { key: '__profiles', type: 'profiles', label: 'Profile', keywords: ['preset', 'config', 'configuration', 'save', 'saved', 'profiles', 'switch', 'load', 'new', 'create', 'delete', 'rename', 'duplicate', 'set of settings', 'game profile', 'scheme', 'layout'], desc: 'A saved set of all settings.' }, + // Global UI-only key (trayPinned, #436): WindTray writes the shell's IsPromoted flag for its own icon; the core never reads it. + { key: 'trayPinned', type: 'toggle', label: 'Pin to taskbar', keywords: ['tray', 'taskbar', 'notification area', 'icon', 'clock', 'system tray', 'overflow', 'hidden icons', 'show', 'pin', 'corner', 'always visible', 'tray icon'], desc: 'Keeps the Wind icon next to the clock', def: 1 }, // Global UI-only key: shows the advanced rows of every page. { key: 'showAdvanced', type: 'toggle', label: 'Show advanced settings', keywords: ['expert', 'extra', 'more', 'hidden', 'options', 'all settings', 'power user', 'developer', 'reveal', 'show all', 'additional'], desc: 'Shows extra options on every page.', def: 0 }, ] }, diff --git a/ui/tests/prefs.spec.js b/ui/tests/prefs.spec.js index 917f2d09..e16a2649 100644 --- a/ui/tests/prefs.spec.js +++ b/ui/tests/prefs.spec.js @@ -372,11 +372,28 @@ test('Troubleshooting is always there, and the advanced switch is the last row o await page.goto('/'); await go(page, 'prefs'); await expect(page.locator('main .cap')).toHaveText(['General', 'Screen light', 'Troubleshooting']); - await expect(page.locator('main .card').first().locator('.row .label')).toHaveText(['Theme', 'Profile', 'Show advanced settings']); + await expect(page.locator('main .card').first().locator('.row .label')).toHaveText(['Theme', 'Profile', 'Pin to taskbar', 'Show advanced settings']); await expect(page.locator('main .card').nth(1).locator('.row .label')).toHaveText(['Warmth', 'Brightness']); await expect(page.locator('main .card').nth(2).locator('.row .label')).toHaveText(['Frame time logging', 'Export diagnostics', 'Open settings file']); }); +test('Pin to taskbar (#436): on by default, writes trayPinned, never an unsaved change', async ({ page }) => { + await page.goto('/'); + await go(page, 'prefs'); + const row = key(page, 'trayPinned'); + await expect(row.locator('.label')).toHaveText('Pin to taskbar'); + await expect(row.locator('.desc')).toHaveText('Keeps the Wind icon next to the clock'); + const sw = row.getByRole('switch'); + await expect(sw).toBeChecked(); // ships on + await sw.uncheck({ force: true }); + expect((await sent(page, 'setConfig')).filter((m) => m.key === 'trayPinned').map((m) => m.value)).toEqual(['0']); + await sw.check({ force: true }); + expect((await sent(page, 'setConfig')).filter((m) => m.key === 'trayPinned').map((m) => m.value)).toEqual(['0', '1']); + // A global key: flipping it must not light up the unsaved-changes state. + await sw.uncheck({ force: true }); + await expect(page.locator('.capsule')).toHaveCount(0); +}); + test('prefs files contain no em-dash', () => { const dash = String.fromCharCode(0x2014); for (const f of ['../src/prefs/ThemePicker.svelte', '../src/prefs/ProfilePicker.svelte', '../src/prefs/NewProfileDialog.svelte', diff --git a/ui/tests/schema.spec.js b/ui/tests/schema.spec.js index a23d195d..5360af13 100644 --- a/ui/tests/schema.spec.js +++ b/ui/tests/schema.spec.js @@ -21,7 +21,7 @@ const OLD_BIND_KEYS = [ 'panLeftVk', 'panLeftMods', 'panRightVk', 'panRightMods', 'panUpVk', 'panUpMods', 'panDownVk', 'panDownMods', 'hideCursorVk', 'hideCursorMods', 'cursorLockVk', 'cursorLockMods', ]; -const NEW_KEYS = ['uiPalette', '__profiles', '__diagnostics', '__openIni', 'trayPerf', 'showAdvanced']; +const NEW_KEYS = ['uiPalette', '__profiles', '__diagnostics', '__openIni', 'trayPerf', 'trayPinned', 'showAdvanced']; test('group ids, order and shape follow the spec', () => { expect(groups.map((g) => g.id)).toEqual(['hotkeys', 'zoom', 'view', 'prefs', 'tray', 'about']); diff --git a/ui/tests/search.spec.js b/ui/tests/search.spec.js index 21b53a36..00bd43e1 100644 --- a/ui/tests/search.spec.js +++ b/ui/tests/search.spec.js @@ -50,7 +50,7 @@ test('stopwords never outrank content words and fuzzy skips free text', () => { expect(zi[0]).toBe('Zoom in'); expect(zi.indexOf('Zoom out')).toBeGreaterThan(-1); expect(zi.indexOf('Zoom out')).toBeLessThan(zi.indexOf('Performance in the tray')); - expect(search(groups, 'tray').map((h) => h.label)).toEqual(['Performance in the tray']); + expect(search(groups, 'tray').map((h) => h.label)).toEqual(['Performance in the tray', 'Pin to taskbar']); expect(search(groups, 'theme').map((h) => h.label)).toEqual(['Theme']); }); @@ -154,7 +154,12 @@ test('Tray menu: only its normal switch row is indexed, never the item lists', ( // The lists are drawn by the custom page, so no key of theirs is a schema row. for (const k of ['trayTools', 'traySliders', 'trayToggles', 'trayOrder', 'keepEdges']) expect(allRows.map((r) => r.key)).not.toContain(k); for (const q of ['slider', 'toggle', 'quick', 'segment', 'drag', 'reorder', 'keep cursor centred']) - expect(keys(q).filter((k) => k.startsWith('tray') && k !== 'trayPerf')).toEqual([]); + expect(keys(q).filter((k) => k.startsWith('tray') && k !== 'trayPerf' && k !== 'trayPinned')).toEqual([]); +}); + +test('Pin to taskbar is found by its natural words (#436)', () => { + for (const q of ['taskbar', 'notification area', 'clock', 'hidden icons', 'system tray', 'overflow']) + expect(keys(q), q).toContain('trayPinned'); }); test('search files contain no em-dash', () => { @@ -249,9 +254,9 @@ test('tray item lists never appear in results; the Performance switch does', asy await expect(page.locator('.res .hit[data-hit="trayPerf"]')).toBeVisible(); await expect(page.locator('.res .hit').filter({ has: page.locator('.drag, [draggable=true]') })).toHaveCount(0); const ids = await page.locator('.res .hit').evaluateAll((els) => els.map((e) => e.getAttribute('data-hit'))); - expect(ids.filter((k) => k.startsWith('tray') && k !== 'trayPerf')).toEqual([]); + expect(ids.filter((k) => k.startsWith('tray') && k !== 'trayPerf' && k !== 'trayPinned')).toEqual([]); await input(page).fill('slider'); - await expect(page.locator('.res [data-hit^="tray"]:not([data-hit="trayPerf"])')).toHaveCount(0); + await expect(page.locator('.res [data-hit^="tray"]:not([data-hit="trayPerf"]):not([data-hit="trayPinned"])')).toHaveCount(0); }); test('Esc clears, Ctrl+F focuses with a ring, Enter moves into the first result, Tab walks the controls', async ({ page }) => { From dafcb4c7fb62cd83319328f65338be78f6d2bd4f Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:41:03 +0200 Subject: [PATCH 2/2] fix(tray): Pin to taskbar ships off and then leaves the icon to Windows Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/09-settings-ui.md | 7 ++++--- src/config.cpp | 7 ++++--- src/config.h | 2 +- src/tray_app/main.cpp | 6 ++++-- tests/test_config.cpp | 6 +++--- ui/src/settings-schema.js | 2 +- ui/tests/prefs.spec.js | 12 ++++++------ 7 files changed, 23 insertions(+), 19 deletions(-) diff --git a/docs/architecture/09-settings-ui.md b/docs/architecture/09-settings-ui.md index 75e2c8bd..c54ec601 100644 --- a/docs/architecture/09-settings-ui.md +++ b/docs/architecture/09-settings-ui.md @@ -167,13 +167,14 @@ global, not profile keys. Spec: ## Pin to taskbar -The Preferences row `trayPinned` (General card, default on) keeps WindTray's icon on the taskbar +The Preferences row `trayPinned` (General card, default off) keeps WindTray's icon on the taskbar next to the clock instead of in the hidden-icons overflow. The page only writes the ini key; it is global and UI-only (`StripUiOnlyKeys`, `IsGlobalProfileKey`), so it never reloads the core and never travels with a profile. WindTray applies it (`src/tray_app/main.cpp`, `tray_pin.*`): -- At startup right after `AddIcon`, after an Explorer restart (`TaskbarCreated`), and whenever the - ini changes. The tray watches the ini folder with `FindFirstChangeNotificationW` as a second wait +- At startup right after `AddIcon` and after an Explorer restart (`TaskbarCreated`), but only when + on: off (the default) leaves the icon to Windows, so a pin made in the taskbar settings survives. + Whenever the ini changes, both ways: switching the row off unpins. The tray watches the ini folder with `FindFirstChangeNotificationW` as a second wait handle of its message loop; a 300 ms debounce timer then re-reads `trayPinned` and applies only when the value changed. - Windows keeps one registry key per icon: `HKCU\Control Panel\NotifyIconSettings\` with diff --git a/src/config.cpp b/src/config.cpp index edd5cacc..49d05f69 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -526,9 +526,10 @@ std::string DefaultIniText() { "; cursorConstantSize: 0=the cursor grows with the zoom (default); 1=keep it at normal\n" "; desktop size at every zoom (render engine only). Replaces cursorScaleWithZoom (ignored).\n" "cursorConstantSize=0\n" - "; trayPinned: 1=keep the Wind tray icon on the taskbar next to the clock (default);\n" - "; 0=leave it in the hidden icons overflow. Applied by WindTray, never reloads the core\n" - "trayPinned=1\n" + "; trayPinned: 1=keep the Wind tray icon on the taskbar next to the clock; 0=leave it\n" + "; to Windows, normally the hidden icons overflow (default). Applied by WindTray, never\n" + "; reloads the core\n" + "trayPinned=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 ebb0a955..c0bb949a 100644 --- a/src/config.h +++ b/src/config.h @@ -393,7 +393,7 @@ struct Config { // Pin to taskbar (#436): 1 (default) = WindTray keeps its icon on the taskbar next to the clock, // 0 = leave it in the hidden-icons overflow. Applied by WindTray alone (NotifyIconSettings // IsPromoted); the core never reads it, so it is a UI-only key (StripUiOnlyKeys) and global. - int trayPinned = 1; + int trayPinned = 0; // Cursor visibility while zoomed: "auto" = follow the focused app (don't draw a cursor // when a game hides its own via ShowCursor(FALSE); detected with GetCursorInfo's // CURSOR_SHOWING flag, which our own MagShowSystemCursor hide does NOT affect); diff --git a/src/tray_app/main.cpp b/src/tray_app/main.cpp index a4ecb9ca..c391460d 100644 --- a/src/tray_app/main.cpp +++ b/src/tray_app/main.cpp @@ -60,14 +60,16 @@ static void ApplyPin(HWND h) { } } -// force: apply even when the value is unchanged (the icon was added again). +// force: the icon was (re)added. Off is the default and then leaves the icon to Windows, so a pin +// made in the taskbar settings survives a restart; only switching the row off in Wind unpins. static void RefreshPin(HWND h, bool force) { - int want = g_pinWanted < 0 ? 1 : g_pinWanted; // an unreadable ini keeps what we had + int want = g_pinWanted < 0 ? 0 : g_pinWanted; // an unreadable ini keeps what we had std::string text; if (wind::ReadLiveIni(wind::ResolveIniPath(), text)) want = wind::ParseConfig(text).trayPinned; if (!force && want == g_pinWanted) return; g_pinWanted = want; g_pinAttempts = 0; + if (force && want == 0) { KillTimer(h, kPinTimerId); return; } ApplyPin(h); } diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 3ef01fad..3004197d 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -712,13 +712,13 @@ 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("trayPinned ships on, clamps to 0..1 and is UI-only: never reloads the core (#436)") { - CHECK(ParseConfig("").trayPinned == 1); +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); CHECK(ParseConfig("trayPinned=1\n").trayPinned == 1); CHECK(ParseConfig("trayPinned=7\n").trayPinned == 1); CHECK(ParseConfig("trayPinned=-3\n").trayPinned == 0); - CHECK(ParseConfig("trayPinned=abc\n").trayPinned == 1); // strict parser: junk keeps the default + CHECK(ParseConfig("trayPinned=abc\n").trayPinned == 0); // strict parser: junk keeps the default CHECK(StripUiOnlyKeys("a=1\ntrayPinned=0\nb=2\n") == "a=1\nb=2\n"); CHECK(StripUiOnlyKeys("a=1\ntrayPinned=0\n") == StripUiOnlyKeys("a=1\ntrayPinned=1\n")); CHECK(StripUiOnlyKeys("trayPinnedX=1\n") == "trayPinnedX=1\n"); diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index 259368ea..a7f4c0df 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -134,7 +134,7 @@ export const groups = [ { key: 'uiPalette', type: 'palette', label: 'Theme', keywords: ['palette', 'colour', 'color', 'colours', 'colors', 'appearance', 'look', 'dark', 'grey', 'gray', 'ember', 'ocean', 'high contrast', 'hicon', 'skin', 'style', 'accent', 'ui', 'interface'], desc: 'Set your preferred look.', def: 'grey' }, { key: '__profiles', type: 'profiles', label: 'Profile', keywords: ['preset', 'config', 'configuration', 'save', 'saved', 'profiles', 'switch', 'load', 'new', 'create', 'delete', 'rename', 'duplicate', 'set of settings', 'game profile', 'scheme', 'layout'], desc: 'A saved set of all settings.' }, // Global UI-only key (trayPinned, #436): WindTray writes the shell's IsPromoted flag for its own icon; the core never reads it. - { key: 'trayPinned', type: 'toggle', label: 'Pin to taskbar', keywords: ['tray', 'taskbar', 'notification area', 'icon', 'clock', 'system tray', 'overflow', 'hidden icons', 'show', 'pin', 'corner', 'always visible', 'tray icon'], desc: 'Keeps the Wind icon next to the clock', def: 1 }, + { key: 'trayPinned', type: 'toggle', label: 'Pin to taskbar', keywords: ['tray', 'taskbar', 'notification area', 'icon', 'clock', 'system tray', 'overflow', 'hidden icons', 'show', 'pin', 'corner', 'always visible', 'tray icon'], desc: 'Keeps the Wind icon next to the clock', def: 0 }, // Global UI-only key: shows the advanced rows of every page. { key: 'showAdvanced', type: 'toggle', label: 'Show advanced settings', keywords: ['expert', 'extra', 'more', 'hidden', 'options', 'all settings', 'power user', 'developer', 'reveal', 'show all', 'additional'], desc: 'Shows extra options on every page.', def: 0 }, ] }, diff --git a/ui/tests/prefs.spec.js b/ui/tests/prefs.spec.js index e16a2649..1d0ad3c0 100644 --- a/ui/tests/prefs.spec.js +++ b/ui/tests/prefs.spec.js @@ -377,20 +377,20 @@ test('Troubleshooting is always there, and the advanced switch is the last row o await expect(page.locator('main .card').nth(2).locator('.row .label')).toHaveText(['Frame time logging', 'Export diagnostics', 'Open settings file']); }); -test('Pin to taskbar (#436): on by default, writes trayPinned, never an unsaved change', async ({ page }) => { +test('Pin to taskbar (#436): off by default, writes trayPinned, never an unsaved change', async ({ page }) => { await page.goto('/'); await go(page, 'prefs'); const row = key(page, 'trayPinned'); await expect(row.locator('.label')).toHaveText('Pin to taskbar'); await expect(row.locator('.desc')).toHaveText('Keeps the Wind icon next to the clock'); const sw = row.getByRole('switch'); - await expect(sw).toBeChecked(); // ships on - await sw.uncheck({ force: true }); - expect((await sent(page, 'setConfig')).filter((m) => m.key === 'trayPinned').map((m) => m.value)).toEqual(['0']); + await expect(sw).not.toBeChecked(); // ships off: the icon stays in the overflow await sw.check({ force: true }); - expect((await sent(page, 'setConfig')).filter((m) => m.key === 'trayPinned').map((m) => m.value)).toEqual(['0', '1']); - // A global key: flipping it must not light up the unsaved-changes state. + expect((await sent(page, 'setConfig')).filter((m) => m.key === 'trayPinned').map((m) => m.value)).toEqual(['1']); await sw.uncheck({ force: true }); + expect((await sent(page, 'setConfig')).filter((m) => m.key === 'trayPinned').map((m) => m.value)).toEqual(['1', '0']); + // A global key: flipping it must not light up the unsaved-changes state. + await sw.check({ force: true }); await expect(page.locator('.capsule')).toHaveCount(0); });