Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.bat
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ rem --- Test build (pure-logic sources only; no <windows.h>) -----------------
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"
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/01-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>]` renders the flyout headless for visual
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/02-tick-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions docs/architecture/08-config-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions docs/architecture/09-settings-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,33 @@ 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 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` 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\<id>` 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
Expand Down
8 changes: 7 additions & 1 deletion src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ Config ParseConfig(const std::string& text) {
else if (key == "cursorSensitivity") c.cursorSensitivity = ParseDoubleStrict(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);
Expand Down Expand Up @@ -301,6 +302,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;
Expand Down Expand Up @@ -389,7 +391,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; }
}
}
Expand Down Expand Up @@ -520,6 +522,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; 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"
Expand Down
4 changes: 4 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,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 = 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);
Expand Down
4 changes: 2 additions & 2 deletions src/profiles.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down
66 changes: 65 additions & 1 deletion src/tray_app/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <shellapi.h>
#include <cwchar>
#include <string>
Expand All @@ -33,6 +37,42 @@ 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: 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 ? 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);
}

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();
Expand All @@ -42,8 +82,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);
}
Expand Down Expand Up @@ -121,12 +164,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;
Expand All @@ -144,6 +207,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);
Expand Down
Loading
Loading