diff --git a/CLAUDE.md b/CLAUDE.md index 9170c48..fb95808 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,6 +207,12 @@ restartWind), `dirty`, `openIni`, `exportDiagnostics`, `pickExe`, `mpoState`, `s flips per 20s; measured 13-24 spike frames per 14 wheel-clicks, 0 with no context). Writing level 1.0 does NOT leave the mode; only releasing the runtime does. Hence the transform model creates its context on a session's first write and releases it ~1.2s after the zoom ends. +- COLOUR (WARMTH/BRIGHTNESS, #288) HOLDS THE MAGNIFICATION RUNTIME AT 1X while either is on, so the + gotcha above applies at 1x too (the #148 game-cursor tax). Accepted trade-off: the feature needs it, + and it is off by default. Measure a cursor-toggling game with colour on before blaming anything else. + The DWM effect scales LINEAR light under HDR (encoded in SDR), is one matrix for all monitors (mixed + HDR/SDR: right strength on the primary only), and never reaches the hardware pointer, so Wind swaps + the system pointers for tinted copies at 1x (src/cursor_tint.*). docs/COLOUR-FILTER-FINDINGS.md. - THE CURSOR GROWS WITH THE ZOOM, IN EVERY ENGINE (owner decision 2026-09-18, issue #253; this REPLACES the old "cursor size is constant, always" rule - do not restore it). The transform engine cannot do otherwise (its sprite lives in desktop space, DWM magnifies it), and a render diff --git a/docs/COLOUR-FILTER-FINDINGS.md b/docs/COLOUR-FILTER-FINDINGS.md new file mode 100644 index 0000000..d6e01cd --- /dev/null +++ b/docs/COLOUR-FILTER-FINDINGS.md @@ -0,0 +1,136 @@ +# Colour filter findings (issue #288) + +Spike 2026-09-29, this PC (3840x2160, 225%), Wind stopped, a standalone probe calling +`MagSetFullscreenColorEffect` with an invert matrix at level 1 (no UIAccess needed). + +| Question | Result | +|---|---| +| Does the effect apply at level 1? | Yes (`set=1`, the screen inverted). | +| Does GDI capture (BitBlt) see it? | Yes: centre-block mean luma 19.8 before, 235.2 after. | +| Does Desktop Duplication (the render engine's capture) see it? | **Yes**: 34.2 before, 251.8 after. | +| Does the effect survive the process being killed? | **No**: after `TerminateProcess` with the effect on, the screen read 19.8 / 34.2 again. Windows clears it with the process. | + +Consequences for the design: + +- **Render engine:** its capture already contains the filtered desktop and DWM would filter its + overlay again (invert twice = no filter). So while a render session is live, the DWM effect is set + to identity and the matrix is applied in the render pixel shader instead. The transform engine and + 1x use the DWM effect. +- **Crash safety comes free:** a killed or crashed Wind never leaves the screen filtered. The normal + exits still write identity so the change is immediate. +- Screenshots and recordings of the screen show the filter (both capture paths see it). + +## Why there are only warmth and brightness (measured 2026-09-29) + +Field report: with the filters on, coloured text in Windows Terminal became hard to read or +vanished. Measured on the Campbell palette (14 text colours on #0C0C0C) and a light web page +(black text, links, red/green/grey/orange on white): WCAG contrast of each text colour against +its filtered background, and the smallest RGB distance between any two filtered text colours. + +| filter | terminal: min contrast | terminal: closest pair | page: min contrast | +|---|---|---|---| +| none | 2.38 | 0.146 | 3.49 | +| invert | 1.20 | 0.146 | 6.35 | +| greyscale | 1.52 | 0.002 | 4.48 | +| yellow on black (as first built) | 1.43 | 0.001 | 5.57 | +| same, balanced luma weights | 1.83 | 0.013 | 5.88 | +| same, 50% of the original colour kept | 1.51 | 0.057 | 5.81 | +| hue-keeping "smart invert" | 1.56 | 0.038 | 6.35 | +| brightness-keeping yellow tint | 1.56-1.74 | 0.001-0.094 | 3.8-3.9 | + +Two limits, both inherent to one affine matrix applied to every pixel (all the DWM colour effect, +and Windows' own colour filters, can do): + +1. Squashing three channels onto a grey or two-colour ramp makes colours of equal brightness + identical (closest pair 0.001). Greyscale has the same flaw. +2. Anything that inverts swaps light and dark for every window at once: it helps white pages and + wrecks dark apps (bright green in the terminal keeps 35% of its contrast under Invert). + +Only a per-pixel, non-linear mapping could keep each text's contrast, and Wind has that only in +the render engine's shader (zoomed, render engine), not at 1x or on the transform engine. Owner +decision: keep only warmth and brightness, which scale channels and never merge or swap colours. +The numbers come from a small Python harness over the palette (not committed; rerun by computing +the matrix per colour and WCAG contrast in linear light). + +## HDR, Night light and the warmth maths (measured 2026-09-29/30, this PC: LG OLED, Windows HDR on, SDR white 188 nits) + +- **Under HDR the DWM colour effect scales LINEAR scRGB.** Desktop Duplication in R16G16B16A16_FLOAT + with a green gain of 0.339: green 2.198 -> 0.746 (exactly x0.339). A GDI capture (SDR, after + Windows' HDR->SDR step) had suggested sRGB-encoded maths; that was the conversion, not the effect. + In SDR the effect acts on encoded values. So the same number means a far weaker tint under HDR: + the first warmth build's 100% (green 0.339) was really about 2400 K on this screen, "yellow". +- **Night light's scale** (from its stored setting, CloudStore `bluelightreduction.settings`, the + value after `cf 28` is 2 x Kelvin as a varint): 0% = 6500 K, 100% = 1200 K, 50% = 3850 K, i.e. + LINEAR in Kelvin. Wind's Warmth now uses the same scale (`WarmKelvin`). +- **Night light is invisible to software capture under HDR** (no change in the FP16 duplication, gamma + ramp untouched): it runs in the display pipeline. Wind therefore models it: CIE 1931 blackbody + gains in linear light (`kKelvinGains`, Planck + Wyman-Sloan-Shirley CMF fit), encoded for SDR and + the render shader, linear for DWM under HDR (`BuildColorMatrix(..., linearLight)`). Brightness is + decoded to linear under HDR so the slider looks the same in both. Verified on the HDR desktop: + warm 100% green x0.074 / blue 0; warm 50% x0.67 / x0.34; brightness 50% x0.214; 0% black. +- **The pointer at 1x is out of the DWM effect's reach**: Windows draws it on a hardware cursor plane + after composition (Night light, in the display pipeline, does reach it). Since 2026-09-30 Wind + swaps the standard pointers for tinted copies while idle (spec 2026-09-30-cursor-tint-design.md). + Verified on this PC: Desktop Duplication's pointer shape reads 246,76,0 at warmth 100 (246,246,246 + off), size 64x64 and hotspot kept; zoom-in swaps back in 1.1 ms (direct, no scheme reload) and the + engine draws + filters its own cursor; a fullscreen window in front and colour off restore the real + scheme (reload, ~6 ms, idle only); a force-killed Wind leaves the tint until its next start heals it. + In-memory COPIES of the pointers are not identical to the scheme's own (fixed size; the default + text beam came back as a different format), so idle restores always reload the real scheme. +- Wind's diagnostics snapshot logs `hdr=0` regardless (logging.cpp records it conservatively); the + engine's own `GetHdrEnabled` is the truth. + +## Settings crash (RTSS), 2026-09-29 + +WebView2 154's browser process loads and unloads `dxgi.dll` at start-up; RTSS's global hook +(RTSSHooks64 of 2025-09-27) remembers it and, on the next window creation (Chromium's network-cost +watcher's hidden COM window), reads the unloaded DLL in `ValidateRuntimes`: an access violation that +kills the whole WebView2 engine (dump: faulting address inside "unloaded module dxgi.dll"). Chromium +creates that watcher unconditionally (`network_change_notifier_win.cc`), so there is no flag to avoid +it. Settings now recovers: `ProcessFailed` -> recreate the engine (page process: reload), at most 3 a +minute (`src/config_ui/webview_recover.h`), and the page's unapplied edits are mirrored to the host and +handed back. Verified by killing the engine with a staged change: back in ~0.3 s with the edit kept. + +## How other night-light apps do it (research 2026-09-30, sources cited) + +- f.lux, Iris, LightBulb, Night Ember tint with the GDI gamma ramp (`SetDeviceGammaRamp`, a 1D + 256-entry table per channel; LightBulb source: https://github.com/Tyrrrz/LightBulb). +- They have THE SAME pointer problem: f.lux's FAQ says a bright white cursor "happens when your + videocard displays uses a 'hardware cursor'" and ships "Software mouse cursor when needed" + (https://justgetflux.com/faq.html); Iris has "use software mouse cursor" + (https://iristech.co/troubleshooting/). Wind's tinted-pointer swap is the same class of fix. +- They are WEAKER under HDR: Microsoft documents the gamma ramp as "undefined behavior in HDR modes" + (https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-setdevicegammaramp); f.lux + users report it stops working with HDR/Advanced Color + (https://forum.justgetflux.com/topic/6163/windows-10-with-hdr-and-advanced-color-working-with-f-lux). + Ramps are also reset by sleep/display changes (apps re-apply constantly) and deep warm shifts need + the admin `GdiIcmGammaRange` registry change. +- Windows Night light: Microsoft says it "might also use" the post-composition display pipeline + (3x3 linear matrix + 1D LUT), which apps can only reach through an ICC profile with the private + `MHC2` tag (https://learn.microsoft.com/en-us/windows/win32/wcs/display-calibration-mhc). That fits + the measurements above (invisible to capture, gamma ramp identity, hardware cursor tinted). +- Rejected for Wind: dwm_lut (injects into dwm.exe; https://github.com/ledoge/dwm_lut), NVAPI via + novideo_srgb (NVIDIA-only, fails under HDR, its README admits cursor issues; + https://github.com/ledoge/novideo_srgb), `IDXGIOutput::SetGammaControl` (exclusive fullscreen only), + `D3DKMTSetGammaRamp` (no public user-mode contract). +- The one untested lead: an MHC2 profile associated at runtime + (`ColorProfileAddDisplayAssociation`) would sit in Night light's own pipeline (HDR-capable, + likely reaching the pointer). Unknowns: pointer behaviour (https://github.com/dantmnf/MHC2 notes a + "buggy mouse cursor and MPO composition" with MHC active), change latency, stacking with Night + light, and it would displace a user's own calibration profile. + +## Review fixes (2026-09-30) + +- The render engine's drawn pointer, Inspect crosshair and zoom outline are filtered too (cursor + shader + CPU-filtered outline colour); an inverting text beam is drawn untinted (a matrix on an + inverting texture changes the inversion, it does not tint). +- Colour follows the VISIBLE engine (`RenderModel::visible()`), not the selected one: a pending + render reveal keeps the DWM-filtered desktop, the effect returns before the overlay hides at + zoom-out and the moment an outgoing render overlay rests in a hybrid switch. Worst case left: one + double-filtered frame while the capture catches up after the effect is cleared. +- HDR state is the PRIMARY monitor's, re-read once a second while a colour setting is on (toggling + HDR is not guaranteed to raise WM_DISPLAYCHANGE). Mixed HDR/SDR monitors: the single DWM matrix is + right on the primary only. +- Tinted pointer: a separate "pointers are ours" flag, so an idle restore after a render session + (which never reloads the scheme) still reloads the user's real scheme. +- Trade-off kept: colour on holds the Magnification runtime at 1x (CLAUDE.md gotcha). diff --git a/docs/superpowers/plans/2026-09-29-colour-filters.md b/docs/superpowers/plans/2026-09-29-colour-filters.md new file mode 100644 index 0000000..585c105 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-colour-filters.md @@ -0,0 +1,92 @@ +# Colour Filters Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Invert / greyscale / warm / two-colour tint filters and a dim control, at 1x and zoomed, with an optional toggle hotkey. + +**Architecture:** One DWM colour matrix (`MagSetFullscreenColorEffect`) applied through the Magnification runtime owner thread, built by a pure `color_matrix.h`, applied and lifetime-managed by `color_filter.*`, decided per tick in RunTick. + +**Tech Stack:** C++17 / MSVC, Magnification API, doctest; Svelte settings UI, Playwright. + +**Spec:** `docs/superpowers/specs/2026-09-29-colour-filters-design.md` + +## Global Constraints + +- No em-dashes anywhere (code, comments, docs, UI copy). +- Pure headers must not include ``. +- Every Magnification call runs on the owner thread via `wind::MagThreadInvoke`; the runtime is held only through `MagApiAcquire`/`MagApiRelease`. +- Nothing may cost anything when no filter is on and dim is 100% (no context, no per-tick syscalls). +- Identity must be restored on every exit path (disable, toggle, shutdown, crash filter, atexit). +- Branch `feat/288-colour-filters`, commits `type(scope): subject` with the attribution trailer. + +## Review Focus + +1. Wind killed (Task Manager) while a filter is on: the screen must not stay filtered (spike decides the mechanism). +2. Zoom in and out with a filter on: no flash of unfiltered or double-filtered frames at the transitions. +3. Render engine while zoomed: the magnified picture filtered exactly once. +4. HDR desktop: the filter still looks right (invert of HDR white). +5. Filter at 1x plus a game that toggles its cursor: the known context tax, only while a 1x filter is on. + +--- + +### Task 1: Spike (throwaway probe, not committed) + +**Files:** scratchpad only. + +- [ ] Probe exe: `MagInitialize`, `MagSetFullscreenColorEffect(invert)` at level 1; read a known white pixel through Desktop Duplication before and after. Records whether DDA sees the effect (decides 4.1 single vs render-shader path). +- [ ] Same probe, then `TerminateProcess` itself with the effect on: does the screen return to normal? (decides 4.4 crash handling). +- [ ] Repeat the pixel read with HDR on, if the display supports it. +- [ ] Record results in the spec section 4.1 / 4.4 and in `docs/COLOUR-FILTER-FINDINGS.md`. + +### Task 2: Pure matrices + +**Files:** Create `src/color_matrix.h`, `tests/test_color_matrix.cpp`. + +**Interfaces (produces):** `enum class ColorFilter { Off=0, Invert, Greyscale, Warm, YellowOnBlack, WhiteOnBlue, GreenOnBlack };` `struct ColorMatrix { float m[5][5]; };` `ColorMatrix BuildColorMatrix(ColorFilter f, double warm01, double dim01);` `bool IsIdentity(const ColorMatrix&);` `ColorMatrix Multiply(const ColorMatrix&, const ColorMatrix&);` `RGB ApplyToRgb(const ColorMatrix&, double r, double g, double b)` (test helper). + +- [ ] Write the tests: identity for (Off, any, 1.0); invert maps white to black and black to white; greyscale maps pure red to 0.2126 grey; warm strength 1 keeps red, reduces blue to 0.4; yellow-on-black maps white page to black background and black text to yellow; dim 0.5 halves; Invert + dim composes. +- [ ] Run `build.bat test`, see them fail; implement; see them pass. +- [ ] Commit `feat(color): pure colour matrices (#288)`. + +### Task 3: Config + +**Files:** `src/config.h`, `src/config.cpp`, `tests/test_config.cpp`. + +- [ ] Keys: `colorFilter` (0-6, default 0), `colorWarmPct` (10-100, default 50), `colorDimPct` (20-100, default 100), `colorAt1x` (default 1), `colorToggleVk`/`colorToggleMods` (default 0 = no hotkey). Parse, clamp, template lines. +- [ ] Tests: defaults, parse, clamps. +- [ ] Commit `feat(config): colour filter keys (#288)`. + +### Task 4: Controller + +**Files:** Create `src/color_filter.h`, `src/color_filter.cpp`. + +**Interfaces:** `class ColorFilterController { void apply(const ColorMatrix& want, bool needOwnHold); void shutdown(); }` plus a static `RestoreColorIdentityForCrash()`. + +- [ ] `apply`: if `want` equals the last applied matrix and the hold state matches, return (no syscalls). Else take or drop the own `MagApiAcquire` hold as `needOwnHold` says, then `MagThreadInvoke` a `MagSetFullscreenColorEffect`. Identity + no hold = fully released. +- [ ] `shutdown` and the crash restore write identity (while a runtime exists) and release. +- [ ] Log each change once (`color` tag): filter, dim, hold. +- [ ] Commit `feat(color): colour filter controller (#288)`. + +### Task 5: RunTick, hotkey, exits + +**Files:** `src/main.cpp`. + +- [ ] Per tick: `want = BuildColorMatrix(cfg, toggleOn)`; identity if the model is `magnify`, or if not zoomed and `colorAt1x` is 0. `needOwnHold = !IsIdentity(want) && !zoomed`. Call the controller. When the spike says the render engine filters twice, clear the effect while a render session is active and pass the matrix to the render model instead (Task 5b). +- [ ] Hotkey: register `colorToggleVk/Mods` with `RegisterHotKey` like the hide-cursor hotkey (hot-reload on change); WM_HOTKEY flips `colorToggleOn`. +- [ ] Exit paths: controller `shutdown()` in the normal shutdown, the crash filter and `atexit` next to `RestoreInputState`; a model switch re-applies. +- [ ] (5b, only if the spike requires it) render shader: add a 5x5 matrix to the constant buffer and apply it after the brightness stage. +- [ ] Commit `feat(color): colour filters in RunTick + toggle hotkey (#288)`. + +### Task 6: Settings UI + +**Files:** `ui/src/settings-schema.js`, `ui/tests/settings.spec.js`. + +- [ ] New section `{ id:'colour', label:'Colour' }`: select `colorFilter` (Off, Invert, Greyscale, Warm, Yellow on black, White on blue, Green on black), slider `colorWarmPct` (%), slider `colorDimPct` (%, "100% = normal"), toggle `colorAt1x`, keybind `__colorToggle` (vkKey `colorToggleVk`, modsKey `colorToggleMods`). +- [ ] Playwright: the section renders, choosing Invert writes `colorFilter=1`, dim slider writes `colorDimPct`. +- [ ] Commit `feat(ui): Colour settings section (#288)`. + +### Task 7: Verify, docs, ship + +- [ ] `build.bat test`, Playwright, deploy via `tools\uiaccess_setup.ps1`; owner field test per spec section 5. +- [ ] Docs: `docs/COLOUR-FILTER-FINDINGS.md` (spike results), a section in `docs/architecture/07-cursor.md` or a new chapter entry, README feature line, CLAUDE.md gotcha (effect lifetime / crash restore). +- [ ] Version bump in the PR; review workflow (sonnet reviewers + verifier), owner approves fixes, PR, owner says merge. diff --git a/docs/superpowers/plans/2026-09-30-cursor-tint.md b/docs/superpowers/plans/2026-09-30-cursor-tint.md new file mode 100644 index 0000000..9017294 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-cursor-tint.md @@ -0,0 +1,14 @@ +# Tinted pointer at 1x: implementation plan + +**Spec:** `docs/superpowers/specs/2026-09-30-cursor-tint-design.md`. Branch `feat/288-colour-filters`. + +1. `src/cursor_tint_pixels.h` (pure) + `tests/test_cursor_tint.cpp`: `TintArgb(pixels, n, matrix)` and + `MonoToArgb(andBits, xorBits, w, h, out)` with the outline rule. +2. `src/cursor_tint.{h,cpp}`: `CursorTint` with `capture()`, `apply(matrix)`, `restore()`, + `invalidate()`, `applied()`; builds tinted cursors with GetIconInfo/GetDIBits/CreateIconIndirect; + logs each apply/restore with its duration. +3. `src/main.cpp`: capture after `RestoreInputState`; idle-tick `UpdateCursorTint` (colour, idle, + magnify, fullscreen game every 250 ms); `restore()` at the start of `enterActive`; `invalidate()` on + active -> idle; `WM_SETTINGCHANGE`/`SPI_SETCURSORS` recaptures; `restore()` at shutdown. +4. Build, unit + UI tests, deploy, field-verify per the spec, document in + `docs/COLOUR-FILTER-FINDINGS.md`. diff --git a/docs/superpowers/specs/2026-09-29-colour-filters-design.md b/docs/superpowers/specs/2026-09-29-colour-filters-design.md new file mode 100644 index 0000000..3a496df --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-colour-filters-design.md @@ -0,0 +1,125 @@ +# Colour filters (issue #288) + +Date: 2026-09-29. Owner: Max. Status: awaiting approval (spec + plan together). + +## 1. What the owner asked for + +Colour filters for low vision, chosen in Settings and optionally toggled with a hotkey: + +- **Invert colours** +- **Greyscale** +- **Custom tints**: two-colour reading schemes (yellow on black, white on blue, green on black) +- **Warm (orange) tint**, like a night light, with a strength setting +- **Dim**: an artificial "TV brightness" control that darkens the picture (the panel's backlight is + untouched; it is a colour-scale on the image) + +Decisions (2026-09-29): +1. Filters apply **while zoomed and at 1x**. +2. A **toggle hotkey** is optional; a filter can simply be always on. +3. Group the choices in dropdowns rather than a long list of toggles. + +## 2. Behaviour + +- Settings > **Colour** section: + - **Colour filter** (dropdown): Off, Invert, Greyscale, Warm, Yellow on black, White on blue, + Green on black. + - **Warm strength** (slider 10-100%, default 50%): used by Warm. + - **Dim** (slider 100% = off down to 20%, default 100%): composes with any filter (e.g. Invert + + dim), and works on its own with the filter Off. + - **Also when not zoomed** (toggle, default on): off = the filter only shows while zoomed. + - **Toggle colour filter** (keybind, optional): flips the filter (and dim) on and off without + changing the chosen settings. The toggle state resets to "on" when Wind starts. +- The filter covers the whole monitor Wind magnifies, including the cursor, exactly like Windows + Magnifier's colour inversion. +- `model=magnify` (native Windows Magnifier) is out of scope: Windows Magnifier has its own filters; + the rows show a note there. +- Nothing changes for a user with no filter and dim at 100%: no context, no cost (see 4.3). + +## 3. Scope + +In: the five filter kinds above, dim, the 1x option, the hotkey, both engines on the primary +monitor. Out (v1): per-app filters (can ride #286 profiles later), colour-blind correction matrices, +custom colour pickers for tints, multi-monitor secondaries at 1x. + +## 4. Design + +### 4.1 Mechanism: the DWM colour effect + +`MagSetFullscreenColorEffect(MAGCOLOREFFECT*)` (Magnification.dll, the same call Windows Magnifier +uses for inversion) applies a 5x5 colour matrix inside DWM to everything composed on screen. It costs +nothing per frame (done by the compositor) and works at level 1. It needs a live magnification +runtime (`MagApiAcquire`) and, like every Magnification call, must run on the runtime's owner thread +(`MagThreadInvoke`). + +One effect for both engines: the render engine's overlay is itself a window DWM composes, so the +effect lands on the magnified picture too. **This must be verified by the spike (Task 1)**: if Desktop +Duplication captures the desktop AFTER the effect, the render engine would filter twice, and the +render path then needs the effect cleared while its overlay is up and the matrix applied in its pixel +shader instead (the shader already has a brightness stage, `render_shaders.h`). + +### 4.2 Units + +| Unit | Kind | Responsibility | +|---|---|---| +| `src/color_matrix.h` | pure, doctested | Build the 5x5 matrix from (filter, warm strength, dim); identity check; compose | +| `src/color_filter.h/.cpp` | Win32 | Holds a runtime reference while a filter must exist at 1x, applies the matrix (deduped) through the owner thread, restores identity and releases on disable / shutdown / crash | +| `src/main.cpp` RunTick | integration | Decide the wanted matrix per tick (config, toggle state, zoomed, model) and hand it to the controller; the toggle hotkey | +| `src/config.*`, `ui/src/settings-schema.js` | settings | Keys, defaults, the Colour section | + +### 4.3 Runtime lifetime and the 1x cost + +- Zoomed: the engine already holds a runtime; the filter adds nothing. +- At 1x with a filter on: the controller holds its own `MagApiAcquire` reference, so the transform + model's idle release (~1.2 s after zoom-out) does not tear the context down. **Cost, by design:** a + live context adds a DWM re-composite to every cursor shape/visibility change any app makes (the + documented tax; a game that toggles its pointer can hitch). It is paid ONLY while a 1x filter is on; + "Also when not zoomed" off avoids it. +- Filter off (or dim 100% with filter Off, or toggled off): identity is written and the reference is + released, so the idle machine is exactly as today. + +### 4.4 Safety + +- Identity is restored on every exit path: disable, toggle, model switch, `shutdown`, the crash + filter and `atexit` (next to the existing `RestoreInputState`). +- The spike checks whether Windows clears the effect by itself when the process dies. If it does not, + a killed Wind would leave the screen filtered; the crash-path restore is then mandatory, and a + stale effect found at startup is cleared. + +### 4.5 Matrices (row vectors, DWM layout: out = in x M) + +- Invert: `-1` diagonal, `+1` offset row (on RGB). +- Greyscale: luma weights 0.2126 / 0.7152 / 0.0722 in every column. +- Warm (strength s): R x 1, G x (1 - 0.25 s), B x (1 - 0.6 s). +- Two-colour tints: greyscale luma L, then out = bg + L x (fg - bg) for dark-background schemes + (text light) computed from the INVERTED luma so dark text on white pages becomes light on dark: + yellow on black (fg 1,1,0 / bg 0,0,0), white on blue (fg 1,1,1 / bg 0,0,0.5), green on black + (fg 0,1,0 / bg 0,0,0). +- Dim d (0.2-1.0): RGB x d. Composed last with the chosen filter. + +## 5. Testing + +- Doctests for every matrix (identity, invert of white/black, greyscale weights, warm strength ends, + tint endpoints, dim composition, identity detection). +- Playwright for the Colour section rows. +- Spike + field on this PC (signed UIAccess build): each filter at 1x and zoomed, in both engines + (transform desktop, render via `desktopTransform=0`), HDR on/off, hotkey toggle, zoom in/out + transitions with no flash, Wind quit and kill leave the screen clean, no hitch at 1x with no filter. + +## 6. Delivery + +One PR: issue #288 -> `feat/288-colour-filters`, version bump inside the PR (0.12.0 as a feature; +owner may prefer a patch bump), release on merge. + +## Amendment: two sliders only (owner field test, 2026-09-29) + +This supersedes the filter list, the "also when not zoomed" toggle and the hotkey above. + +- The feature is two sliders, always applied (zoomed and at 1x): **Warmth** (`colorWarmPct`, + 0-100, default 0 = off) and **Brightness** (`colorDimPct`, 1-100, default 100). Both neutral = no + colour effect and no held Magnification runtime. +- Warmth follows the blackbody curve from 6500 K to 1200 K (Night light's range), linear in + mireds; 100% is about (1, 0.34, 0). The first version (G x 0.75, B x 0.4 at 100%) read as dim pink. +- Brightness floor 1% (`kMinDim01`; owner, 2026-09-30: 0 was completely black). +- Removed: Invert, Greyscale, the two-colour tints, `colorAt1x`, the toggle hotkey + (`colorFilter`, `colorAt1x`, `colorToggleVk/Mods` in an old ini are ignored). Why: a single + colour matrix cannot keep multi-coloured text readable, see `docs/COLOUR-FILTER-FINDINGS.md`. diff --git a/docs/superpowers/specs/2026-09-30-cursor-tint-design.md b/docs/superpowers/specs/2026-09-30-cursor-tint-design.md new file mode 100644 index 0000000..4bf5fae --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-cursor-tint-design.md @@ -0,0 +1,47 @@ +# Tinted pointer at 1x (issue #288 follow-up) + +Date: 2026-09-30. Owner: Max. Status: approved to build without review ("plan spec and implement now, +no approval from me"); owner tests the verified build. + +## Problem + +Warmth and Brightness reach everything at 1x except the mouse pointer. Windows draws the pointer on a +hardware cursor plane after composition, which the DWM colour effect never touches (Night light, in +the display pipeline, does). While zoomed Wind draws the cursor itself, so it is filtered there. + +## Design + +While the colour is not neutral, Wind is idle (1x, not Inspect), the engine is not the native +Magnifier, and no fullscreen game is in front, Wind replaces the standard system pointers with tinted +copies (`SetSystemCursor`). They stay hardware pointers: no per-frame cost, no latency. + +- **Source images:** pristine copies of the 14 standard pointers captured at start-up (after the + start-up scheme reload), per-monitor-DPI sized (64x64 at 225% on the owner's PC), recaptured when + the user changes the pointer scheme (`WM_SETTINGCHANGE` + `SPI_SETCURSORS`). +- **Tint:** the same matrix as the screen in sRGB-encoded form (`BuildColorMatrix(w, d, false)`), applied + to each pixel's RGB, alpha kept. Size and hotspot unchanged. +- **Monochrome pointers** (the default text beam inverts what is under it): rebuilt as colour + pointers: black/white pixels kept, inverting pixels drawn white with a 1 px dark outline so the beam + stays visible on light and dark backgrounds, then tinted. +- **Animated pointers** (busy, app starting) are left as they are: a static copy would stop the + animation. +- **Zoom-in:** the pristine pointers go back first, before the engine hides or captures the pointer + (the transform sprite and the render engine draw the real shape and filter it themselves; a tinted + source would be tinted twice). Direct `SetSystemCursor` of the in-memory copies, never a scheme + reload (`SPI_SETCURSORS` rereads the registry and broadcasts to every window: a hitch risk). +- **Zoom-out:** the engines restore the scheme; the next idle tick re-applies the tint. +- **Fullscreen game in front:** pristine pointers (the DWM effect does not reach exclusive fullscreen, + so a tinted arrow would not match). Checked at most every 250 ms while idle. +- **Exit, crash, force-kill:** quitting restores the pristine pointers; the existing start-up and crash + heals (`SPI_SETCURSORS`) restore the scheme, so a force-killed Wind is healed on its next start. +- **No fighting:** Wind only rewrites pointers when its colour, zoom state or the foreground-game state + changes, never on a loop. + +## Testing + +- Doctests: pixel tint (RGB by matrix, alpha kept, identity untouched), monochrome conversion (the + four AND/XOR cases and the outline). +- Field verification on the owner's PC (by Claude, before the owner tests): tinted system pointers + read back with the expected pixels, size and hotspot; the pointer shape Desktop Duplication reports + is the tinted one; zoom-in restores pristine and zoom-out re-tints; a borderless fullscreen window + in front restores pristine; quitting restores; apply/restore timing logged. diff --git a/src/color_filter.cpp b/src/color_filter.cpp new file mode 100644 index 0000000..0b2e26f --- /dev/null +++ b/src/color_filter.cpp @@ -0,0 +1,52 @@ +#include "color_filter.h" +#include "mag_host.h" +#include "mag_thread.h" +#include "logging.h" +#include +#include +#include +namespace wind { + +static bool WriteEffect(const ColorMatrix& m) { + MAGCOLOREFFECT e{}; + static_assert(sizeof(e.transform) == sizeof(m.m), "MAGCOLOREFFECT is 5x5 floats"); + std::memcpy(e.transform, m.m, sizeof(m.m)); + // Thread-affine like every Magnification call: run on the runtime's owner (inline when we are it). + return MagThreadInvoke([e]() mutable -> bool { return MagSetFullscreenColorEffect(&e) != FALSE; }); +} + +void ColorFilterController::apply(const ColorMatrix& want, bool needOwnHold) { + const bool identity = IsIdentity(want); + // The effect lives in the runtime: if the runtime went away (the engine released its context + // and we held none), whatever we applied is gone with it. + if (!MagApiAlive()) applied_ = false; + if (identity) needOwnHold = false; + if (needOwnHold && !holding_) { + holding_ = MagApiAcquire(); + if (holding_) applied_ = false; // possibly a fresh runtime: re-apply + } + if (!MagApiAlive()) { + // Nothing to write into yet (zoomed, before the engine's first write builds its context). + // Retried next tick; identity needs nothing. + return; + } + const bool alreadyThere = applied_ ? SameMatrix(want, lastApplied_) : identity; + if (!alreadyThere) { + const bool ok = WriteEffect(want); + if (ok) { lastApplied_ = want; applied_ = true; } + wind::Log(ok ? wind::LogLevel::Info : wind::LogLevel::Warn, "color", "effect %s (%s)", + identity ? "cleared" : "applied", ok ? "ok" : "FAILED"); + } + if (!needOwnHold && holding_) { + // Identity went out above before we let go, so releasing cannot strand a filtered screen. + MagApiRelease(); + holding_ = false; + } +} + +void ColorFilterController::shutdown() { + if (MagApiAlive() && applied_ && !IsIdentity(lastApplied_)) WriteEffect(IdentityColorMatrix()); + applied_ = false; + if (holding_) { MagApiRelease(); holding_ = false; } +} +} // namespace wind diff --git a/src/color_filter.h b/src/color_filter.h new file mode 100644 index 0000000..20b9973 --- /dev/null +++ b/src/color_filter.h @@ -0,0 +1,22 @@ +#pragma once +// Colour filter controller (issue #288). Applies the DWM colour effect (MagSetFullscreenColorEffect) +// through the Magnification runtime's owner thread, deduped so an unchanged filter costs one compare +// per tick. At 1x it holds its OWN runtime reference while a filter is on, because the effect only +// exists while a runtime lives, and a live runtime taxes every cursor change any app makes (so it is +// held only while it has to be). Windows clears the effect when the process dies +// (docs/COLOUR-FILTER-FINDINGS.md), so a crash cannot leave the screen filtered. +#include "color_matrix.h" +namespace wind { +class ColorFilterController { +public: + // want: the matrix DWM should apply now (identity = no filter). needOwnHold: keep a runtime + // alive ourselves (1x); when false the engine's runtime carries the effect (zoomed). + void apply(const ColorMatrix& want, bool needOwnHold); + // Identity, then release our reference. Idempotent. + void shutdown(); +private: + bool holding_ = false; + bool applied_ = false; // lastApplied_ is live in the current runtime + ColorMatrix lastApplied_{}; +}; +} // namespace wind diff --git a/src/color_matrix.h b/src/color_matrix.h new file mode 100644 index 0000000..09639a8 --- /dev/null +++ b/src/color_matrix.h @@ -0,0 +1,125 @@ +#pragma once +// Colour filter matrices (issue #288). Pure; tests/test_color_matrix.cpp. +// +// Layout matches the DWM colour effect (MAGCOLOREFFECT): a ROW vector [R G B A 1] times the 5x5 +// matrix, so out[j] = sum_i in[i] * m[i][j] and row 4 holds the offsets. The same numbers feed the +// render engine's pixel shader, which cannot use the DWM effect (its capture already contains it: +// docs/COLOUR-FILTER-FINDINGS.md). +#include +namespace wind { + +struct ColorMatrix { float m[5][5]; }; + +inline ColorMatrix IdentityColorMatrix() { + ColorMatrix r{}; + for (int i = 0; i < 5; ++i) r.m[i][i] = 1.0f; + return r; +} + +inline ColorMatrix Multiply(const ColorMatrix& a, const ColorMatrix& b) { + ColorMatrix r{}; + for (int i = 0; i < 5; ++i) + for (int j = 0; j < 5; ++j) { + double s = 0.0; + for (int k = 0; k < 5; ++k) s += (double)a.m[i][k] * b.m[k][j]; + r.m[i][j] = (float)s; + } + return r; +} + +inline bool IsIdentity(const ColorMatrix& c) { + for (int i = 0; i < 5; ++i) + for (int j = 0; j < 5; ++j) { + const float want = i == j ? 1.0f : 0.0f; + const float d = c.m[i][j] - want; + if (d > 1e-6f || d < -1e-6f) return false; + } + return true; +} + +inline bool SameMatrix(const ColorMatrix& a, const ColorMatrix& b) { + for (int i = 0; i < 5; ++i) + for (int j = 0; j < 5; ++j) + if (a.m[i][j] != b.m[i][j]) return false; + return true; +} + +// Lowest brightness the dim control reaches: 1% (owner, 2026-09-30: 0 made the screen completely +// black, so the floor stays just above it; under HDR 1% decodes to ~0.08% light, still readable on an +// OLED in the dark). Quitting Wind (Ctrl+Alt+Q) always clears the effect; so does a crash. +inline constexpr double kMinDim01 = 0.01; + +// Channel gains for a blackbody colour temperature, in LINEAR light, normalised so 6500 K is white +// and the largest channel is 1. Computed from Planck's law and the CIE 1931 colour-matching functions +// (Wyman-Sloan-Shirley fit), converted to linear sRGB; 1000 K to 6500 K in 100 K steps. +inline constexpr float kKelvinGains[][3] = { + { 1.0000f, 0.0303f, 0.0000f }, { 1.0000f, 0.0532f, 0.0000f }, { 1.0000f, 0.0774f, 0.0000f }, { 1.0000f, 0.1024f, 0.0000f }, + { 1.0000f, 0.1279f, 0.0000f }, { 1.0000f, 0.1537f, 0.0000f }, { 1.0000f, 0.1796f, 0.0000f }, { 1.0000f, 0.2055f, 0.0000f }, + { 1.0000f, 0.2312f, 0.0000f }, { 1.0000f, 0.2567f, 0.0000f }, { 1.0000f, 0.2820f, 0.0077f }, { 1.0000f, 0.3069f, 0.0174f }, + { 1.0000f, 0.3315f, 0.0283f }, { 1.0000f, 0.3557f, 0.0404f }, { 1.0000f, 0.3794f, 0.0538f }, { 1.0000f, 0.4027f, 0.0682f }, + { 1.0000f, 0.4256f, 0.0838f }, { 1.0000f, 0.4480f, 0.1004f }, { 1.0000f, 0.4700f, 0.1179f }, { 1.0000f, 0.4915f, 0.1364f }, + { 1.0000f, 0.5125f, 0.1557f }, { 1.0000f, 0.5331f, 0.1757f }, { 1.0000f, 0.5532f, 0.1965f }, { 1.0000f, 0.5729f, 0.2179f }, + { 1.0000f, 0.5920f, 0.2400f }, { 1.0000f, 0.6108f, 0.2625f }, { 1.0000f, 0.6291f, 0.2856f }, { 1.0000f, 0.6469f, 0.3091f }, + { 1.0000f, 0.6643f, 0.3330f }, { 1.0000f, 0.6813f, 0.3572f }, { 1.0000f, 0.6979f, 0.3817f }, { 1.0000f, 0.7140f, 0.4064f }, + { 1.0000f, 0.7298f, 0.4314f }, { 1.0000f, 0.7451f, 0.4565f }, { 1.0000f, 0.7601f, 0.4818f }, { 1.0000f, 0.7747f, 0.5072f }, + { 1.0000f, 0.7889f, 0.5326f }, { 1.0000f, 0.8028f, 0.5581f }, { 1.0000f, 0.8163f, 0.5836f }, { 1.0000f, 0.8295f, 0.6091f }, + { 1.0000f, 0.8423f, 0.6346f }, { 1.0000f, 0.8548f, 0.6599f }, { 1.0000f, 0.8670f, 0.6852f }, { 1.0000f, 0.8789f, 0.7104f }, + { 1.0000f, 0.8904f, 0.7355f }, { 1.0000f, 0.9017f, 0.7605f }, { 1.0000f, 0.9127f, 0.7853f }, { 1.0000f, 0.9234f, 0.8099f }, + { 1.0000f, 0.9339f, 0.8344f }, { 1.0000f, 0.9441f, 0.8587f }, { 1.0000f, 0.9540f, 0.8828f }, { 1.0000f, 0.9637f, 0.9067f }, + { 1.0000f, 0.9731f, 0.9303f }, { 1.0000f, 0.9823f, 0.9538f }, { 1.0000f, 0.9913f, 0.9770f }, { 1.0000f, 1.0000f, 1.0000f } +}; +inline constexpr int kKelvinMin = 1000, kKelvinStep = 100; +inline constexpr int kKelvinCount = (int)(sizeof(kKelvinGains) / sizeof(kKelvinGains[0])); + +inline void KelvinGainsLinear(double kelvin, double& r, double& g, double& b) { + double f = (kelvin - kKelvinMin) / kKelvinStep; + if (f < 0) f = 0; + if (f > kKelvinCount - 1) f = kKelvinCount - 1; + const int i = (int)f; + const int j = i + 1 < kKelvinCount ? i + 1 : i; + const double t = f - i; + r = kKelvinGains[i][0] + (kKelvinGains[j][0] - kKelvinGains[i][0]) * t; + g = kKelvinGains[i][1] + (kKelvinGains[j][1] - kKelvinGains[i][1]) * t; + b = kKelvinGains[i][2] + (kKelvinGains[j][2] - kKelvinGains[i][2]) * t; +} + +inline double SrgbEncode(double c) { return c <= 0.0031308 ? 12.92 * c : 1.055 * std::pow(c, 1.0 / 2.4) - 0.055; } +inline double SrgbDecode(double c) { return c <= 0.04045 ? c / 12.92 : std::pow((c + 0.055) / 1.055, 2.4); } + +// Warmth 0..1 -> colour temperature, on Night light's own scale: 0% = 6500 K, 100% = 1200 K, +// LINEAR in Kelvin (measured 2026-09-29 from its stored setting: 50% = 3850 K). +inline double WarmKelvin(double warm01) { return 6500.0 - 5300.0 * warm01; } + +// The whole colour feature is two controls (owner decision 2026-09-29): warmth and brightness. +// Invert, greyscale and two-colour tints were dropped: one colour matrix cannot keep multi-coloured +// text readable (docs/COLOUR-FILTER-FINDINGS.md). +// warm01: 0..1 (0 = no warmth). dim01: kMinDim01..1 (1 = no dim). Out-of-range values clamp. +// linearLight: the matrix acts on LINEAR values. True for the DWM colour effect while Windows HDR is +// on (measured: it scales scRGB directly); false for SDR DWM and for the render engine's shader, +// which apply it to sRGB-encoded values. Warmth uses the blackbody gains in the matching space, and +// brightness is decoded to linear there, so both look the same in SDR and HDR. +// Both at their neutral ends give the identity, which the controller treats as "off". +inline ColorMatrix BuildColorMatrix(double warm01, double dim01, bool linearLight) { + auto clamp = [](double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }; + warm01 = clamp(warm01, 0.0, 1.0); + dim01 = clamp(dim01, kMinDim01, 1.0); + ColorMatrix m = IdentityColorMatrix(); + if (warm01 > 0.0) { + double g[3]; + KelvinGainsLinear(WarmKelvin(warm01), g[0], g[1], g[2]); + for (int i = 0; i < 3; ++i) m.m[i][i] = (float)(linearLight ? g[i] : SrgbEncode(g[i])); + } + const double d = linearLight ? SrgbDecode(dim01) : dim01; + for (int i = 0; i < 3; ++i) m.m[i][i] = (float)(m.m[i][i] * d); // dim composes last + return m; +} + +// Test/diagnostic helper: apply to an opaque RGB colour. +inline void ApplyToRgb(const ColorMatrix& c, double r, double g, double b, double& o0, double& o1, double& o2) { + const double in[5] = { r, g, b, 1.0, 1.0 }; + double out[3] = { 0, 0, 0 }; + for (int j = 0; j < 3; ++j) + for (int i = 0; i < 5; ++i) out[j] += in[i] * c.m[i][j]; + o0 = out[0]; o1 = out[1]; o2 = out[2]; +} +} // namespace wind diff --git a/src/config.cpp b/src/config.cpp index 3b7e4c2..6ba313f 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -175,6 +175,8 @@ Config ParseConfig(const std::string& text) { else if (key == "sharpness") c.sharpness = std::stod(val); else if (key == "zorderBand") c.zorderBand = std::stoi(val); else if (key == "brightness") c.brightness = std::stod(val); + else if (key == "colorWarmPct") c.colorWarmPct = std::stoi(val); + else if (key == "colorDimPct") c.colorDimPct = std::stoi(val); else if (key == "hdrTonemap") c.hdrTonemap = std::stoi(val); else if (key == "multiMonitor") c.multiMonitor = std::stoi(val); else if (key == "cropCapture") c.cropCapture = std::stoi(val); @@ -256,6 +258,8 @@ Config ParseConfig(const std::string& text) { c.cursorSmoothing = clampd(c.cursorSmoothing, 0.0, 0.95); c.sharpness = clampd(c.sharpness, 0.0, 1.0); 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.quickZoomDefault = clampd(c.quickZoomDefault, 1.0, 50.0); if (c.outlineThickness < 1) c.outlineThickness = 1; if (c.outlineThickness > 40) c.outlineThickness = 40; @@ -465,6 +469,9 @@ std::string DefaultIniText() { "mouseMarginPct=0\n" "; brightness: magnified-view output multiplier (1.0=unchanged; fine-tune for HDR)\n" "brightness=1.0\n" + "; colorWarmPct: screen warmth like Night light (0-100, 0 = off); colorDimPct: artificial brightness (1-100, 100 = normal)\n" + "colorWarmPct=0\n" + "colorDimPct=100\n" "; hdrTonemap: 1=HDR10->SDR tonemap when Windows HDR is on (no-op on SDR); 0=off\n" "hdrTonemap=1\n" "; model: hybrid = Auto (default): picks render or transform per zoom-in (games get\n" diff --git a/src/config.h b/src/config.h index e29e0c6..a10616d 100644 --- a/src/config.h +++ b/src/config.h @@ -367,7 +367,7 @@ struct Config { int edgeClip = 1; int txPace = 0; int txHookWrite = 0; - int panelPointer = 1; // #283: real magnified pointer + hook writes while a shell input panel is open (restart) + int panelPointer = 1; // #283: real magnified pointer, frozen and moved by Wind, while a shell input panel is open int txFreeCursor = 1; // WRITE CADENCE - SHIPPED OFF (tried ON 2026-08-26, REVERTED the same day on field report). // The theory (issue #204) is sound: we write ~144/s where native writes ~49/s, and each write @@ -505,6 +505,10 @@ struct Config { int zorderBand = 0; // Output brightness multiplier for the magnified view. 1.0 = unchanged. Hot-reloadable. double brightness = 1.0; + // Colour filters (issue #288): a DWM colour matrix (render engine: its pixel shader). + // Two controls, always applied (zoomed or not): warmth and brightness. Both neutral = off. + int colorWarmPct = 0; // warmth 0..100 (0 = off, 100 = 1200 K like Night light at full) + int colorDimPct = 100; // artificial brightness 1..100 (100 = no dim; quitting Wind clears it) // HDR->SDR tonemap. Only engages when Windows HDR is actually on (advancedColorEnabled); // on SDR it's a no-op (plain BGRA8 passthrough), so it's safe on by default. Set 0 to // force the legacy BGRA8 capture even on HDR. Applied at startup + on HDR toggle. diff --git a/src/cursor_tint.cpp b/src/cursor_tint.cpp new file mode 100644 index 0000000..92202b3 --- /dev/null +++ b/src/cursor_tint.cpp @@ -0,0 +1,139 @@ +#include "cursor_tint.h" +#include "cursor_tint_pixels.h" +#include "logging.h" +#include + +namespace wind { + +// The standard system pointers (OCR_*), the same set the cursor blanker swaps. +static const UINT kIds[] = { 32512, 32513, 32514, 32515, 32516, 32642, 32643, + 32644, 32645, 32646, 32648, 32649, 32650, 32651 }; +// Animated in every stock scheme (busy, app starting): a static copy would freeze them. +static bool IsAnimatedId(UINT id) { return id == 32514 || id == 32650; } + +static double MsSince(const LARGE_INTEGER& a) { + LARGE_INTEGER b, f; QueryPerformanceCounter(&b); QueryPerformanceFrequency(&f); + return double(b.QuadPart - a.QuadPart) * 1000.0 / double(f.QuadPart); +} + +CursorTint::~CursorTint() { release(); } + +void CursorTint::release() { + for (auto& c : pristine_) if (c) { DestroyCursor(c); c = nullptr; } +} + +void CursorTint::capture() { + release(); + for (int i = 0; i < kCount; ++i) { + HCURSOR shared = LoadCursorW(nullptr, MAKEINTRESOURCEW(kIds[i])); + pristine_[i] = shared ? CopyCursor(shared) : nullptr; + mono_[i] = false; + ICONINFO ii{}; + if (pristine_[i] && GetIconInfo(pristine_[i], &ii)) { + mono_[i] = ii.hbmColor == nullptr; + if (ii.hbmMask) DeleteObject(ii.hbmMask); + if (ii.hbmColor) DeleteObject(ii.hbmColor); + } + } + applied_ = false; + swapped_ = false; // capture() runs right after a scheme reload: the pointers are the user's +} + +// Reads a bitmap as top-down 32bpp (colour) or 1bpp (mask) rows. +static bool ReadBits(HDC dc, HBITMAP bm, int w, int h, int bpp, std::vector& out, int& stride) { + struct { BITMAPINFOHEADER h; RGBQUAD pal[2]; } bi{}; + bi.h.biSize = sizeof(BITMAPINFOHEADER); + bi.h.biWidth = w; bi.h.biHeight = -h; bi.h.biPlanes = 1; bi.h.biBitCount = (WORD)bpp; + bi.h.biCompression = BI_RGB; + stride = ((w * bpp + 31) / 32) * 4; + out.assign((size_t)stride * h, 0); + return GetDIBits(dc, bm, 0, h, out.data(), reinterpret_cast(&bi), DIB_RGB_COLORS) == h; +} + +// A tinted copy of `src`, same size and hotspot; nullptr on any failure (the caller keeps the original). +static HCURSOR BuildTinted(HCURSOR src, const ColorMatrix& m) { + ICONINFO ii{}; + if (!GetIconInfo(src, &ii)) return nullptr; + BITMAP bm{}; + GetObjectW(ii.hbmMask, sizeof(bm), &bm); + const int w = bm.bmWidth, h = ii.hbmColor ? bm.bmHeight : bm.bmHeight / 2; + HCURSOR out = nullptr; + HDC dc = GetDC(nullptr); + std::vector px((size_t)w * h); + bool ok = w > 0 && h > 0; + std::vector mask; int mStride = 0; + if (ok) ok = ReadBits(dc, ii.hbmMask, w, ii.hbmColor ? h : h * 2, 1, mask, mStride); + if (ok && ii.hbmColor) { + std::vector col; int cStride = 0; + ok = ReadBits(dc, ii.hbmColor, w, h, 32, col, cStride); + if (ok) { + memcpy(px.data(), col.data(), px.size() * 4); + if (!AnyAlpha(px.data(), (int)px.size())) AlphaFromMask(px.data(), w, h, mask.data(), mStride); + } + } else if (ok) { + MonoToArgb(mask.data(), mask.data() + (size_t)mStride * h, mStride, w, h, px.data()); + } + if (ok) { + TintArgb(px.data(), (int)px.size(), m); + BITMAPV5HEADER bh{}; + bh.bV5Size = sizeof(bh); bh.bV5Width = w; bh.bV5Height = -h; bh.bV5Planes = 1; bh.bV5BitCount = 32; + bh.bV5Compression = BI_BITFIELDS; + bh.bV5RedMask = 0x00FF0000; bh.bV5GreenMask = 0x0000FF00; bh.bV5BlueMask = 0x000000FF; bh.bV5AlphaMask = 0xFF000000; + void* bits = nullptr; + HBITMAP color = CreateDIBSection(dc, reinterpret_cast(&bh), DIB_RGB_COLORS, &bits, nullptr, 0); + HBITMAP andMask = CreateBitmap(w, h, 1, 1, nullptr); // all 0: the alpha channel decides + if (color && bits && andMask) { + memcpy(bits, px.data(), px.size() * 4); + // CreateBitmap leaves the bits undefined: clear the mask explicitly. + std::vector zero((size_t)(((w + 15) / 16) * 2) * h, 0); + SetBitmapBits(andMask, (DWORD)zero.size(), zero.data()); + ICONINFO ni{}; + ni.fIcon = FALSE; ni.xHotspot = ii.xHotspot; ni.yHotspot = ii.yHotspot; + ni.hbmMask = andMask; ni.hbmColor = color; + out = (HCURSOR)CreateIconIndirect(&ni); + } + if (color) DeleteObject(color); + if (andMask) DeleteObject(andMask); + } + ReleaseDC(nullptr, dc); + if (ii.hbmMask) DeleteObject(ii.hbmMask); + if (ii.hbmColor) DeleteObject(ii.hbmColor); + return out; +} + +void CursorTint::apply(const ColorMatrix& m) { + if (IsIdentity(m)) { restore(true); return; } + if (applied_ && SameMatrix(m, current_)) return; + LARGE_INTEGER t0; QueryPerformanceCounter(&t0); + int done = 0; + for (int i = 0; i < kCount; ++i) { + if (!pristine_[i] || IsAnimatedId(kIds[i])) continue; + HCURSOR tinted = BuildTinted(pristine_[i], m); + if (tinted && SetSystemCursor(tinted, kIds[i])) ++done; // SetSystemCursor owns `tinted` on success + else if (tinted) DestroyCursor(tinted); + } + applied_ = true; + swapped_ = true; + current_ = m; + wind::Log(wind::LogLevel::Info, "color", "pointer tint applied (%d pointers, %.1f ms)", done, MsSince(t0)); +} + +void CursorTint::restore(bool reloadScheme) { + if (reloadScheme ? !swapped_ : !applied_) return; + LARGE_INTEGER t0; QueryPerformanceCounter(&t0); + if (reloadScheme) { + SystemParametersInfoW(SPI_SETCURSORS, 0, nullptr, 0); // no broadcast: nothing else needs telling + swapped_ = false; + } else { + for (int i = 0; i < kCount; ++i) { + if (!pristine_[i] || IsAnimatedId(kIds[i])) continue; + HCURSOR copy = CopyCursor(pristine_[i]); + if (copy && !SetSystemCursor(copy, kIds[i])) DestroyCursor(copy); + } + } + applied_ = false; + wind::Log(wind::LogLevel::Info, "color", "pointer tint restored (%s, %.1f ms)", + reloadScheme ? "scheme reload" : "direct swap", MsSince(t0)); +} + +} // namespace wind diff --git a/src/cursor_tint.h b/src/cursor_tint.h new file mode 100644 index 0000000..aa1e9e2 --- /dev/null +++ b/src/cursor_tint.h @@ -0,0 +1,45 @@ +#pragma once +// Tinted system pointers at 1x (spec 2026-09-30-cursor-tint-design.md). Windows draws the pointer on +// a hardware plane the DWM colour effect never reaches, so while Warmth/Brightness are on Wind swaps +// the standard pointers for tinted copies. Tick thread only. +#include +#include "color_matrix.h" + +namespace wind { + +class CursorTint { +public: + ~CursorTint(); + // Pristine copies of the standard pointers. Call when the scheme is known clean: at start-up + // after the scheme reload, and on WM_SETTINGCHANGE/SPI_SETCURSORS (the user changed it). + void capture(); + // Tints every non-animated standard pointer with `m` (the sRGB-encoded matrix). No-op when the + // same matrix is already applied. The identity restores instead. + void apply(const ColorMatrix& m); + // Puts the pointers back. reloadScheme=true (idle: colour off, fullscreen app, exit) reloads the + // user's real scheme, which keeps multi-size, DPI-scaling pointers. false (zoom-in) swaps the + // in-memory copies directly: no registry read, no broadcast, safe on the zoom-in path, and only + // momentary because zoom-out reloads the scheme anyway. Measured 2026-09-30: copies differ + // slightly from the scheme's own pointers (fixed size, the text beam's format), so they must + // never be what stays on screen. + void restore(bool reloadScheme); + // Someone else (an engine's scheme reload) already put the scheme back: forget our state so the + // next apply() re-tints. + void invalidate() { applied_ = false; } + bool applied() const { return applied_; } + +private: + static constexpr int kCount = 14; + HCURSOR pristine_[kCount] = {}; + bool mono_[kCount] = {}; + bool applied_ = false; + // The system pointers are ours (tinted, or the in-memory copies from a zoom-in swap) rather than + // the user's scheme. Separate from applied_: a zoom-in swap and invalidate() clear applied_, but + // the render engine never reloads the scheme at zoom-out, so the copies can still be on screen + // (review 2026-09-30). Only a real scheme reload clears it. + bool swapped_ = false; + ColorMatrix current_{}; + void release(); +}; + +} // namespace wind diff --git a/src/cursor_tint_pixels.h b/src/cursor_tint_pixels.h new file mode 100644 index 0000000..50f1c2a --- /dev/null +++ b/src/cursor_tint_pixels.h @@ -0,0 +1,70 @@ +#pragma once +// Pixel work for the tinted 1x pointer (spec 2026-09-30-cursor-tint-design.md). Pure; tests in +// tests/test_cursor_tint.cpp. Pixels are 32bpp BI_RGB DIB values: 0xAARRGGBB, straight (not +// premultiplied) alpha, which is what cursor colour bitmaps hold and CreateIconIndirect takes. +#include +#include "color_matrix.h" +namespace wind { + +inline uint32_t TintPixel(uint32_t p, const ColorMatrix& m) { + const double r = ((p >> 16) & 0xFF) / 255.0, g = ((p >> 8) & 0xFF) / 255.0, b = (p & 0xFF) / 255.0; + double o[3]; + ApplyToRgb(m, r, g, b, o[0], o[1], o[2]); + uint32_t c[3]; + for (int i = 0; i < 3; ++i) { + double v = o[i] < 0 ? 0 : (o[i] > 1 ? 1 : o[i]); + c[i] = (uint32_t)(v * 255.0 + 0.5); + } + return (p & 0xFF000000u) | (c[0] << 16) | (c[1] << 8) | c[2]; +} + +inline void TintArgb(uint32_t* px, int n, const ColorMatrix& m) { + for (int i = 0; i < n; ++i) px[i] = TintPixel(px[i], m); +} + +// An old-style colour cursor carries no alpha (all zero) and relies on its AND mask instead. +inline bool AnyAlpha(const uint32_t* px, int n) { + for (int i = 0; i < n; ++i) if (px[i] & 0xFF000000u) return true; + return false; +} + +// 1bpp DIB rows, DWORD-aligned, most significant bit = leftmost pixel. +inline bool MaskBit(const uint8_t* bits, int stride, int x, int y) { + return (bits[y * stride + (x >> 3)] >> (7 - (x & 7))) & 1; +} + +// Alpha from an AND mask: bit 1 = transparent (screen shows through), bit 0 = opaque. +inline void AlphaFromMask(uint32_t* px, int w, int h, const uint8_t* andBits, int stride) { + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) { + uint32_t& p = px[y * w + x]; + p = (p & 0x00FFFFFFu) | (MaskBit(andBits, stride, x, y) ? 0u : 0xFF000000u); + } +} + +// A monochrome pointer (AND + XOR masks) as a colour one: +// AND 0 XOR 0 -> black, AND 0 XOR 1 -> white, AND 1 XOR 0 -> transparent, +// AND 1 XOR 1 -> "invert the screen": drawn white, and every transparent pixel touching one +// (8-neighbourhood) becomes a black outline, so the beam reads on light and dark backgrounds. +// Inverting cannot be tinted (it is not a colour), so this is the price of tinting it. +inline void MonoToArgb(const uint8_t* andBits, const uint8_t* xorBits, int stride, int w, int h, uint32_t* out) { + auto inv = [&](int x, int y) { + return x >= 0 && y >= 0 && x < w && y < h && MaskBit(andBits, stride, x, y) && MaskBit(xorBits, stride, x, y); + }; + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) { + const bool a = MaskBit(andBits, stride, x, y), xo = MaskBit(xorBits, stride, x, y); + uint32_t p; + if (!a) p = xo ? 0xFFFFFFFFu : 0xFF000000u; + else if (xo) p = 0xFFFFFFFFu; + else { + bool edge = false; + for (int dy = -1; dy <= 1 && !edge; ++dy) + for (int dx = -1; dx <= 1 && !edge; ++dx) + if ((dx || dy) && inv(x + dx, y + dy)) edge = true; + p = edge ? 0xFF000000u : 0x00000000u; + } + out[y * w + x] = p; + } +} +} // namespace wind diff --git a/src/magnifier_model.h b/src/magnifier_model.h index b4be2ea..bbbc09d 100644 --- a/src/magnifier_model.h +++ b/src/magnifier_model.h @@ -2,6 +2,7 @@ #include "render_engine.h" // MonitorTarget #include "cursor_mapper.h" // MapResult #include "config.h" // Config +#include "color_matrix.h" // ColorMatrix (issue #288) namespace wind { // Per-tick render-only overrides RunTick computes (outline fade, inspect crosshair, click freeze, // cursor hide). The transform model ignores everything except drawCursor. @@ -32,6 +33,11 @@ struct PresentExtras { // draws above its panels) instead of the sprite, and prime one public-API write so DWM draws // that pointer magnified. Transform model only. bool realPointer = false; + // Colour filter for the render engine's pixel shader (issue #288). Its capture already + // contains the DWM colour effect, so RunTick clears that effect during a render session and + // hands the matrix here instead. Transform and 1x use the DWM effect. + bool colorOn = false; + ColorMatrix color{}; bool fsGame = false; // foreground covers the monitor -> skip the periodic topmost backstop bool forceCrop = false; // fsGame && gameCrop: crop the capture copy to the magnified region bool noVsync = false; // game pacing engaged: Present(0,0); the main loop's timer paces diff --git a/src/main.cpp b/src/main.cpp index e2e8fbc..62cf046 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -26,6 +26,9 @@ #pragma comment(lib, "Dwmapi.lib") #include "render_engine.h" #include "render_model.h" +#include "color_filter.h" +#include "hdr_info.h" // issue #288 +#include "cursor_tint.h" // tinted pointer at 1x (#288) #include "magnify_model.h" #include "transform_model.h" #include "input_router.h" @@ -793,11 +796,78 @@ static void EndPanelFreeze(TickState& t) { t.panelFreeze = false; } +// COLOUR (issue #288): warmth + brightness, always on when set. Transform sessions and 1x use +// the DWM colour effect; a render session clears it and filters in its pixel shader instead, because +// its capture already contains the effect (docs/COLOUR-FILTER-FINDINGS.md). The controller dedupes, so +// calling this every tick costs a compare when nothing changed, and holds a runtime only while a +// filter is on. +static wind::ColorFilterController g_color; +// Windows HDR on? Under HDR the DWM colour effect scales LINEAR scRGB (measured 2026-09-29), so the +// matrix must be built for linear light there. Read at startup and on WM_DISPLAYCHANGE (toggling HDR +// changes the display mode), never per tick: it is a DisplayConfig query. +static std::atomic g_hdrOn{false}; +// HDR state of the PRIMARY monitor. The DWM effect is one matrix for every monitor, so a mixed +// HDR/SDR setup gets the right strength on the primary only (documented limitation). +static bool PrimaryHdrOn() { + MONITORINFOEXW mi{}; mi.cbSize = sizeof(mi); + HMONITOR hm = MonitorFromPoint(POINT{ 0, 0 }, MONITOR_DEFAULTTOPRIMARY); + return wind::GetHdrEnabled(hm && GetMonitorInfoW(hm, &mi) ? mi.szDevice : nullptr); +} +// The render overlay is on screen (revealed, not yet rested). Colour is filtered in its shader only +// then; before the reveal and after it rests the DWM effect must carry it (review 2026-09-30). +static bool RenderOverlayShown(TickState& t) { + auto* rm = dynamic_cast(t.mRender ? t.mRender : t.model); + return rm && rm->visible(); +} +static void UpdateColorFilter(TickState& t, bool zoomedNow, bool renderSession, PresentExtras* ex) { + const bool magnify = t.model && t.model->selfDrivenZoom(); // native Magnifier has its own filters + (void)zoomedNow; // applies zoomed and at 1x alike (owner decision 2026-09-29) + const double w = t.cfg.colorWarmPct / 100.0, d = t.cfg.colorDimPct / 100.0; + // Toggling HDR is not guaranteed to raise WM_DISPLAYCHANGE (and may settle after it), so while a + // filter is on the state is re-read once a second. A DisplayConfig query is microseconds (the + // render engine runs the same kind of query at 4 Hz, CLAUDE.md), so this is not a tick cost. + if (!magnify && (w > 0.0 || d < 1.0)) { + static unsigned long long hdrReadMs = 0; + const unsigned long long now = GetTickCount64(); + if (now - hdrReadMs >= 1000) { hdrReadMs = now; g_hdrOn.store(PrimaryHdrOn()); } + } + // The render shader works on sRGB-encoded values (after its HDR->SDR step), in SDR and HDR alike. + const wind::ColorMatrix enc = magnify ? wind::IdentityColorMatrix() : wind::BuildColorMatrix(w, d, false); + const bool inShader = renderSession && !wind::IsIdentity(enc); + if (ex) { ex->colorOn = inShader; ex->color = enc; } + const wind::ColorMatrix dwm = (inShader || magnify) ? wind::IdentityColorMatrix() + : wind::BuildColorMatrix(w, d, g_hdrOn.load(std::memory_order_relaxed)); + g_color.apply(dwm, !wind::IsIdentity(dwm)); +} + +// Tinted pointer at 1x (spec 2026-09-30-cursor-tint-design.md): the hardware pointer is out of the +// DWM effect's reach, so while the colour is on and Wind is idle the standard pointers are swapped +// for tinted copies. Idle ticks only; zoom-in restores first, zoom-out invalidates (the engines +// reload the scheme), and a fullscreen app in front (not the desktop) keeps the pristine pointers. +static wind::CursorTint g_tint; +static void UpdateCursorTint(TickState& t) { + const bool magnify = t.model && t.model->selfDrivenZoom(); + const wind::ColorMatrix enc = magnify ? wind::IdentityColorMatrix() + : wind::BuildColorMatrix(t.cfg.colorWarmPct / 100.0, t.cfg.colorDimPct / 100.0, false); + if (wind::IsIdentity(enc)) { g_tint.restore(true); return; } + static unsigned long long checkedMs = 0; + static bool fsApp = false; + const unsigned long long now = GetTickCount64(); + if (now - checkedMs >= 250) { // a window-rect query, 4x a second at most + checkedMs = now; + const HWND fg = GetForegroundWindow(); + fsApp = ForegroundCoversMonitor(t.mon) && !IsShellDesktopFg(fg); + } + if (fsApp) g_tint.restore(true); + else g_tint.apply(enc); +} // The status block WindTray.exe reads (tray_ipc.h). Null if the mapping failed: every accessor // treats null as "no tray data", so the tick path needs no extra branch beyond the pointer test. static wind::TrayShared* g_trayBlock = nullptr; static void RunTick(TickState& t) { + // Idle (1x) colour filter; a zoomed tick re-decides below with the engine known. + if (!t.prevActive) UpdateColorFilter(t, false, false, nullptr); LARGE_INTEGER now; QueryPerformanceCounter(&now); double dt = double(now.QuadPart - t.prev.QuadPart) / double(t.freq.QuadPart); @@ -1155,6 +1225,10 @@ static void RunTick(TickState& t) { bool enterActive = !t.prevActive; // idle -> active (overlay just turned on) bool inspectEnter = inspect && !t.prevInspect; if (enterActive) { + // Pristine pointers back BEFORE any engine hides or captures the pointer: the transform + // sprite and the render engine draw the real shape and filter it themselves, so a + // tinted source would be tinted twice (#288). Direct swaps, no scheme reload. + g_tint.restore(false); t.outlineIdleSec = 0.0; // each activation starts with the outline fully shown // Follow the cursor's monitor (multiMonitor on, only when zoomed). Only reconfigure when // it actually changed; retarget() returns false on multi-GPU/failure, in which case we keep @@ -1788,6 +1862,7 @@ static void RunTick(TickState& t) { t.restAfterReveal->setActive(false); t.restAfterReveal = nullptr; t.restOverlapTicks = 0; + UpdateColorFilter(t, true, RenderOverlayShown(t), nullptr); // colour follows what is visible } IMagnifierModel* old = t.model; SetSystemCursorHidden(t, old, false); @@ -1915,6 +1990,7 @@ static void RunTick(TickState& t) { } ex.suppressTransformWrite = hookWrite; ex.realPointer = panel; + UpdateColorFilter(t, lvl > 1.0, RenderOverlayShown(t), &ex); // Serialize transform writes around an Inspect click's injected absolute move (issue #148 // TDR class): the injection and a transform write racing each other is the proven trigger. // The launch quiesce holds writes AND the weld for its whole window (see above). @@ -2080,6 +2156,9 @@ static void RunTick(TickState& t) { if (t.restAfterReveal && t.restOverlapTicks > 0 && --t.restOverlapTicks == 0) { t.restAfterReveal->setActive(false); t.restAfterReveal = nullptr; + // The render overlay just left the screen: the DWM effect takes the colour back THIS + // tick, not next tick's top-of-tick call (a one-frame unfiltered flash otherwise). + UpdateColorFilter(t, true, RenderOverlayShown(t), nullptr); } // Execute the deferred game-inspect steal now that the reveal logic has read the true // foreground, and RE-assert it if the game pulled foreground back mid-inspect (some @@ -2158,6 +2237,9 @@ static void RunTick(TickState& t) { } else if (t.prevActive) { // active -> idle: tear the overlay down EndPanelFreeze(t); // #283: never leave the pointer pinned (review #284) if (t.restAfterReveal) { t.restAfterReveal->setActive(false); t.restAfterReveal = nullptr; } + // DWM effect back BEFORE the overlay hides: worst case one double-filtered frame, never a + // bright unfiltered one (review 2026-09-30). + UpdateColorFilter(t, false, false, nullptr); t.model->setActive(false); SetSystemCursorHidden(t, t.model, false); t.outlineZoneSec = 0.0; // zoom-out clears the low-zoom dwell (no banked partial) @@ -2176,6 +2258,7 @@ static void RunTick(TickState& t) { t.lastSetVirtual = lp; } t.revealPending = 0; // a quick tap may zoom out before the deferred reveal + g_tint.invalidate(); // the engines put the scheme back: re-tint when idle } else { // Idle: let the transform model release its magnification context shortly after a zoom // ends. While a context is alive, DWM composites magnification-aware and every cursor @@ -2184,6 +2267,7 @@ static void RunTick(TickState& t) { // half get the tick (in model=transform the transform IS t.model); others no-op. t.model->idleTick(); if (t.mTransform && t.mTransform != t.model) t.mTransform->idleTick(); + UpdateCursorTint(t); } t.prevLvl = lvl; t.prevActive = active; @@ -2356,6 +2440,10 @@ static void RegisterQuickZoomHotkey(HWND hwnd, int vk, int mods) { } static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + if (msg == WM_DISPLAYCHANGE) g_hdrOn.store(PrimaryHdrOn()); // colour space (#288); falls through + // A pointer-scheme reload (the user changed scheme or size, or an engine healed it) leaves the + // clean scheme in place: take fresh pristine copies; the next idle tick re-tints (#288). + if (msg == WM_SETTINGCHANGE && wp == SPI_SETCURSORS) g_tint.capture(); if (msg == WM_HOTKEY && wp == kQuitHotkeyId) { PostQuitMessage(0); return 0; } if (msg == WM_HOTKEY && wp == kHideCursorHotkeyId) { if (g_tick) g_tick->cursorHidden = !g_tick->cursorHidden; @@ -2624,6 +2712,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { // Safety: global Ctrl+Alt+Q quits cleanly from anywhere (works even with the overlay up // and the cursor hidden). If the combo is already taken, the tray Quit still works. RegisterHotKey(hwnd, kQuitHotkeyId, MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, 'Q'); + g_hdrOn.store(PrimaryHdrOn()); // colour filter space (#288), refreshed on WM_DISPLAYCHANGE + 1 Hz while on RegisterHideCursorHotkey(hwnd, cfg.hideCursorVk, cfg.hideCursorMods); RegisterQuickZoomHotkey(hwnd, (cfg.quickZoomHotkeyMode && cfg.quickZoomVk) ? cfg.quickZoomVk : 0, cfg.quickZoomMods); @@ -2711,6 +2800,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { // above the cursor and the Snipping Tool overlay, an ordinary process's menu does not. g_trayBlock = wind::TrayHost::Start(exePath); + g_tint.capture(); // the scheme is clean here: RestoreInputState reloaded it at start-up (#288) TickState ts(model.get(), startupMon, cfg); ts.mRender = model.get(); ts.mTransform = model2.get(); @@ -3099,6 +3189,8 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { UnregisterHotKey(hwnd, kHideCursorHotkeyId); UnregisterHotKey(hwnd, kQuickZoomHotkeyId); EndGameInspect(ts); // quitting mid-game-inspect hands foreground back to the game + g_color.shutdown(); // colour filter back to identity while the runtime still lives (#288) + g_tint.restore(true); // the user's own pointers back (the exit scheme reload heals them too) model->shutdown(); // restores cursor + tears down D3D/overlay // Hybrid holds TWO models; quitting while zoomed in (or shortly after) a transform session // left the transform half's magnification context + cursor state untouched without this. diff --git a/src/render_engine.cpp b/src/render_engine.cpp index 023fdd4..7a36844 100644 --- a/src/render_engine.cpp +++ b/src/render_engine.cpp @@ -639,7 +639,7 @@ bool RenderEngine::State::buildDeviceResources() { if (FAILED(hr3) || FAILED(hr4)) { RLog("buildDeviceResources: cursor shader create failed hr3=0x%08lX hr4=0x%08lX", (unsigned long)hr3, (unsigned long)hr4); return false; } D3D11_BUFFER_DESC ccbd{}; - ccbd.ByteWidth = 16; // float2 posClip + float2 sizeClip + ccbd.ByteWidth = sizeof(CursorCB); // quad placement + the colour filter (issue #288) ccbd.Usage = D3D11_USAGE_DEFAULT; ccbd.BindFlags = D3D11_BIND_CONSTANT_BUFFER; if (FAILED(device->CreateBuffer(&ccbd, nullptr, ccb.ReleaseAndGetAddressOf()))) { RLog("buildDeviceResources: CreateBuffer(cursor cb) failed"); return false; } @@ -1113,7 +1113,9 @@ void RenderEngine::State::render(const RenderFrameParams& p) { (float)(p.srcLeft / sw), (float)(p.srcTop / sh), (float)((p.srcLeft + vw) / sw), (float)((p.srcTop + vh) / sh), bright, hdrMode, scRgbScale, sharp, - (sw > 0 ? 1.0f / (float)sw : 0.0f), (sh > 0 ? 1.0f / (float)sh : 0.0f), 0.0f, 0.0f }; + (sw > 0 ? 1.0f / (float)sw : 0.0f), (sh > 0 ? 1.0f / (float)sh : 0.0f), p.colorOn ? 1.0f : 0.0f, 0.0f, {} }; + for (int i = 0; i < 5; ++i) + for (int j = 0; j < 4; ++j) cbv.cm[i][j] = p.color.m[i][j]; c->UpdateSubresource(cb.Get(), 0, nullptr, &cbv, 0, 0); c->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); c->VSSetShader(vs.Get(), nullptr, 0); @@ -1150,7 +1152,14 @@ void RenderEngine::State::render(const RenderFrameParams& p) { c->PSSetShader(bps.Get(), nullptr, 0); c->PSSetConstantBuffers(0, 1, bcb.GetAddressOf()); // cb layout matches kBorderHLSL: rgba color, screen size (px), thickness (px), inset (px). - const float bcbv[8] = { p.outlineR, p.outlineG, p.outlineB, p.outlineAlpha, + float oR = p.outlineR, oG = p.outlineG, oB = p.outlineB; + if (p.colorOn) { // the outline is dimmed/warmed like everything else (issue #288) + double fr, fg, fb; + wind::ApplyToRgb(p.color, oR, oG, oB, fr, fg, fb); + auto sat = [](double v) { return (float)(v < 0 ? 0 : (v > 1 ? 1 : v)); }; + oR = sat(fr); oG = sat(fg); oB = sat(fb); + } + const float bcbv[8] = { oR, oG, oB, p.outlineAlpha, (float)sw, (float)sh, (float)t, (float)inset }; D3D11_MAPPED_SUBRESOURCE ms{}; if (SUCCEEDED(c->Map(bcb.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &ms))) { @@ -1179,13 +1188,21 @@ void RenderEngine::State::render(const RenderFrameParams& p) { float posClipY = (float)(1.0 - tlY / sh * 2.0); float sizeClipX = (float)(drawW / sw * 2.0); float sizeClipY = (float)(-(drawH / sh * 2.0)); // clip-y up vs screen-y down - float ccbv[4] = { posClipX, posClipY, sizeClipX, sizeClipY }; - c->UpdateSubresource(ccb.Get(), 0, nullptr, ccbv, 0, 0); + CursorCB ccbv{ posClipX, posClipY, sizeClipX, sizeClipY, 0.0f, 0.0f, 0.0f, 0.0f, {} }; + // An inverting cursor (the classic text beam) inverts what is behind it: a colour matrix + // on its texture would change the inversion, not tint it, so it is drawn as is. + if (p.colorOn && !(cursorInvert && !useCross)) { + ccbv.colorOn = 1.0f; + for (int i = 0; i < 5; ++i) + for (int j = 0; j < 4; ++j) ccbv.cm[i][j] = p.color.m[i][j]; + } + c->UpdateSubresource(ccb.Get(), 0, nullptr, &ccbv, 0, 0); c->OMSetBlendState(((cursorInvert && !useCross) ? blendInvert : blend).Get(), nullptr, 0xFFFFFFFF); c->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP); c->VSSetShader(cvs.Get(), nullptr, 0); c->VSSetConstantBuffers(0, 1, ccb.GetAddressOf()); c->PSSetShader(cps.Get(), nullptr, 0); + c->PSSetConstantBuffers(0, 1, ccb.GetAddressOf()); // PS reads the colour filter c->PSSetShaderResources(0, 1, useCross ? crosshairSRV.GetAddressOf() : cursorSRV.GetAddressOf()); c->PSSetSamplers(0, 1, sampLinear.GetAddressOf()); c->Draw(4, 0); diff --git a/src/render_engine.h b/src/render_engine.h index b7a95d3..8f16a6b 100644 --- a/src/render_engine.h +++ b/src/render_engine.h @@ -1,4 +1,5 @@ #pragma once +#include "color_matrix.h" // RenderFrameParams::color (issue #288) namespace wind { // A target monitor for the magnifier overlay. All values are in physical pixels in the @@ -23,6 +24,8 @@ struct RenderFrameParams { bool bilinear; // bilinear (smooth) vs point sampling double sharpness; // 0 = off; >0 = adaptive sharpen strength (crisps upscaled detail) double brightness; // output multiplier (1.0 = unchanged; <1 dims for HDR) + bool colorOn = false; // apply `color` in the pixel shader (issue #288) + wind::ColorMatrix color{}; int cursorMode; // 0=auto (draw only when the app shows a cursor), 1=always, 2=never bool vsync; // true = Present(1,0) vsync; false = Present(0,0) no vsync bool cropCapture; // on a full-screen repaint, copy only the magnified region (cuts 4K copy) diff --git a/src/render_model.cpp b/src/render_model.cpp index 24ada65..c453455 100644 --- a/src/render_model.cpp +++ b/src/render_model.cpp @@ -41,6 +41,7 @@ void RenderModel::shutdown() { engine_.shutdown(); } bool RenderModel::ready() const { return engine_.ready(); } void RenderModel::hideSystemCursor(bool hide) { engine_.hideSystemCursor(hide); } void RenderModel::setActive(bool active) { + visible_ = active; engine_.setVisible(active); // Zoom-out: also drop the Desktop Duplication session (issue #148). While a duplication is // alive, DWM keeps servicing it; idle at 1x should cost the system nothing. The next zoom-in @@ -73,6 +74,8 @@ void RenderModel::present(const MapResult& r, double level, const Config& cfg, p.cursorMode = ex.cursorMode; if (ex.clickOverride) { p.clickDesktopX = ex.clickDesktopX; p.clickDesktopY = ex.clickDesktopY; } p.suppressCursorSync = ex.suppressCursorSync; // mid-drag: the pointer owns the interaction (#169) + p.colorOn = ex.colorOn; // colour filter in the shader (#288) + p.color = ex.color; p.fsGame = ex.fsGame; // skip the periodic topmost backstop over a game if (ex.forceCrop) p.cropCapture = true; // game session: crop the copy to the magnified view if (ex.noVsync) p.vsync = false; // game pacing: timer paces, Present(0,0) diff --git a/src/render_model.h b/src/render_model.h index 584f4f0..9f15da1 100644 --- a/src/render_model.h +++ b/src/render_model.h @@ -41,5 +41,10 @@ class RenderModel : public IMagnifierModel { bool hdrTonemap_; int gpuPriority_; bool primed_ = false; + bool visible_ = false; // the overlay is revealed (setActive(true) .. setActive(false)) +public: + // Whether the overlay is on screen. The colour filter follows the VISIBLE engine, not the + // selected one: a pending reveal still shows the DWM-filtered desktop (issue #288 review). + bool visible() const { return visible_; } }; } diff --git a/src/render_shaders.h b/src/render_shaders.h index 7463829..d71d864 100644 --- a/src/render_shaders.h +++ b/src/render_shaders.h @@ -10,7 +10,8 @@ namespace wind { struct MagCB { float uvMinX, uvMinY, uvMaxX, uvMaxY; // reg 0 float brightness, hdrMode, scRgbScale, sharpness; // reg 1 - float texelW, texelH, pad0, pad1; // reg 2 + float texelW, texelH, colorOn, pad1; // reg 2 (colorOn: issue #288) + float cm[5][4]; // reg 3-7: colour matrix rows (RGBA in, RGBA out) + offsets }; // Fullscreen-triangle magnify shader. The VS maps the visible [0,1] screen UV into the @@ -20,7 +21,8 @@ inline constexpr const char* kMagHLSL = R"( cbuffer CB : register(b0) { float2 uvMin; float2 uvMax; float brightness; float hdrMode; float scRgbScale; float sharpness; - float texelW; float texelH; float2 pad; + float texelW; float texelH; float colorOn; float pad; + float4 cm0; float4 cm1; float4 cm2; float4 cm3; float4 cmOff; }; struct VSOut { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; VSOut VSMain(uint id : SV_VertexID) { @@ -56,6 +58,11 @@ float4 PSMain(VSOut i) : SV_TARGET { // then sRGB-encode. Reconstructs the SDR appearance the HDR desktop shows. c.rgb = LinearToSrgb(max(c.rgb, 0.0) * scRgbScale); } + if (colorOn > 0.5) { + // Colour filter (issue #288): the same row-vector matrix DWM would apply, done here because + // the captured desktop already went through DWM (see COLOUR-FILTER-FINDINGS.md). + c.rgb = saturate((c.r * cm0 + c.g * cm1 + c.b * cm2 + cm3 + cmOff).rgb); + } c.rgb *= brightness; // optional fine-tune (default 1.0) c.a = 1.0; // opaque output; window opacity is set via LWA_ALPHA return c; @@ -64,8 +71,13 @@ float4 PSMain(VSOut i) : SV_TARGET { // Cursor quad shader: a per-quad transform (top-left + size in clip space) places an // alpha-blended textured quad. Drawn as a 4-vertex triangle strip from the vertex id. +// colorOn + cm*: the colour filter (issue #288). The DWM effect is cleared in a render session, so +// the drawn pointer and the Inspect crosshair must be filtered here like the desktop pass, or they +// stay full-bright over a dimmed picture (review 2026-09-30). Straight alpha: RGB only. +struct CursorCB { float posClipX, posClipY, sizeClipX, sizeClipY; float colorOn, pad0, pad1, pad2; float cm[5][4]; }; inline constexpr const char* kCursorHLSL = R"( -cbuffer CB : register(b0) { float2 posClip; float2 sizeClip; }; +cbuffer CB : register(b0) { float2 posClip; float2 sizeClip; float colorOn; float3 cpad; + float4 cm0; float4 cm1; float4 cm2; float4 cm3; float4 cmOff; }; struct VSOut { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; VSOut VSMain(uint id : SV_VertexID) { float2 q = float2(id & 1, (id >> 1) & 1); // (0,0),(1,0),(0,1),(1,1) @@ -76,7 +88,11 @@ VSOut VSMain(uint id : SV_VertexID) { } Texture2D tex : register(t0); SamplerState smp : register(s0); -float4 PSMain(VSOut i) : SV_TARGET { return tex.Sample(smp, i.uv); } +float4 PSMain(VSOut i) : SV_TARGET { + float4 c = tex.Sample(smp, i.uv); + if (colorOn > 0.5) c.rgb = saturate((c.r * cm0 + c.g * cm1 + c.b * cm2 + cm3 + cmOff).rgb); + return c; +} )"; // Zoom edge outline as a SINGLE full-screen quad: the pixel shader colors only pixels within diff --git a/src/version.h b/src/version.h index 945a7d3..6463fba 100644 --- a/src/version.h +++ b/src/version.h @@ -3,8 +3,8 @@ #pragma once #define WIND_VER_MAJOR 0 -#define WIND_VER_MINOR 13 -#define WIND_VER_PATCH 3 +#define WIND_VER_MINOR 14 +#define WIND_VER_PATCH 0 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.13.3" +#define WIND_VERSION_STR "0.14.0" diff --git a/tests/test_color_matrix.cpp b/tests/test_color_matrix.cpp new file mode 100644 index 0000000..5fdffb9 --- /dev/null +++ b/tests/test_color_matrix.cpp @@ -0,0 +1,64 @@ +#include "doctest.h" +#include "../src/color_matrix.h" +using namespace wind; + +static void Rgb(const ColorMatrix& m, double r, double g, double b, double& o0, double& o1, double& o2) { + ApplyToRgb(m, r, g, b, o0, o1, o2); +} + +TEST_CASE("no warmth at full brightness is the identity in both spaces") { + CHECK(IsIdentity(BuildColorMatrix(0.0, 1.0, false))); + CHECK(IsIdentity(BuildColorMatrix(0.0, 1.0, true))); + CHECK_FALSE(IsIdentity(BuildColorMatrix(0.0, 0.9, false))); + CHECK_FALSE(IsIdentity(BuildColorMatrix(0.05, 1.0, true))); +} +TEST_CASE("warmth uses Night light's scale: 6500 K to 1200 K, linear in Kelvin") { + CHECK(WarmKelvin(0.0) == doctest::Approx(6500)); + CHECK(WarmKelvin(0.5) == doctest::Approx(3850)); // measured from Night light at 50% + CHECK(WarmKelvin(1.0) == doctest::Approx(1200)); +} +TEST_CASE("blackbody gains: white at 6500 K, blue gone first, CIE values at 1200 K") { + double r, g, b; + KelvinGainsLinear(6500, r, g, b); + CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(1.0)); CHECK(b == doctest::Approx(1.0)); + KelvinGainsLinear(1200, r, g, b); + CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(0.0774).epsilon(0.01)); CHECK(b == doctest::Approx(0.0)); + KelvinGainsLinear(2700, r, g, b); + CHECK(g == doctest::Approx(0.448).epsilon(0.02)); CHECK(b == doctest::Approx(0.10).epsilon(0.05)); + KelvinGainsLinear(99999, r, g, b); CHECK(b == doctest::Approx(1.0)); // clamps to the table + KelvinGainsLinear(0, r, g, b); CHECK(b == doctest::Approx(0.0)); +} +TEST_CASE("HDR (linear) and SDR (encoded) warmth are the same colour, not the same number") { + double lr, lg, lb, er, eg, eb; + ApplyToRgb(BuildColorMatrix(1.0, 1.0, true), 1, 1, 1, lr, lg, lb); + ApplyToRgb(BuildColorMatrix(1.0, 1.0, false), 1, 1, 1, er, eg, eb); + CHECK(lg == doctest::Approx(0.0774).epsilon(0.01)); // HDR: the linear gain itself + CHECK(eg == doctest::Approx(SrgbEncode(lg)).epsilon(0.001)); // SDR: its sRGB encoding + CHECK(lb == doctest::Approx(0.0)); CHECK(eb == doctest::Approx(0.0)); + // Monotonic: more warmth is never less orange. + double pg = 2; + for (int i = 0; i <= 20; ++i) { + double r, g, b; + ApplyToRgb(BuildColorMatrix(i / 20.0, 1.0, true), 1, 1, 1, r, g, b); + CHECK(r == doctest::Approx(1.0)); CHECK(g <= pg + 1e-9); CHECK(b <= g + 1e-9); + pg = g; + } +} +TEST_CASE("brightness: encoded in SDR, decoded to linear in HDR, floor 1% (never black)") { + double r, g, b; + ApplyToRgb(BuildColorMatrix(0.0, 0.5, false), 1, 0.5, 0, r, g, b); + CHECK(r == doctest::Approx(0.5)); CHECK(g == doctest::Approx(0.25)); + ApplyToRgb(BuildColorMatrix(0.0, 0.5, true), 1, 1, 1, r, g, b); + CHECK(r == doctest::Approx(SrgbDecode(0.5))); + ApplyToRgb(BuildColorMatrix(0.0, 0.0, true), 1, 1, 1, r, g, b); + CHECK(r == doctest::Approx(SrgbDecode(0.01))); // HDR: 1% decoded, dim but not black + CHECK(r > 0.0); + ApplyToRgb(BuildColorMatrix(0.0, -1.0, false), 1, 1, 1, r, g, b); + CHECK(r == doctest::Approx(0.01)); // SDR: 1% + CHECK(IsIdentity(BuildColorMatrix(-1.0, 2.0, true))); +} +TEST_CASE("sRGB encode and decode round-trip") { + const double vals[] = { 0.0, 0.002, 0.04, 0.2, 0.5, 1.0 }; + for (double v : vals) + CHECK(SrgbDecode(SrgbEncode(v)) == doctest::Approx(v).epsilon(1e-6)); +} diff --git a/tests/test_config.cpp b/tests/test_config.cpp index f7598f2..f9b28d3 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -503,6 +503,19 @@ TEST_CASE("tracking settings: defaults and parsing (issue #276)") { CHECK(c.trackLog == 1); } +TEST_CASE("colour keys: defaults, parse and clamps (#288)") { + Config d = ParseConfig(""); + CHECK(d.colorWarmPct == 0); CHECK(d.colorDimPct == 100); + Config c = ParseConfig("colorWarmPct=80\ncolorDimPct=60\n"); + CHECK(c.colorWarmPct == 80); CHECK(c.colorDimPct == 60); + Config x = ParseConfig("colorWarmPct=-4\ncolorDimPct=-5\n"); + CHECK(x.colorWarmPct == 0); CHECK(x.colorDimPct == 1); + CHECK(ParseConfig("colorWarmPct=150\n").colorWarmPct == 100); + // Keys of the dropped controls are ignored, not errors. + Config old = ParseConfig("colorFilter=4\ncolorAt1x=0\ncolorToggleVk=67\n"); + CHECK(old.colorWarmPct == 0); CHECK(old.colorDimPct == 100); +} + TEST_CASE("unsafe binds in an ini read as unbound; safe ones survive (#285)") { Config c = ParseConfig("zoomInVk=65\nzoomOutVk=34\nzoomInVk2=50\nzoomInMods2=3\nzoomOutVk2=115\nzoomOutMods2=2\n" "hideCursorVk=112\nhideCursorMods=1\nrecenterVk=82\ncursorLockVk=113\n"); diff --git a/tests/test_cursor_tint.cpp b/tests/test_cursor_tint.cpp new file mode 100644 index 0000000..9b63ed7 --- /dev/null +++ b/tests/test_cursor_tint.cpp @@ -0,0 +1,53 @@ +#include "doctest.h" +#include "../src/cursor_tint_pixels.h" +#include +using namespace wind; + +TEST_CASE("tint scales RGB by the matrix and keeps alpha") { + const ColorMatrix m = BuildColorMatrix(1.0, 1.0, false); // warmth 100%: 1200 K, encoded + const uint32_t white = 0x80FFFFFFu; // half-transparent white + const uint32_t t = TintPixel(white, m); + CHECK((t >> 24) == 0x80u); // alpha untouched + CHECK(((t >> 16) & 0xFF) == 255u); // red kept + CHECK(((t >> 8) & 0xFF) == (uint32_t)(SrgbEncode(0.0774) * 255 + 0.5)); // green per the table + CHECK((t & 0xFF) == 0u); // no blue at 1200 K +} +TEST_CASE("the identity leaves a pointer untouched, and dimming scales it") { + std::vector px = { 0xFF102030u, 0x00FFFFFFu, 0xFFFFFFFFu }; + std::vector orig = px; + TintArgb(px.data(), (int)px.size(), IdentityColorMatrix()); + CHECK(px == orig); + TintArgb(px.data(), (int)px.size(), BuildColorMatrix(0.0, 0.5, false)); + CHECK(px[2] == 0xFF808080u); // white at 50% brightness (128 = round(127.5)) +} +TEST_CASE("alpha comes from the AND mask when the colour bitmap has none") { + uint32_t px[2] = { 0x00FF0000u, 0x0000FF00u }; + CHECK_FALSE(AnyAlpha(px, 2)); + const uint8_t andBits[4] = { 0x40, 0, 0, 0 }; // x=0 opaque, x=1 transparent + AlphaFromMask(px, 2, 1, andBits, 4); + CHECK(px[0] == 0xFFFF0000u); + CHECK(px[1] == 0x0000FF00u); + CHECK(AnyAlpha(px, 2)); +} +TEST_CASE("a monochrome pointer becomes colour: black, white, transparent, inverting-with-outline") { + // 4x1 row: [AND0 XOR0][AND0 XOR1][AND1 XOR0][AND1 XOR1] + const uint8_t andBits[4] = { 0x30, 0, 0, 0 }; // bits: 0 0 1 1 + const uint8_t xorBits[4] = { 0x50, 0, 0, 0 }; // bits: 0 1 0 1 + uint32_t out[4]; + MonoToArgb(andBits, xorBits, 4, 4, 1, out); + CHECK(out[0] == 0xFF000000u); // black + CHECK(out[1] == 0xFFFFFFFFu); // white + CHECK(out[2] == 0xFF000000u); // transparent, but next to the inverting pixel: outline + CHECK(out[3] == 0xFFFFFFFFu); // inverting pixel drawn white +} +TEST_CASE("the outline only hugs inverting pixels; plain transparency stays clear") { + // 5x1: [invert][transparent][transparent][transparent][transparent] + const uint8_t andBits[4] = { 0xF8, 0, 0, 0 }; // all 1 + const uint8_t xorBits[4] = { 0x80, 0, 0, 0 }; // only x=0 + uint32_t out[5]; + MonoToArgb(andBits, xorBits, 4, 5, 1, out); + CHECK(out[0] == 0xFFFFFFFFu); + CHECK(out[1] == 0xFF000000u); // neighbour: outline + CHECK(out[2] == 0x00000000u); // two away: clear + CHECK(out[4] == 0x00000000u); +} diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index ad8f8c0..2d90022 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -76,6 +76,11 @@ export const sections = [ { key:'mouseAlign', type:'select', label:'Keep the mouse pointer', desc:'Within the edges: the pointer moves freely and the view only moves when it nears the edge.', options:['0','1'], optionLabels:{ '0':'Centred', '1':'Within the edges' }, def:'0' }, { key:'mouseMarginPct', type:'slider', label:'Mouse edge margin', desc:'With the pointer kept within the edges: how close it gets to the edge of the view before the view moves.', min:0, max:30, step:1, def:0, unit:'%' }, ]}, + // Colour (issue #288): warmth and brightness, one DWM colour matrix (the render engine applies it in its shader). + { id:'colour', label:'Colour', icon:'display', desc:'Warmth and brightness for the whole screen, zoomed or not.', rows: [ + { key:'colorWarmPct', type:'slider', label:'Warmth', desc:'Makes the screen more orange, like Night light. 0% is off.', min:0, max:100, step:5, def:0, unit:'%' }, + { key:'colorDimPct', type:'slider', label:'Brightness', desc:'Darkens the picture, like turning down a TV. 100% is normal.', min:1, max:100, step:1, def:100, unit:'%' }, + ]}, { id:'display', label:'Display', icon:'display', desc:'The engine behind the magnified view.', rows: [ { key:'model', type:'select', label:'Magnifier engine', desc:'Auto picks the best engine for the app in front. Restart to switch.', diff --git a/ui/tests/settings.spec.js b/ui/tests/settings.spec.js index 71788a0..90c763c 100644 --- a/ui/tests/settings.spec.js +++ b/ui/tests/settings.spec.js @@ -536,6 +536,15 @@ test('Mouse edge margin: a slider in the Tracking section, 0% by default (issue await expect(row).toContainText('0'); }); +test('Colour section: only the warmth and brightness sliders, neutral by default (issue #288)', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Warmth', { exact: true }).locator('xpath=../..')).toContainText('0'); + await expect(page.getByText('Brightness', { exact: true }).locator('xpath=../..')).toContainText('100'); + for (const gone of ['Colour filter', 'Also when not zoomed', 'Toggle colour filter']) { + await expect(page.getByText(gone, { exact: true })).toHaveCount(0); + } +}); + // --- Safe keybinds, click and wheel binds (issue #285) ------------------------------------- const zoomInCap = page => page.getByText('Zoom in', { exact: true }).locator('xpath=../..').getByRole('button').first(); const lastSet = (page, key) => page.evaluate(k => (window.__sets.filter(s => s.key === k).at(-1) || {}).value, key);