From 3c7d94516af643591eb993355e4cffb64a94cb53 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:49:02 +0200 Subject: [PATCH 01/19] docs(color): colour filters spec + plan (#288) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- .../plans/2026-09-29-colour-filters.md | 92 +++++++++++++++ .../specs/2026-09-29-colour-filters-design.md | 111 ++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-29-colour-filters.md create mode 100644 docs/superpowers/specs/2026-09-29-colour-filters-design.md 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/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..594da72 --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-colour-filters-design.md @@ -0,0 +1,111 @@ +# 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. From 0f8f7f606e81dc37778d7dce0ce295911b540ed0 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:54:59 +0200 Subject: [PATCH 02/19] feat(color): pure colour matrices + spike findings (#288) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- docs/COLOUR-FILTER-FINDINGS.md | 21 ++++++++ src/color_matrix.h | 98 ++++++++++++++++++++++++++++++++++ tests/test_color_matrix.cpp | 58 ++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 docs/COLOUR-FILTER-FINDINGS.md create mode 100644 src/color_matrix.h create mode 100644 tests/test_color_matrix.cpp diff --git a/docs/COLOUR-FILTER-FINDINGS.md b/docs/COLOUR-FILTER-FINDINGS.md new file mode 100644 index 0000000..5d05a95 --- /dev/null +++ b/docs/COLOUR-FILTER-FINDINGS.md @@ -0,0 +1,21 @@ +# 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). diff --git a/src/color_matrix.h b/src/color_matrix.h new file mode 100644 index 0000000..eda2e8e --- /dev/null +++ b/src/color_matrix.h @@ -0,0 +1,98 @@ +#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). +namespace wind { + +enum class ColorFilter { Off = 0, Invert, Greyscale, Warm, YellowOnBlack, WhiteOnBlue, GreenOnBlack }; + +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; +} + +// warm01: Warm strength 0..1. dim01: brightness 0.2..1 (1 = no dim). Out-of-range values clamp. +inline ColorMatrix BuildColorMatrix(ColorFilter f, double warm01, double dim01) { + 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, 0.2, 1.0); + const double w[3] = { 0.2126, 0.7152, 0.0722 }; // Rec.709 luma + ColorMatrix m = IdentityColorMatrix(); + auto twoColour = [&](const double fg[3], const double bg[3]) { + // Inverted luma drives the blend, so dark text on a light page becomes fg on bg: + // out = bg + (1 - L) (fg - bg) = fg - L (fg - bg). + ColorMatrix t = IdentityColorMatrix(); + for (int j = 0; j < 3; ++j) { + for (int i = 0; i < 3; ++i) t.m[i][j] = (float)(-w[i] * (fg[j] - bg[j])); + t.m[4][j] = (float)fg[j]; + } + return t; + }; + switch (f) { + case ColorFilter::Invert: + for (int i = 0; i < 3; ++i) { m.m[i][i] = -1.0f; m.m[4][i] = 1.0f; } + break; + case ColorFilter::Greyscale: + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) m.m[i][j] = (float)w[i]; + break; + case ColorFilter::Warm: + m.m[1][1] = (float)(1.0 - 0.25 * warm01); + m.m[2][2] = (float)(1.0 - 0.6 * warm01); + break; + case ColorFilter::YellowOnBlack: { const double fg[3] = { 1, 1, 0 }, bg[3] = { 0, 0, 0 }; m = twoColour(fg, bg); break; } + case ColorFilter::WhiteOnBlue: { const double fg[3] = { 1, 1, 1 }, bg[3] = { 0, 0, 0.5 }; m = twoColour(fg, bg); break; } + case ColorFilter::GreenOnBlack: { const double fg[3] = { 0, 1, 0 }, bg[3] = { 0, 0, 0 }; m = twoColour(fg, bg); break; } + default: break; // Off, or an unknown value from a hand-edited ini + } + if (dim01 < 1.0) { + ColorMatrix d = IdentityColorMatrix(); + for (int i = 0; i < 3; ++i) d.m[i][i] = (float)dim01; + m = Multiply(m, d); // dim last: out = (in x filter) x dim + } + 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/tests/test_color_matrix.cpp b/tests/test_color_matrix.cpp new file mode 100644 index 0000000..4476c5e --- /dev/null +++ b/tests/test_color_matrix.cpp @@ -0,0 +1,58 @@ +#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("off with full brightness is the identity, and is detected as such") { + const ColorMatrix m = BuildColorMatrix(ColorFilter::Off, 0.5, 1.0); + CHECK(IsIdentity(m)); + CHECK_FALSE(IsIdentity(BuildColorMatrix(ColorFilter::Off, 0.5, 0.9))); + CHECK_FALSE(IsIdentity(BuildColorMatrix(ColorFilter::Invert, 0.5, 1.0))); +} +TEST_CASE("invert swaps black and white and mirrors a colour") { + const ColorMatrix m = BuildColorMatrix(ColorFilter::Invert, 0.5, 1.0); + double r, g, b; + Rgb(m, 1, 1, 1, r, g, b); CHECK(r == doctest::Approx(0)); CHECK(g == doctest::Approx(0)); CHECK(b == doctest::Approx(0)); + Rgb(m, 0, 0, 0, r, g, b); CHECK(r == doctest::Approx(1)); CHECK(b == doctest::Approx(1)); + Rgb(m, 1, 0, 0.25, r, g, b); CHECK(r == doctest::Approx(0)); CHECK(g == doctest::Approx(1)); CHECK(b == doctest::Approx(0.75)); +} +TEST_CASE("greyscale uses Rec.709 luma on every channel") { + const ColorMatrix m = BuildColorMatrix(ColorFilter::Greyscale, 0.5, 1.0); + double r, g, b; + Rgb(m, 1, 0, 0, r, g, b); CHECK(r == doctest::Approx(0.2126)); CHECK(g == doctest::Approx(0.2126)); CHECK(b == doctest::Approx(0.2126)); + Rgb(m, 1, 1, 1, r, g, b); CHECK(r == doctest::Approx(1.0)); CHECK(b == doctest::Approx(1.0)); +} +TEST_CASE("warm keeps red and cuts blue harder than green, scaled by strength") { + double r, g, b; + Rgb(BuildColorMatrix(ColorFilter::Warm, 1.0, 1.0), 1, 1, 1, r, g, b); + CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(0.75)); CHECK(b == doctest::Approx(0.4)); + Rgb(BuildColorMatrix(ColorFilter::Warm, 0.0, 1.0), 1, 1, 1, r, g, b); + CHECK(g == doctest::Approx(1.0)); CHECK(b == doctest::Approx(1.0)); +} +TEST_CASE("two-colour tints: a white page becomes the background, black text the foreground") { + double r, g, b; + const ColorMatrix y = BuildColorMatrix(ColorFilter::YellowOnBlack, 0.5, 1.0); + Rgb(y, 1, 1, 1, r, g, b); CHECK(r == doctest::Approx(0)); CHECK(g == doctest::Approx(0)); CHECK(b == doctest::Approx(0)); + Rgb(y, 0, 0, 0, r, g, b); CHECK(r == doctest::Approx(1)); CHECK(g == doctest::Approx(1)); CHECK(b == doctest::Approx(0)); + const ColorMatrix w = BuildColorMatrix(ColorFilter::WhiteOnBlue, 0.5, 1.0); + Rgb(w, 1, 1, 1, r, g, b); CHECK(b == doctest::Approx(0.5)); CHECK(r == doctest::Approx(0)); + Rgb(w, 0, 0, 0, r, g, b); CHECK(r == doctest::Approx(1)); CHECK(b == doctest::Approx(1)); + const ColorMatrix gr = BuildColorMatrix(ColorFilter::GreenOnBlack, 0.5, 1.0); + Rgb(gr, 0, 0, 0, r, g, b); CHECK(g == doctest::Approx(1)); CHECK(r == doctest::Approx(0)); +} +TEST_CASE("dim scales the result of any filter") { + double r, g, b; + Rgb(BuildColorMatrix(ColorFilter::Off, 0.5, 0.5), 1, 0.5, 0, r, g, b); + CHECK(r == doctest::Approx(0.5)); CHECK(g == doctest::Approx(0.25)); CHECK(b == doctest::Approx(0)); + Rgb(BuildColorMatrix(ColorFilter::Invert, 0.5, 0.5), 0, 0, 0, r, g, b); + CHECK(r == doctest::Approx(0.5)); // black -> white -> half +} +TEST_CASE("out-of-range inputs are clamped") { + double r, g, b; + Rgb(BuildColorMatrix(ColorFilter::Off, 0.5, 0.0), 1, 1, 1, r, g, b); + CHECK(r == doctest::Approx(0.2)); // dim floor 20% + CHECK(IsIdentity(BuildColorMatrix(static_cast(99), 0.5, 1.0))); // unknown -> off +} From 6a540edd1aa7dd9673ca98d9bf9278099475d0ed Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:55:42 +0200 Subject: [PATCH 03/19] feat(config): colour filter keys (#288) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/config.cpp | 18 ++++++++++++++++++ src/config.h | 9 ++++++++- tests/test_config.cpp | 12 ++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/config.cpp b/src/config.cpp index 4af673b..502ecdd 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -169,6 +169,12 @@ 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 == "colorFilter") c.colorFilter = std::stoi(val); + else if (key == "colorWarmPct") c.colorWarmPct = std::stoi(val); + else if (key == "colorDimPct") c.colorDimPct = std::stoi(val); + else if (key == "colorAt1x") c.colorAt1x = std::stoi(val); + else if (key == "colorToggleVk") c.colorToggleVk = std::stoi(val); + else if (key == "colorToggleMods") c.colorToggleMods = 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); @@ -250,6 +256,9 @@ 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.colorFilter = (int)clampd(c.colorFilter, 0, 6); + c.colorWarmPct = (int)clampd(c.colorWarmPct, 10, 100); + c.colorDimPct = (int)clampd(c.colorDimPct, 20, 100); c.quickZoomDefault = clampd(c.quickZoomDefault, 1.0, 50.0); if (c.outlineThickness < 1) c.outlineThickness = 1; if (c.outlineThickness > 40) c.outlineThickness = 40; @@ -313,6 +322,7 @@ Config ParseConfig(const std::string& text) { sanitizeVk(c.recenterVk); sanitizeVk(c.cursorLockVk); sanitizeVk(c.hideCursorVk); + sanitizeVk(c.colorToggleVk); sanitizeVk(c.quickZoomVk); return c; } @@ -440,6 +450,14 @@ std::string DefaultIniText() { "mouseMarginPct=0\n" "; brightness: magnified-view output multiplier (1.0=unchanged; fine-tune for HDR)\n" "brightness=1.0\n" + "; colorFilter: 0 off, 1 invert, 2 greyscale, 3 warm, 4 yellow on black, 5 white on blue, 6 green on black\n" + "colorFilter=0\n" + "; colorWarmPct: Warm strength (10-100); colorDimPct: artificial brightness (20-100, 100 = normal)\n" + "colorWarmPct=50\n" + "colorDimPct=100\n" + "; colorAt1x: 1 = filter also when not zoomed; colorToggleVk/Mods: optional on/off hotkey\n" + "colorAt1x=1\n" + "colorToggleVk=0\ncolorToggleMods=0\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 b341794..f1bff5e 100644 --- a/src/config.h +++ b/src/config.h @@ -357,7 +357,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 @@ -495,6 +495,13 @@ 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). + int colorFilter = 0; // 0 off, 1 invert, 2 greyscale, 3 warm, 4 yellow on black, 5 white on blue, 6 green on black + int colorWarmPct = 50; // Warm strength, 10..100 + int colorDimPct = 100; // artificial brightness, 20..100 (100 = no dim); composes with any filter + int colorAt1x = 1; // also apply when not zoomed (holds a magnification context while on) + int colorToggleVk = 0; // optional hotkey that flips the filter on/off (0 = none) + int colorToggleMods = 0; // 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/tests/test_config.cpp b/tests/test_config.cpp index daf8820..5d097c1 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -502,3 +502,15 @@ TEST_CASE("tracking settings: defaults and parsing (issue #276)") { CHECK(c.mouseAlign == 1); CHECK(c.trackGlideMs == 90); CHECK(c.trackMarginPct == 20); CHECK(c.trackLog == 1); } + +TEST_CASE("colour filter keys: defaults, parse and clamps (#288)") { + Config d = ParseConfig(""); + CHECK(d.colorFilter == 0); CHECK(d.colorWarmPct == 50); CHECK(d.colorDimPct == 100); CHECK(d.colorAt1x == 1); + CHECK(d.colorToggleVk == 0); + Config c = ParseConfig("colorFilter=1\ncolorWarmPct=80\ncolorDimPct=60\ncolorAt1x=0\ncolorToggleVk=67\ncolorToggleMods=3\n"); + CHECK(c.colorFilter == 1); CHECK(c.colorWarmPct == 80); CHECK(c.colorDimPct == 60); CHECK(c.colorAt1x == 0); + CHECK(c.colorToggleVk == 67); CHECK(c.colorToggleMods == 3); + Config x = ParseConfig("colorFilter=42\ncolorWarmPct=0\ncolorDimPct=5\ncolorToggleVk=1\n"); + CHECK(x.colorFilter == 6); CHECK(x.colorWarmPct == 10); CHECK(x.colorDimPct == 20); + CHECK(x.colorToggleVk == 0); // left click is a forbidden bind +} From 7d11c4eafd3c5c503a5e33badc2c95a1b3600144 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:59:07 +0200 Subject: [PATCH 04/19] feat(color): colour filter controller, RunTick wiring, render shader path, toggle hotkey (#288) Transform and 1x use the DWM colour effect; a render session clears it and applies the same matrix in its pixel shader (its capture already contains the effect). The controller dedupes and holds a runtime only while a filter is on. Optional toggle hotkey (RegisterHotKey, hot-reloaded). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/color_filter.cpp | 52 +++++++++++++++++++++++++++++++++++++++++++ src/color_filter.h | 22 ++++++++++++++++++ src/magnifier_model.h | 6 +++++ src/main.cpp | 50 ++++++++++++++++++++++++++++++++++++++++- src/render_engine.cpp | 4 +++- src/render_engine.h | 3 +++ src/render_model.cpp | 2 ++ src/render_shaders.h | 11 +++++++-- 8 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 src/color_filter.cpp create mode 100644 src/color_filter.h 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/magnifier_model.h b/src/magnifier_model.h index 2a4010d..1aa89c2 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 2f1243b..b65e599 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -26,6 +26,7 @@ #pragma comment(lib, "Dwmapi.lib") #include "render_engine.h" #include "render_model.h" +#include "color_filter.h" // issue #288 #include "magnify_model.h" #include "transform_model.h" #include "input_router.h" @@ -740,6 +741,7 @@ static void DiagLog(const char* fmt, ...) { // Forward-declared so RunTick can re-register the hide-cursor hotkey on config hot-reload; // the definition (with the static state it manages) lives near WndProc / kHideCursorHotkeyId. static void RegisterHideCursorHotkey(HWND hwnd, int vk, int mods); +static void RegisterColorToggleHotkey(HWND hwnd, int vk, int mods); // issue #288 // Same pattern for the quick-zoom hotkey (hotkey mode). Pass vk=0 to unregister. static void RegisterQuickZoomHotkey(HWND hwnd, int vk, int mods); @@ -791,7 +793,28 @@ static void EndPanelFreeze(TickState& t) { t.panelFreeze = false; } +// COLOUR FILTER (issue #288). One matrix from the config and the toggle. 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; +static std::atomic g_colorToggleOn{true}; // the optional hotkey flips it; starts on each launch +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 + wind::ColorMatrix m = wind::IdentityColorMatrix(); + if (!magnify && g_colorToggleOn.load() && (zoomedNow || t.cfg.colorAt1x != 0)) + m = wind::BuildColorMatrix(static_cast(t.cfg.colorFilter), + t.cfg.colorWarmPct / 100.0, t.cfg.colorDimPct / 100.0); + const bool inShader = renderSession && !wind::IsIdentity(m); + if (ex) { ex->colorOn = inShader; ex->color = m; } + const wind::ColorMatrix dwm = inShader ? wind::IdentityColorMatrix() : m; + g_color.apply(dwm, !wind::IsIdentity(dwm)); +} + 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); @@ -867,7 +890,9 @@ static void RunTick(TickState& t) { g_input.setKeys(nc.zoomInVk, nc.zoomInVk2, nc.zoomOutVk, nc.zoomOutVk2, nc.recenterVk, nc.cursorLockVk); } - if (nc.hideCursorVk != t.cfg.hideCursorVk || nc.hideCursorMods != t.cfg.hideCursorMods) { + if (nc.colorToggleVk != t.cfg.colorToggleVk || nc.colorToggleMods != t.cfg.colorToggleMods) + RegisterColorToggleHotkey(t.hwnd, nc.colorToggleVk, nc.colorToggleMods); + if (nc.hideCursorVk != t.cfg.hideCursorVk || nc.hideCursorMods != t.cfg.hideCursorMods) { RegisterHideCursorHotkey(t.hwnd, nc.hideCursorVk, nc.hideCursorMods); } if (nc.quickZoomHotkeyMode != t.cfg.quickZoomHotkeyMode @@ -1877,6 +1902,7 @@ static void RunTick(TickState& t) { } ex.suppressTransformWrite = hookWrite; ex.realPointer = panel; + UpdateColorFilter(t, lvl > 1.0, dynamic_cast(t.model) != nullptr, &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). @@ -2275,6 +2301,7 @@ static void RunTick(TickState& t) { // keyboard-only escape. The clean exit path restores the cursor and resets zoom to 1x. static const int kQuitHotkeyId = 0xB001; static const int kHideCursorHotkeyId = 0xB002; +static const int kColorToggleHotkeyId = 0xB005; // issue #288 static const int kQuickZoomHotkeyId = 0xB003; // Translate our bit mask (1=Ctrl, 2=Alt, 4=Shift, 8=Win) into Win32 MOD_* flags for RegisterHotKey. @@ -2302,6 +2329,19 @@ static void RegisterHideCursorHotkey(HWND hwnd, int vk, int mods) { } } +// Hot-reloadable registration of the colour-filter toggle hotkey (issue #288). vk=0 unregisters. +static int g_registeredColorVk = 0; +static int g_registeredColorMods = 0; +static void RegisterColorToggleHotkey(HWND hwnd, int vk, int mods) { + if (g_registeredColorVk != 0) { + UnregisterHotKey(hwnd, kColorToggleHotkeyId); + g_registeredColorVk = 0; g_registeredColorMods = 0; + } + if (vk != 0 && RegisterHotKey(hwnd, kColorToggleHotkeyId, WinModsFromBitmask(mods), vk)) { + g_registeredColorVk = vk; g_registeredColorMods = mods; + } +} + // Hot-reloadable registration of the quick-zoom hotkey (hotkey mode). Callers pass vk=0 to // unregister (modifier mode, or hotkey cleared), releasing the global key grab. static int g_registeredQuickVk = 0; @@ -2322,6 +2362,11 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { if (g_tick) g_tick->cursorHidden = !g_tick->cursorHidden; return 0; } + if (msg == WM_HOTKEY && wp == kColorToggleHotkeyId) { + g_colorToggleOn.store(!g_colorToggleOn.load()); // the next tick applies it + wind::Log(wind::LogLevel::Info, "color", "toggle -> %s", g_colorToggleOn.load() ? "on" : "off"); + return 0; + } if (msg == WM_HOTKEY && wp == kQuickZoomHotkeyId) { if (g_tick) g_tick->quickZoomHotkey.store(true); // RunTick consumes it (rising-edge via MOD_NOREPEAT) return 0; @@ -2576,6 +2621,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { // 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'); RegisterHideCursorHotkey(hwnd, cfg.hideCursorVk, cfg.hideCursorMods); + RegisterColorToggleHotkey(hwnd, cfg.colorToggleVk, cfg.colorToggleMods); // issue #288 RegisterQuickZoomHotkey(hwnd, (cfg.quickZoomHotkeyMode && cfg.quickZoomVk) ? cfg.quickZoomVk : 0, cfg.quickZoomMods); @@ -3043,8 +3089,10 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { if (ts.configWatch && ts.configWatch != INVALID_HANDLE_VALUE) FindCloseChangeNotification(ts.configWatch); UnregisterHotKey(hwnd, kQuitHotkeyId); UnregisterHotKey(hwnd, kHideCursorHotkeyId); + UnregisterHotKey(hwnd, kColorToggleHotkeyId); 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) 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..801979d 100644 --- a/src/render_engine.cpp +++ b/src/render_engine.cpp @@ -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); 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..005b9a0 100644 --- a/src/render_model.cpp +++ b/src/render_model.cpp @@ -73,6 +73,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_shaders.h b/src/render_shaders.h index 7463829..d961dae 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; From d53134f876653c2806917fb5c69f393ffa3cf5e6 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:00:21 +0200 Subject: [PATCH 05/19] feat(ui): Colour settings section; version 0.12.0 (#288) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/version.h | 6 +++--- ui/src/settings-schema.js | 12 ++++++++++++ ui/tests/settings.spec.js | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/version.h b/src/version.h index 5b1c5af..737d2b7 100644 --- a/src/version.h +++ b/src/version.h @@ -3,8 +3,8 @@ #pragma once #define WIND_VER_MAJOR 0 -#define WIND_VER_MINOR 11 -#define WIND_VER_PATCH 1 +#define WIND_VER_MINOR 12 +#define WIND_VER_PATCH 0 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.11.1" +#define WIND_VERSION_STR "0.12.0" diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index 7f0a37b..4adb53a 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -72,6 +72,18 @@ 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 filters (issue #288): one DWM colour matrix (the render engine applies it in its shader). + { id:'colour', label:'Colour', icon:'display', desc:'Colour filters for the screen, while zoomed and optionally at normal size.', rows: [ + { key:'colorFilter', type:'select', label:'Colour filter', + desc:'Changes the colours of everything on the screen.', + options:['0','1','2','3','4','5','6'], + optionLabels:{ '0':'Off', '1':'Invert', '2':'Greyscale', '3':'Warm (orange)', '4':'Yellow on black', '5':'White on blue', '6':'Green on black' }, + def:'0' }, + { key:'colorWarmPct', type:'slider', label:'Warm strength', desc:'How orange the Warm filter is.', min:10, max:100, step:5, def:50, unit:'%' }, + { key:'colorDimPct', type:'slider', label:'Brightness', desc:'Darkens the picture, like turning down a TV. 100% is normal. Works with any filter.', min:20, max:100, step:5, def:100, unit:'%' }, + { key:'colorAt1x', type:'toggle', label:'Also when not zoomed', desc:'Keep the filter on at normal size too.', def:'1' }, + { key:'__colorToggle', type:'keybind', label:'Toggle colour filter', desc:'Turns the filter and brightness on and off. Optional.', vkKey:'colorToggleVk', modsKey:'colorToggleMods' }, + ]}, { 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 a915adf..d4f6b70 100644 --- a/ui/tests/settings.spec.js +++ b/ui/tests/settings.spec.js @@ -534,3 +534,22 @@ test('Mouse edge margin: a slider in the Tracking section, 0% by default (issue await expect(row).toBeVisible(); await expect(row).toContainText('0'); }); + +test('Colour section: choosing Invert writes colorFilter=1 (issue #288)', async ({ page }) => { + await page.goto('/'); + const row = page.getByText('Colour filter', { exact: true }).locator('xpath=../..'); + await expect(row.getByRole('combobox')).toContainText('Off'); + await row.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Invert' }).click(); + await page.getByRole('button', { name: 'Apply' }).click(); + const sets = await page.evaluate(() => window.__sets.filter(m => m.type === 'setConfig' && m.key === 'colorFilter')); + expect(sets.at(-1).value).toBe('1'); +}); + +test('Colour section: brightness and warm sliders, the 1x toggle and the hotkey row render (issue #288)', async ({ page }) => { + await page.goto('/'); + for (const label of ['Warm strength', 'Brightness', 'Also when not zoomed', 'Toggle colour filter']) { + await expect(page.getByText(label, { exact: true })).toBeVisible(); + } + await expect(page.getByText('Brightness', { exact: true }).locator('xpath=../..')).toContainText('100'); +}); From 8b73e57643eef067033ec0d884c4d33bd7f923c5 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:55:26 +0200 Subject: [PATCH 06/19] fix(color): Night-light warm curve (6500-1200 K), brightness down to 5% (#288) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/color_matrix.h | 48 +++++++++++++++++++++++++++++++++---- src/config.cpp | 4 ++-- tests/test_color_matrix.cpp | 24 +++++++++++++++---- tests/test_config.cpp | 4 ++-- ui/src/settings-schema.js | 4 ++-- 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/color_matrix.h b/src/color_matrix.h index eda2e8e..c0b534b 100644 --- a/src/color_matrix.h +++ b/src/color_matrix.h @@ -5,6 +5,7 @@ // 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 { enum class ColorFilter { Off = 0, Invert, Greyscale, Warm, YellowOnBlack, WhiteOnBlue, GreenOnBlack }; @@ -45,11 +46,44 @@ inline bool SameMatrix(const ColorMatrix& a, const ColorMatrix& b) { return true; } -// warm01: Warm strength 0..1. dim01: brightness 0.2..1 (1 = no dim). Out-of-range values clamp. +// Lowest brightness the dim control reaches. Not 0: a black screen with the filter hotkey unbound +// would look like a dead display. +inline constexpr double kMinDim01 = 0.05; + +// Channel gains for a blackbody colour temperature, normalised so 6500 K is white (red stays 1 +// below that). Tanner Helland's fit to the CIE blackbody data, accurate to a few percent, which is +// all a screen tint needs. 1200 K gives about (1, 0.34, 0): the orange of Night light at full. +inline void KelvinGains(double kelvin, double& r, double& g, double& b) { + auto raw = [](double k, double& rr, double& gg, double& bb) { + const double t = k / 100.0; + rr = t <= 66 ? 1.0 : 329.698727446 * std::pow(t - 60, -0.1332047592) / 255.0; + gg = t <= 66 ? (99.4708025861 * std::log(t) - 161.1195681661) / 255.0 + : 288.1221695283 * std::pow(t - 60, -0.0755148492) / 255.0; + bb = t >= 66 ? 1.0 : t <= 19 ? 0.0 : (138.5177312231 * std::log(t - 10) - 305.0447927307) / 255.0; + auto c01 = [](double v) { return v < 0 ? 0.0 : (v > 1 ? 1.0 : v); }; + rr = c01(rr); gg = c01(gg); bb = c01(bb); + }; + double wr, wg, wb, cr, cg, cb; + raw(6500.0, wr, wg, wb); + raw(kelvin, cr, cg, cb); + r = cr / wr; g = cg / wg; b = cb / wb; + if (r > 1) r = 1; + if (g > 1) g = 1; + if (b > 1) b = 1; +} + +// Warm strength 0..1 -> colour temperature 6500 K..1200 K (Night light's range), linear in mireds +// so equal slider steps look like equal steps of warmth. +inline double WarmKelvin(double warm01) { + const double m0 = 1e6 / 6500.0, m1 = 1e6 / 1200.0; + return 1e6 / (m0 + (m1 - m0) * warm01); +} + +// warm01: Warm strength 0..1. dim01: brightness kMinDim01..1 (1 = no dim). Out-of-range values clamp. inline ColorMatrix BuildColorMatrix(ColorFilter f, double warm01, double dim01) { 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, 0.2, 1.0); + dim01 = clamp(dim01, kMinDim01, 1.0); const double w[3] = { 0.2126, 0.7152, 0.0722 }; // Rec.709 luma ColorMatrix m = IdentityColorMatrix(); auto twoColour = [&](const double fg[3], const double bg[3]) { @@ -70,10 +104,14 @@ inline ColorMatrix BuildColorMatrix(ColorFilter f, double warm01, double dim01) for (int i = 0; i < 3; ++i) for (int j = 0; j < 3; ++j) m.m[i][j] = (float)w[i]; break; - case ColorFilter::Warm: - m.m[1][1] = (float)(1.0 - 0.25 * warm01); - m.m[2][2] = (float)(1.0 - 0.6 * warm01); + case ColorFilter::Warm: { + // A colour temperature, like Night light: red kept, green and blue cut along the + // blackbody curve (the old fixed 25%/60% cut read as dim pink, field 2026-09-29). + double r, g, b; + KelvinGains(WarmKelvin(warm01), r, g, b); + m.m[0][0] = (float)r; m.m[1][1] = (float)g; m.m[2][2] = (float)b; break; + } case ColorFilter::YellowOnBlack: { const double fg[3] = { 1, 1, 0 }, bg[3] = { 0, 0, 0 }; m = twoColour(fg, bg); break; } case ColorFilter::WhiteOnBlue: { const double fg[3] = { 1, 1, 1 }, bg[3] = { 0, 0, 0.5 }; m = twoColour(fg, bg); break; } case ColorFilter::GreenOnBlack: { const double fg[3] = { 0, 1, 0 }, bg[3] = { 0, 0, 0 }; m = twoColour(fg, bg); break; } diff --git a/src/config.cpp b/src/config.cpp index 502ecdd..a984184 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -258,7 +258,7 @@ Config ParseConfig(const std::string& text) { c.brightness = clampd(c.brightness, 0.5, 1.5); c.colorFilter = (int)clampd(c.colorFilter, 0, 6); c.colorWarmPct = (int)clampd(c.colorWarmPct, 10, 100); - c.colorDimPct = (int)clampd(c.colorDimPct, 20, 100); + c.colorDimPct = (int)clampd(c.colorDimPct, 5, 100); c.quickZoomDefault = clampd(c.quickZoomDefault, 1.0, 50.0); if (c.outlineThickness < 1) c.outlineThickness = 1; if (c.outlineThickness > 40) c.outlineThickness = 40; @@ -452,7 +452,7 @@ std::string DefaultIniText() { "brightness=1.0\n" "; colorFilter: 0 off, 1 invert, 2 greyscale, 3 warm, 4 yellow on black, 5 white on blue, 6 green on black\n" "colorFilter=0\n" - "; colorWarmPct: Warm strength (10-100); colorDimPct: artificial brightness (20-100, 100 = normal)\n" + "; colorWarmPct: Warm strength (10-100); colorDimPct: artificial brightness (5-100, 100 = normal)\n" "colorWarmPct=50\n" "colorDimPct=100\n" "; colorAt1x: 1 = filter also when not zoomed; colorToggleVk/Mods: optional on/off hotkey\n" diff --git a/tests/test_color_matrix.cpp b/tests/test_color_matrix.cpp index 4476c5e..67b826d 100644 --- a/tests/test_color_matrix.cpp +++ b/tests/test_color_matrix.cpp @@ -25,12 +25,28 @@ TEST_CASE("greyscale uses Rec.709 luma on every channel") { Rgb(m, 1, 0, 0, r, g, b); CHECK(r == doctest::Approx(0.2126)); CHECK(g == doctest::Approx(0.2126)); CHECK(b == doctest::Approx(0.2126)); Rgb(m, 1, 1, 1, r, g, b); CHECK(r == doctest::Approx(1.0)); CHECK(b == doctest::Approx(1.0)); } -TEST_CASE("warm keeps red and cuts blue harder than green, scaled by strength") { +TEST_CASE("warm follows the blackbody curve from 6500 K to Night light's 1200 K") { double r, g, b; Rgb(BuildColorMatrix(ColorFilter::Warm, 1.0, 1.0), 1, 1, 1, r, g, b); - CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(0.75)); CHECK(b == doctest::Approx(0.4)); + CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(0.34).epsilon(0.05)); CHECK(b == doctest::Approx(0.0)); Rgb(BuildColorMatrix(ColorFilter::Warm, 0.0, 1.0), 1, 1, 1, r, g, b); - CHECK(g == doctest::Approx(1.0)); CHECK(b == doctest::Approx(1.0)); + CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(1.0)); CHECK(b == doctest::Approx(1.0)); + // Monotonic: more strength is never less orange. + double pg = 2, pb = 2; + for (int i = 0; i <= 20; ++i) { + Rgb(BuildColorMatrix(ColorFilter::Warm, i / 20.0, 1.0), 1, 1, 1, r, g, b); + CHECK(r == doctest::Approx(1.0)); + CHECK(g <= pg + 1e-9); CHECK(b <= pb + 1e-9); CHECK(b <= g + 1e-9); + pg = g; pb = b; + } +} +TEST_CASE("Kelvin gains are white at 6500 K and lose blue first") { + double r, g, b; + KelvinGains(6500, r, g, b); + CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(1.0)); CHECK(b == doctest::Approx(1.0)); + KelvinGains(2700, r, g, b); + CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(0.66).epsilon(0.05)); CHECK(b == doctest::Approx(0.35).epsilon(0.08)); + CHECK(WarmKelvin(0.0) == doctest::Approx(6500)); CHECK(WarmKelvin(1.0) == doctest::Approx(1200)); } TEST_CASE("two-colour tints: a white page becomes the background, black text the foreground") { double r, g, b; @@ -53,6 +69,6 @@ TEST_CASE("dim scales the result of any filter") { TEST_CASE("out-of-range inputs are clamped") { double r, g, b; Rgb(BuildColorMatrix(ColorFilter::Off, 0.5, 0.0), 1, 1, 1, r, g, b); - CHECK(r == doctest::Approx(0.2)); // dim floor 20% + CHECK(r == doctest::Approx(0.05)); // dim floor 5% CHECK(IsIdentity(BuildColorMatrix(static_cast(99), 0.5, 1.0))); // unknown -> off } diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 5d097c1..e073103 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -510,7 +510,7 @@ TEST_CASE("colour filter keys: defaults, parse and clamps (#288)") { Config c = ParseConfig("colorFilter=1\ncolorWarmPct=80\ncolorDimPct=60\ncolorAt1x=0\ncolorToggleVk=67\ncolorToggleMods=3\n"); CHECK(c.colorFilter == 1); CHECK(c.colorWarmPct == 80); CHECK(c.colorDimPct == 60); CHECK(c.colorAt1x == 0); CHECK(c.colorToggleVk == 67); CHECK(c.colorToggleMods == 3); - Config x = ParseConfig("colorFilter=42\ncolorWarmPct=0\ncolorDimPct=5\ncolorToggleVk=1\n"); - CHECK(x.colorFilter == 6); CHECK(x.colorWarmPct == 10); CHECK(x.colorDimPct == 20); + Config x = ParseConfig("colorFilter=42\ncolorWarmPct=0\ncolorDimPct=1\ncolorToggleVk=1\n"); + CHECK(x.colorFilter == 6); CHECK(x.colorWarmPct == 10); CHECK(x.colorDimPct == 5); CHECK(x.colorToggleVk == 0); // left click is a forbidden bind } diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index 4adb53a..0a2ecd4 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -79,8 +79,8 @@ export const sections = [ options:['0','1','2','3','4','5','6'], optionLabels:{ '0':'Off', '1':'Invert', '2':'Greyscale', '3':'Warm (orange)', '4':'Yellow on black', '5':'White on blue', '6':'Green on black' }, def:'0' }, - { key:'colorWarmPct', type:'slider', label:'Warm strength', desc:'How orange the Warm filter is.', min:10, max:100, step:5, def:50, unit:'%' }, - { key:'colorDimPct', type:'slider', label:'Brightness', desc:'Darkens the picture, like turning down a TV. 100% is normal. Works with any filter.', min:20, max:100, step:5, def:100, unit:'%' }, + { key:'colorWarmPct', type:'slider', label:'Warm strength', desc:'How orange the Warm filter is, like Night light. 100% is its warmest setting.', min:10, max:100, step:5, def:50, unit:'%' }, + { key:'colorDimPct', type:'slider', label:'Brightness', desc:'Darkens the picture, like turning down a TV. 100% is normal. Works with any filter.', min:5, max:100, step:5, def:100, unit:'%' }, { key:'colorAt1x', type:'toggle', label:'Also when not zoomed', desc:'Keep the filter on at normal size too.', def:'1' }, { key:'__colorToggle', type:'keybind', label:'Toggle colour filter', desc:'Turns the filter and brightness on and off. Optional.', vkKey:'colorToggleVk', modsKey:'colorToggleMods' }, ]}, From 83d916e2c4293699bec126f8f60341bb43315236 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:59:10 +0200 Subject: [PATCH 07/19] feat(color): warmth and brightness sliders only, always applied (#288) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- docs/COLOUR-FILTER-FINDINGS.md | 32 +++++++++++ .../specs/2026-09-29-colour-filters-design.md | 14 +++++ src/color_matrix.h | 53 +++++------------ src/config.cpp | 17 +----- src/config.h | 9 +-- src/main.cpp | 33 ++--------- tests/test_color_matrix.cpp | 57 ++++++------------- tests/test_config.cpp | 19 ++++--- ui/src/settings-schema.js | 15 ++--- ui/tests/settings.spec.js | 20 ++----- 10 files changed, 106 insertions(+), 163 deletions(-) diff --git a/docs/COLOUR-FILTER-FINDINGS.md b/docs/COLOUR-FILTER-FINDINGS.md index 5d05a95..087bf8a 100644 --- a/docs/COLOUR-FILTER-FINDINGS.md +++ b/docs/COLOUR-FILTER-FINDINGS.md @@ -19,3 +19,35 @@ Consequences for the design: - **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). diff --git a/docs/superpowers/specs/2026-09-29-colour-filters-design.md b/docs/superpowers/specs/2026-09-29-colour-filters-design.md index 594da72..47ec965 100644 --- a/docs/superpowers/specs/2026-09-29-colour-filters-design.md +++ b/docs/superpowers/specs/2026-09-29-colour-filters-design.md @@ -109,3 +109,17 @@ shader instead (the shader already has a brightness stage, `render_shaders.h`). 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`, 5-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 5% (`kMinDim01`; not 0, a black screen looks like a dead display). +- 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/src/color_matrix.h b/src/color_matrix.h index c0b534b..a036f79 100644 --- a/src/color_matrix.h +++ b/src/color_matrix.h @@ -8,8 +8,6 @@ #include namespace wind { -enum class ColorFilter { Off = 0, Invert, Greyscale, Warm, YellowOnBlack, WhiteOnBlue, GreenOnBlack }; - struct ColorMatrix { float m[5][5]; }; inline ColorMatrix IdentityColorMatrix() { @@ -79,49 +77,24 @@ inline double WarmKelvin(double warm01) { return 1e6 / (m0 + (m1 - m0) * warm01); } -// warm01: Warm strength 0..1. dim01: brightness kMinDim01..1 (1 = no dim). Out-of-range values clamp. -inline ColorMatrix BuildColorMatrix(ColorFilter f, double warm01, double dim01) { +// 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. +// Both at their neutral ends give the identity, which the controller treats as "off". +inline ColorMatrix BuildColorMatrix(double warm01, double dim01) { 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); - const double w[3] = { 0.2126, 0.7152, 0.0722 }; // Rec.709 luma ColorMatrix m = IdentityColorMatrix(); - auto twoColour = [&](const double fg[3], const double bg[3]) { - // Inverted luma drives the blend, so dark text on a light page becomes fg on bg: - // out = bg + (1 - L) (fg - bg) = fg - L (fg - bg). - ColorMatrix t = IdentityColorMatrix(); - for (int j = 0; j < 3; ++j) { - for (int i = 0; i < 3; ++i) t.m[i][j] = (float)(-w[i] * (fg[j] - bg[j])); - t.m[4][j] = (float)fg[j]; - } - return t; - }; - switch (f) { - case ColorFilter::Invert: - for (int i = 0; i < 3; ++i) { m.m[i][i] = -1.0f; m.m[4][i] = 1.0f; } - break; - case ColorFilter::Greyscale: - for (int i = 0; i < 3; ++i) - for (int j = 0; j < 3; ++j) m.m[i][j] = (float)w[i]; - break; - case ColorFilter::Warm: { - // A colour temperature, like Night light: red kept, green and blue cut along the - // blackbody curve (the old fixed 25%/60% cut read as dim pink, field 2026-09-29). - double r, g, b; - KelvinGains(WarmKelvin(warm01), r, g, b); - m.m[0][0] = (float)r; m.m[1][1] = (float)g; m.m[2][2] = (float)b; - break; - } - case ColorFilter::YellowOnBlack: { const double fg[3] = { 1, 1, 0 }, bg[3] = { 0, 0, 0 }; m = twoColour(fg, bg); break; } - case ColorFilter::WhiteOnBlue: { const double fg[3] = { 1, 1, 1 }, bg[3] = { 0, 0, 0.5 }; m = twoColour(fg, bg); break; } - case ColorFilter::GreenOnBlack: { const double fg[3] = { 0, 1, 0 }, bg[3] = { 0, 0, 0 }; m = twoColour(fg, bg); break; } - default: break; // Off, or an unknown value from a hand-edited ini - } - if (dim01 < 1.0) { - ColorMatrix d = IdentityColorMatrix(); - for (int i = 0; i < 3; ++i) d.m[i][i] = (float)dim01; - m = Multiply(m, d); // dim last: out = (in x filter) x dim + if (warm01 > 0.0) { + // A colour temperature, like Night light: red kept, green and blue cut along the + // blackbody curve (a fixed 25%/60% cut read as dim pink, field 2026-09-29). + double r, g, b; + KelvinGains(WarmKelvin(warm01), r, g, b); + m.m[0][0] = (float)r; m.m[1][1] = (float)g; m.m[2][2] = (float)b; } + for (int i = 0; i < 3; ++i) m.m[i][i] = (float)(m.m[i][i] * dim01); // dim composes last return m; } diff --git a/src/config.cpp b/src/config.cpp index a984184..fd666c3 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -169,12 +169,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 == "colorFilter") c.colorFilter = std::stoi(val); else if (key == "colorWarmPct") c.colorWarmPct = std::stoi(val); else if (key == "colorDimPct") c.colorDimPct = std::stoi(val); - else if (key == "colorAt1x") c.colorAt1x = std::stoi(val); - else if (key == "colorToggleVk") c.colorToggleVk = std::stoi(val); - else if (key == "colorToggleMods") c.colorToggleMods = 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,8 +252,7 @@ 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.colorFilter = (int)clampd(c.colorFilter, 0, 6); - c.colorWarmPct = (int)clampd(c.colorWarmPct, 10, 100); + c.colorWarmPct = (int)clampd(c.colorWarmPct, 0, 100); c.colorDimPct = (int)clampd(c.colorDimPct, 5, 100); c.quickZoomDefault = clampd(c.quickZoomDefault, 1.0, 50.0); if (c.outlineThickness < 1) c.outlineThickness = 1; @@ -322,7 +317,6 @@ Config ParseConfig(const std::string& text) { sanitizeVk(c.recenterVk); sanitizeVk(c.cursorLockVk); sanitizeVk(c.hideCursorVk); - sanitizeVk(c.colorToggleVk); sanitizeVk(c.quickZoomVk); return c; } @@ -450,14 +444,9 @@ std::string DefaultIniText() { "mouseMarginPct=0\n" "; brightness: magnified-view output multiplier (1.0=unchanged; fine-tune for HDR)\n" "brightness=1.0\n" - "; colorFilter: 0 off, 1 invert, 2 greyscale, 3 warm, 4 yellow on black, 5 white on blue, 6 green on black\n" - "colorFilter=0\n" - "; colorWarmPct: Warm strength (10-100); colorDimPct: artificial brightness (5-100, 100 = normal)\n" - "colorWarmPct=50\n" + "; colorWarmPct: screen warmth like Night light (0-100, 0 = off); colorDimPct: artificial brightness (5-100, 100 = normal)\n" + "colorWarmPct=0\n" "colorDimPct=100\n" - "; colorAt1x: 1 = filter also when not zoomed; colorToggleVk/Mods: optional on/off hotkey\n" - "colorAt1x=1\n" - "colorToggleVk=0\ncolorToggleMods=0\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 f1bff5e..911ee63 100644 --- a/src/config.h +++ b/src/config.h @@ -496,12 +496,9 @@ struct Config { // 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). - int colorFilter = 0; // 0 off, 1 invert, 2 greyscale, 3 warm, 4 yellow on black, 5 white on blue, 6 green on black - int colorWarmPct = 50; // Warm strength, 10..100 - int colorDimPct = 100; // artificial brightness, 20..100 (100 = no dim); composes with any filter - int colorAt1x = 1; // also apply when not zoomed (holds a magnification context while on) - int colorToggleVk = 0; // optional hotkey that flips the filter on/off (0 = none) - int colorToggleMods = 0; + // 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 5..100 (100 = no dim) // 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/main.cpp b/src/main.cpp index b65e599..68966c4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -741,7 +741,6 @@ static void DiagLog(const char* fmt, ...) { // Forward-declared so RunTick can re-register the hide-cursor hotkey on config hot-reload; // the definition (with the static state it manages) lives near WndProc / kHideCursorHotkeyId. static void RegisterHideCursorHotkey(HWND hwnd, int vk, int mods); -static void RegisterColorToggleHotkey(HWND hwnd, int vk, int mods); // issue #288 // Same pattern for the quick-zoom hotkey (hotkey mode). Pass vk=0 to unregister. static void RegisterQuickZoomHotkey(HWND hwnd, int vk, int mods); @@ -793,19 +792,18 @@ static void EndPanelFreeze(TickState& t) { t.panelFreeze = false; } -// COLOUR FILTER (issue #288). One matrix from the config and the toggle. Transform sessions and 1x use +// 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; -static std::atomic g_colorToggleOn{true}; // the optional hotkey flips it; starts on each launch 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 wind::ColorMatrix m = wind::IdentityColorMatrix(); - if (!magnify && g_colorToggleOn.load() && (zoomedNow || t.cfg.colorAt1x != 0)) - m = wind::BuildColorMatrix(static_cast(t.cfg.colorFilter), - t.cfg.colorWarmPct / 100.0, t.cfg.colorDimPct / 100.0); + (void)zoomedNow; // applies zoomed and at 1x alike (owner decision 2026-09-29) + if (!magnify) + m = wind::BuildColorMatrix(t.cfg.colorWarmPct / 100.0, t.cfg.colorDimPct / 100.0); const bool inShader = renderSession && !wind::IsIdentity(m); if (ex) { ex->colorOn = inShader; ex->color = m; } const wind::ColorMatrix dwm = inShader ? wind::IdentityColorMatrix() : m; @@ -890,8 +888,6 @@ static void RunTick(TickState& t) { g_input.setKeys(nc.zoomInVk, nc.zoomInVk2, nc.zoomOutVk, nc.zoomOutVk2, nc.recenterVk, nc.cursorLockVk); } - if (nc.colorToggleVk != t.cfg.colorToggleVk || nc.colorToggleMods != t.cfg.colorToggleMods) - RegisterColorToggleHotkey(t.hwnd, nc.colorToggleVk, nc.colorToggleMods); if (nc.hideCursorVk != t.cfg.hideCursorVk || nc.hideCursorMods != t.cfg.hideCursorMods) { RegisterHideCursorHotkey(t.hwnd, nc.hideCursorVk, nc.hideCursorMods); } @@ -2301,7 +2297,6 @@ static void RunTick(TickState& t) { // keyboard-only escape. The clean exit path restores the cursor and resets zoom to 1x. static const int kQuitHotkeyId = 0xB001; static const int kHideCursorHotkeyId = 0xB002; -static const int kColorToggleHotkeyId = 0xB005; // issue #288 static const int kQuickZoomHotkeyId = 0xB003; // Translate our bit mask (1=Ctrl, 2=Alt, 4=Shift, 8=Win) into Win32 MOD_* flags for RegisterHotKey. @@ -2329,19 +2324,6 @@ static void RegisterHideCursorHotkey(HWND hwnd, int vk, int mods) { } } -// Hot-reloadable registration of the colour-filter toggle hotkey (issue #288). vk=0 unregisters. -static int g_registeredColorVk = 0; -static int g_registeredColorMods = 0; -static void RegisterColorToggleHotkey(HWND hwnd, int vk, int mods) { - if (g_registeredColorVk != 0) { - UnregisterHotKey(hwnd, kColorToggleHotkeyId); - g_registeredColorVk = 0; g_registeredColorMods = 0; - } - if (vk != 0 && RegisterHotKey(hwnd, kColorToggleHotkeyId, WinModsFromBitmask(mods), vk)) { - g_registeredColorVk = vk; g_registeredColorMods = mods; - } -} - // Hot-reloadable registration of the quick-zoom hotkey (hotkey mode). Callers pass vk=0 to // unregister (modifier mode, or hotkey cleared), releasing the global key grab. static int g_registeredQuickVk = 0; @@ -2362,11 +2344,6 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { if (g_tick) g_tick->cursorHidden = !g_tick->cursorHidden; return 0; } - if (msg == WM_HOTKEY && wp == kColorToggleHotkeyId) { - g_colorToggleOn.store(!g_colorToggleOn.load()); // the next tick applies it - wind::Log(wind::LogLevel::Info, "color", "toggle -> %s", g_colorToggleOn.load() ? "on" : "off"); - return 0; - } if (msg == WM_HOTKEY && wp == kQuickZoomHotkeyId) { if (g_tick) g_tick->quickZoomHotkey.store(true); // RunTick consumes it (rising-edge via MOD_NOREPEAT) return 0; @@ -2621,7 +2598,6 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { // 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'); RegisterHideCursorHotkey(hwnd, cfg.hideCursorVk, cfg.hideCursorMods); - RegisterColorToggleHotkey(hwnd, cfg.colorToggleVk, cfg.colorToggleMods); // issue #288 RegisterQuickZoomHotkey(hwnd, (cfg.quickZoomHotkeyMode && cfg.quickZoomVk) ? cfg.quickZoomVk : 0, cfg.quickZoomMods); @@ -3089,7 +3065,6 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { if (ts.configWatch && ts.configWatch != INVALID_HANDLE_VALUE) FindCloseChangeNotification(ts.configWatch); UnregisterHotKey(hwnd, kQuitHotkeyId); UnregisterHotKey(hwnd, kHideCursorHotkeyId); - UnregisterHotKey(hwnd, kColorToggleHotkeyId); 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) diff --git a/tests/test_color_matrix.cpp b/tests/test_color_matrix.cpp index 67b826d..6c4e70c 100644 --- a/tests/test_color_matrix.cpp +++ b/tests/test_color_matrix.cpp @@ -6,35 +6,21 @@ static void Rgb(const ColorMatrix& m, double r, double g, double b, double& o0, ApplyToRgb(m, r, g, b, o0, o1, o2); } -TEST_CASE("off with full brightness is the identity, and is detected as such") { - const ColorMatrix m = BuildColorMatrix(ColorFilter::Off, 0.5, 1.0); - CHECK(IsIdentity(m)); - CHECK_FALSE(IsIdentity(BuildColorMatrix(ColorFilter::Off, 0.5, 0.9))); - CHECK_FALSE(IsIdentity(BuildColorMatrix(ColorFilter::Invert, 0.5, 1.0))); -} -TEST_CASE("invert swaps black and white and mirrors a colour") { - const ColorMatrix m = BuildColorMatrix(ColorFilter::Invert, 0.5, 1.0); - double r, g, b; - Rgb(m, 1, 1, 1, r, g, b); CHECK(r == doctest::Approx(0)); CHECK(g == doctest::Approx(0)); CHECK(b == doctest::Approx(0)); - Rgb(m, 0, 0, 0, r, g, b); CHECK(r == doctest::Approx(1)); CHECK(b == doctest::Approx(1)); - Rgb(m, 1, 0, 0.25, r, g, b); CHECK(r == doctest::Approx(0)); CHECK(g == doctest::Approx(1)); CHECK(b == doctest::Approx(0.75)); -} -TEST_CASE("greyscale uses Rec.709 luma on every channel") { - const ColorMatrix m = BuildColorMatrix(ColorFilter::Greyscale, 0.5, 1.0); - double r, g, b; - Rgb(m, 1, 0, 0, r, g, b); CHECK(r == doctest::Approx(0.2126)); CHECK(g == doctest::Approx(0.2126)); CHECK(b == doctest::Approx(0.2126)); - Rgb(m, 1, 1, 1, r, g, b); CHECK(r == doctest::Approx(1.0)); CHECK(b == doctest::Approx(1.0)); +TEST_CASE("no warmth at full brightness is the identity, and is detected as such") { + CHECK(IsIdentity(BuildColorMatrix(0.0, 1.0))); + CHECK_FALSE(IsIdentity(BuildColorMatrix(0.0, 0.9))); + CHECK_FALSE(IsIdentity(BuildColorMatrix(0.1, 1.0))); } TEST_CASE("warm follows the blackbody curve from 6500 K to Night light's 1200 K") { double r, g, b; - Rgb(BuildColorMatrix(ColorFilter::Warm, 1.0, 1.0), 1, 1, 1, r, g, b); + Rgb(BuildColorMatrix(1.0, 1.0), 1, 1, 1, r, g, b); CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(0.34).epsilon(0.05)); CHECK(b == doctest::Approx(0.0)); - Rgb(BuildColorMatrix(ColorFilter::Warm, 0.0, 1.0), 1, 1, 1, r, g, b); + Rgb(BuildColorMatrix(0.0, 1.0), 1, 1, 1, r, g, b); CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(1.0)); CHECK(b == doctest::Approx(1.0)); // Monotonic: more strength is never less orange. double pg = 2, pb = 2; for (int i = 0; i <= 20; ++i) { - Rgb(BuildColorMatrix(ColorFilter::Warm, i / 20.0, 1.0), 1, 1, 1, r, g, b); + Rgb(BuildColorMatrix(i / 20.0, 1.0), 1, 1, 1, r, g, b); CHECK(r == doctest::Approx(1.0)); CHECK(g <= pg + 1e-9); CHECK(b <= pb + 1e-9); CHECK(b <= g + 1e-9); pg = g; pb = b; @@ -48,27 +34,20 @@ TEST_CASE("Kelvin gains are white at 6500 K and lose blue first") { CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(0.66).epsilon(0.05)); CHECK(b == doctest::Approx(0.35).epsilon(0.08)); CHECK(WarmKelvin(0.0) == doctest::Approx(6500)); CHECK(WarmKelvin(1.0) == doctest::Approx(1200)); } -TEST_CASE("two-colour tints: a white page becomes the background, black text the foreground") { - double r, g, b; - const ColorMatrix y = BuildColorMatrix(ColorFilter::YellowOnBlack, 0.5, 1.0); - Rgb(y, 1, 1, 1, r, g, b); CHECK(r == doctest::Approx(0)); CHECK(g == doctest::Approx(0)); CHECK(b == doctest::Approx(0)); - Rgb(y, 0, 0, 0, r, g, b); CHECK(r == doctest::Approx(1)); CHECK(g == doctest::Approx(1)); CHECK(b == doctest::Approx(0)); - const ColorMatrix w = BuildColorMatrix(ColorFilter::WhiteOnBlue, 0.5, 1.0); - Rgb(w, 1, 1, 1, r, g, b); CHECK(b == doctest::Approx(0.5)); CHECK(r == doctest::Approx(0)); - Rgb(w, 0, 0, 0, r, g, b); CHECK(r == doctest::Approx(1)); CHECK(b == doctest::Approx(1)); - const ColorMatrix gr = BuildColorMatrix(ColorFilter::GreenOnBlack, 0.5, 1.0); - Rgb(gr, 0, 0, 0, r, g, b); CHECK(g == doctest::Approx(1)); CHECK(r == doctest::Approx(0)); -} -TEST_CASE("dim scales the result of any filter") { +TEST_CASE("brightness scales the warm result") { double r, g, b; - Rgb(BuildColorMatrix(ColorFilter::Off, 0.5, 0.5), 1, 0.5, 0, r, g, b); + Rgb(BuildColorMatrix(0.0, 0.5), 1, 0.5, 0, r, g, b); CHECK(r == doctest::Approx(0.5)); CHECK(g == doctest::Approx(0.25)); CHECK(b == doctest::Approx(0)); - Rgb(BuildColorMatrix(ColorFilter::Invert, 0.5, 0.5), 0, 0, 0, r, g, b); - CHECK(r == doctest::Approx(0.5)); // black -> white -> half + double wr0, wg0, wb0; + Rgb(BuildColorMatrix(1.0, 1.0), 1, 1, 1, wr0, wg0, wb0); + Rgb(BuildColorMatrix(1.0, 0.5), 1, 1, 1, r, g, b); + CHECK(r == doctest::Approx(wr0 * 0.5)); CHECK(g == doctest::Approx(wg0 * 0.5)); } TEST_CASE("out-of-range inputs are clamped") { double r, g, b; - Rgb(BuildColorMatrix(ColorFilter::Off, 0.5, 0.0), 1, 1, 1, r, g, b); - CHECK(r == doctest::Approx(0.05)); // dim floor 5% - CHECK(IsIdentity(BuildColorMatrix(static_cast(99), 0.5, 1.0))); // unknown -> off + Rgb(BuildColorMatrix(0.0, 0.0), 1, 1, 1, r, g, b); + CHECK(r == doctest::Approx(0.05)); // brightness floor 5% + CHECK(IsIdentity(BuildColorMatrix(-1.0, 2.0))); + Rgb(BuildColorMatrix(5.0, 1.0), 1, 1, 1, r, g, b); + CHECK(b == doctest::Approx(0.0)); // warmth caps at 1200 K } diff --git a/tests/test_config.cpp b/tests/test_config.cpp index e073103..dc826d0 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -503,14 +503,15 @@ TEST_CASE("tracking settings: defaults and parsing (issue #276)") { CHECK(c.trackLog == 1); } -TEST_CASE("colour filter keys: defaults, parse and clamps (#288)") { +TEST_CASE("colour keys: defaults, parse and clamps (#288)") { Config d = ParseConfig(""); - CHECK(d.colorFilter == 0); CHECK(d.colorWarmPct == 50); CHECK(d.colorDimPct == 100); CHECK(d.colorAt1x == 1); - CHECK(d.colorToggleVk == 0); - Config c = ParseConfig("colorFilter=1\ncolorWarmPct=80\ncolorDimPct=60\ncolorAt1x=0\ncolorToggleVk=67\ncolorToggleMods=3\n"); - CHECK(c.colorFilter == 1); CHECK(c.colorWarmPct == 80); CHECK(c.colorDimPct == 60); CHECK(c.colorAt1x == 0); - CHECK(c.colorToggleVk == 67); CHECK(c.colorToggleMods == 3); - Config x = ParseConfig("colorFilter=42\ncolorWarmPct=0\ncolorDimPct=1\ncolorToggleVk=1\n"); - CHECK(x.colorFilter == 6); CHECK(x.colorWarmPct == 10); CHECK(x.colorDimPct == 5); - CHECK(x.colorToggleVk == 0); // left click is a forbidden bind + 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=1\n"); + CHECK(x.colorWarmPct == 0); CHECK(x.colorDimPct == 5); + 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); } diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index 0a2ecd4..b458762 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -72,17 +72,10 @@ 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 filters (issue #288): one DWM colour matrix (the render engine applies it in its shader). - { id:'colour', label:'Colour', icon:'display', desc:'Colour filters for the screen, while zoomed and optionally at normal size.', rows: [ - { key:'colorFilter', type:'select', label:'Colour filter', - desc:'Changes the colours of everything on the screen.', - options:['0','1','2','3','4','5','6'], - optionLabels:{ '0':'Off', '1':'Invert', '2':'Greyscale', '3':'Warm (orange)', '4':'Yellow on black', '5':'White on blue', '6':'Green on black' }, - def:'0' }, - { key:'colorWarmPct', type:'slider', label:'Warm strength', desc:'How orange the Warm filter is, like Night light. 100% is its warmest setting.', min:10, max:100, step:5, def:50, unit:'%' }, - { key:'colorDimPct', type:'slider', label:'Brightness', desc:'Darkens the picture, like turning down a TV. 100% is normal. Works with any filter.', min:5, max:100, step:5, def:100, unit:'%' }, - { key:'colorAt1x', type:'toggle', label:'Also when not zoomed', desc:'Keep the filter on at normal size too.', def:'1' }, - { key:'__colorToggle', type:'keybind', label:'Toggle colour filter', desc:'Turns the filter and brightness on and off. Optional.', vkKey:'colorToggleVk', modsKey:'colorToggleMods' }, + // 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:5, max:100, step:5, def:100, unit:'%' }, ]}, { id:'display', label:'Display', icon:'display', desc:'The engine behind the magnified view.', rows: [ { key:'model', type:'select', label:'Magnifier engine', diff --git a/ui/tests/settings.spec.js b/ui/tests/settings.spec.js index d4f6b70..2dc3847 100644 --- a/ui/tests/settings.spec.js +++ b/ui/tests/settings.spec.js @@ -535,21 +535,11 @@ test('Mouse edge margin: a slider in the Tracking section, 0% by default (issue await expect(row).toContainText('0'); }); -test('Colour section: choosing Invert writes colorFilter=1 (issue #288)', async ({ page }) => { +test('Colour section: only the warmth and brightness sliders, neutral by default (issue #288)', async ({ page }) => { await page.goto('/'); - const row = page.getByText('Colour filter', { exact: true }).locator('xpath=../..'); - await expect(row.getByRole('combobox')).toContainText('Off'); - await row.getByRole('combobox').click(); - await page.getByRole('option', { name: 'Invert' }).click(); - await page.getByRole('button', { name: 'Apply' }).click(); - const sets = await page.evaluate(() => window.__sets.filter(m => m.type === 'setConfig' && m.key === 'colorFilter')); - expect(sets.at(-1).value).toBe('1'); -}); - -test('Colour section: brightness and warm sliders, the 1x toggle and the hotkey row render (issue #288)', async ({ page }) => { - await page.goto('/'); - for (const label of ['Warm strength', 'Brightness', 'Also when not zoomed', 'Toggle colour filter']) { - await expect(page.getByText(label, { exact: true })).toBeVisible(); - } + 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); + } }); From 569ab62709c3fc8d905972d6e8ea59b08f9c606d Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:07:44 +0200 Subject: [PATCH 08/19] fix(color): brightness down to 1% (#288) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- docs/superpowers/specs/2026-09-29-colour-filters-design.md | 4 ++-- src/color_matrix.h | 2 +- src/config.cpp | 4 ++-- src/config.h | 2 +- tests/test_color_matrix.cpp | 2 +- tests/test_config.cpp | 2 +- ui/src/settings-schema.js | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-09-29-colour-filters-design.md b/docs/superpowers/specs/2026-09-29-colour-filters-design.md index 47ec965..b83aaeb 100644 --- a/docs/superpowers/specs/2026-09-29-colour-filters-design.md +++ b/docs/superpowers/specs/2026-09-29-colour-filters-design.md @@ -115,11 +115,11 @@ owner may prefer a patch bump), release on merge. 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`, 5-100, default 100). Both neutral = no + 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 5% (`kMinDim01`; not 0, a black screen looks like a dead display). +- Brightness floor 1% (`kMinDim01`, owner request; not 0, a black screen looks like a dead display). - 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/src/color_matrix.h b/src/color_matrix.h index a036f79..fa4dbc1 100644 --- a/src/color_matrix.h +++ b/src/color_matrix.h @@ -46,7 +46,7 @@ inline bool SameMatrix(const ColorMatrix& a, const ColorMatrix& b) { // Lowest brightness the dim control reaches. Not 0: a black screen with the filter hotkey unbound // would look like a dead display. -inline constexpr double kMinDim01 = 0.05; +inline constexpr double kMinDim01 = 0.01; // Channel gains for a blackbody colour temperature, normalised so 6500 K is white (red stays 1 // below that). Tanner Helland's fit to the CIE blackbody data, accurate to a few percent, which is diff --git a/src/config.cpp b/src/config.cpp index fd666c3..35580f2 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -253,7 +253,7 @@ Config ParseConfig(const std::string& text) { 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, 5, 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; @@ -444,7 +444,7 @@ 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 (5-100, 100 = normal)\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" diff --git a/src/config.h b/src/config.h index 911ee63..659621a 100644 --- a/src/config.h +++ b/src/config.h @@ -498,7 +498,7 @@ struct Config { // 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 5..100 (100 = no dim) + int colorDimPct = 100; // artificial brightness 1..100 (100 = no dim) // 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/tests/test_color_matrix.cpp b/tests/test_color_matrix.cpp index 6c4e70c..5950e41 100644 --- a/tests/test_color_matrix.cpp +++ b/tests/test_color_matrix.cpp @@ -46,7 +46,7 @@ TEST_CASE("brightness scales the warm result") { TEST_CASE("out-of-range inputs are clamped") { double r, g, b; Rgb(BuildColorMatrix(0.0, 0.0), 1, 1, 1, r, g, b); - CHECK(r == doctest::Approx(0.05)); // brightness floor 5% + CHECK(r == doctest::Approx(0.01)); // brightness floor 1% CHECK(IsIdentity(BuildColorMatrix(-1.0, 2.0))); Rgb(BuildColorMatrix(5.0, 1.0), 1, 1, 1, r, g, b); CHECK(b == doctest::Approx(0.0)); // warmth caps at 1200 K diff --git a/tests/test_config.cpp b/tests/test_config.cpp index dc826d0..b68ef6b 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -509,7 +509,7 @@ TEST_CASE("colour keys: defaults, parse and clamps (#288)") { Config c = ParseConfig("colorWarmPct=80\ncolorDimPct=60\n"); CHECK(c.colorWarmPct == 80); CHECK(c.colorDimPct == 60); Config x = ParseConfig("colorWarmPct=-4\ncolorDimPct=1\n"); - CHECK(x.colorWarmPct == 0); CHECK(x.colorDimPct == 5); + 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"); diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index b458762..d90ad52 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -75,7 +75,7 @@ export const sections = [ // 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:5, max:100, step:5, def:100, 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', From cf286b40d51590face81cc9ddca330995ce91f70 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:05:43 +0200 Subject: [PATCH 09/19] fix(color): Night-light warmth (CIE blackbody, 6500-1200 K linear in Kelvin), linear light under HDR, brightness to 0 (#288) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- .../specs/2026-09-29-colour-filters-design.md | 2 +- src/color_matrix.h | 89 +++++++++++-------- src/config.cpp | 4 +- src/config.h | 2 +- src/main.cpp | 22 +++-- tests/test_color_matrix.cpp | 82 +++++++++-------- tests/test_config.cpp | 4 +- ui/src/settings-schema.js | 2 +- 8 files changed, 120 insertions(+), 87 deletions(-) diff --git a/docs/superpowers/specs/2026-09-29-colour-filters-design.md b/docs/superpowers/specs/2026-09-29-colour-filters-design.md index b83aaeb..2ad160d 100644 --- a/docs/superpowers/specs/2026-09-29-colour-filters-design.md +++ b/docs/superpowers/specs/2026-09-29-colour-filters-design.md @@ -115,7 +115,7 @@ owner may prefer a patch bump), release on merge. 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 + 0-100, default 0 = off) and **Brightness** (`colorDimPct`, 0-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. diff --git a/src/color_matrix.h b/src/color_matrix.h index fa4dbc1..16342df 100644 --- a/src/color_matrix.h +++ b/src/color_matrix.h @@ -44,57 +44,72 @@ inline bool SameMatrix(const ColorMatrix& a, const ColorMatrix& b) { return true; } -// Lowest brightness the dim control reaches. Not 0: a black screen with the filter hotkey unbound -// would look like a dead display. -inline constexpr double kMinDim01 = 0.01; +// Lowest brightness the dim control reaches: 0, black (owner request 2026-09-29: 1% was still +// visible on an OLED). Quitting Wind (Ctrl+Alt+Q) always clears the effect; so does a crash. +inline constexpr double kMinDim01 = 0.0; -// Channel gains for a blackbody colour temperature, normalised so 6500 K is white (red stays 1 -// below that). Tanner Helland's fit to the CIE blackbody data, accurate to a few percent, which is -// all a screen tint needs. 1200 K gives about (1, 0.34, 0): the orange of Night light at full. -inline void KelvinGains(double kelvin, double& r, double& g, double& b) { - auto raw = [](double k, double& rr, double& gg, double& bb) { - const double t = k / 100.0; - rr = t <= 66 ? 1.0 : 329.698727446 * std::pow(t - 60, -0.1332047592) / 255.0; - gg = t <= 66 ? (99.4708025861 * std::log(t) - 161.1195681661) / 255.0 - : 288.1221695283 * std::pow(t - 60, -0.0755148492) / 255.0; - bb = t >= 66 ? 1.0 : t <= 19 ? 0.0 : (138.5177312231 * std::log(t - 10) - 305.0447927307) / 255.0; - auto c01 = [](double v) { return v < 0 ? 0.0 : (v > 1 ? 1.0 : v); }; - rr = c01(rr); gg = c01(gg); bb = c01(bb); - }; - double wr, wg, wb, cr, cg, cb; - raw(6500.0, wr, wg, wb); - raw(kelvin, cr, cg, cb); - r = cr / wr; g = cg / wg; b = cb / wb; - if (r > 1) r = 1; - if (g > 1) g = 1; - if (b > 1) b = 1; -} +// 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])); -// Warm strength 0..1 -> colour temperature 6500 K..1200 K (Night light's range), linear in mireds -// so equal slider steps look like equal steps of warmth. -inline double WarmKelvin(double warm01) { - const double m0 = 1e6 / 6500.0, m1 = 1e6 / 1200.0; - return 1e6 / (m0 + (m1 - m0) * warm01); +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. +// warm01: 0..1 (0 = no warmth). dim01: 0..1 (1 = no dim, 0 = black). 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) { +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) { - // A colour temperature, like Night light: red kept, green and blue cut along the - // blackbody curve (a fixed 25%/60% cut read as dim pink, field 2026-09-29). - double r, g, b; - KelvinGains(WarmKelvin(warm01), r, g, b); - m.m[0][0] = (float)r; m.m[1][1] = (float)g; m.m[2][2] = (float)b; + 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])); } - for (int i = 0; i < 3; ++i) m.m[i][i] = (float)(m.m[i][i] * dim01); // dim composes last + 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; } diff --git a/src/config.cpp b/src/config.cpp index 35580f2..abcf94f 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -253,7 +253,7 @@ Config ParseConfig(const std::string& text) { 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.colorDimPct = (int)clampd(c.colorDimPct, 0, 100); c.quickZoomDefault = clampd(c.quickZoomDefault, 1.0, 50.0); if (c.outlineThickness < 1) c.outlineThickness = 1; if (c.outlineThickness > 40) c.outlineThickness = 40; @@ -444,7 +444,7 @@ 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: screen warmth like Night light (0-100, 0 = off); colorDimPct: artificial brightness (0-100, 100 = normal, 0 = black)\n" "colorWarmPct=0\n" "colorDimPct=100\n" "; hdrTonemap: 1=HDR10->SDR tonemap when Windows HDR is on (no-op on SDR); 0=off\n" diff --git a/src/config.h b/src/config.h index 659621a..9d9c23c 100644 --- a/src/config.h +++ b/src/config.h @@ -498,7 +498,7 @@ struct Config { // 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) + int colorDimPct = 100; // artificial brightness 0..100 (100 = no dim, 0 = black; 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/main.cpp b/src/main.cpp index 68966c4..5d28820 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -26,7 +26,8 @@ #pragma comment(lib, "Dwmapi.lib") #include "render_engine.h" #include "render_model.h" -#include "color_filter.h" // issue #288 +#include "color_filter.h" +#include "hdr_info.h" // issue #288 #include "magnify_model.h" #include "transform_model.h" #include "input_router.h" @@ -798,15 +799,20 @@ static void EndPanelFreeze(TickState& t) { // 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}; 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 - wind::ColorMatrix m = wind::IdentityColorMatrix(); (void)zoomedNow; // applies zoomed and at 1x alike (owner decision 2026-09-29) - if (!magnify) - m = wind::BuildColorMatrix(t.cfg.colorWarmPct / 100.0, t.cfg.colorDimPct / 100.0); - const bool inShader = renderSession && !wind::IsIdentity(m); - if (ex) { ex->colorOn = inShader; ex->color = m; } - const wind::ColorMatrix dwm = inShader ? wind::IdentityColorMatrix() : m; + const double w = t.cfg.colorWarmPct / 100.0, d = t.cfg.colorDimPct / 100.0; + // 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)); } @@ -2339,6 +2345,7 @@ 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(wind::GetHdrEnabled(nullptr)); // colour space (#288); falls through 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; @@ -2597,6 +2604,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(wind::GetHdrEnabled(nullptr)); // colour filter space (#288), refreshed on WM_DISPLAYCHANGE RegisterHideCursorHotkey(hwnd, cfg.hideCursorVk, cfg.hideCursorMods); RegisterQuickZoomHotkey(hwnd, (cfg.quickZoomHotkeyMode && cfg.quickZoomVk) ? cfg.quickZoomVk : 0, cfg.quickZoomMods); diff --git a/tests/test_color_matrix.cpp b/tests/test_color_matrix.cpp index 5950e41..33b955a 100644 --- a/tests/test_color_matrix.cpp +++ b/tests/test_color_matrix.cpp @@ -6,48 +6,58 @@ static void Rgb(const ColorMatrix& m, double r, double g, double b, double& o0, ApplyToRgb(m, r, g, b, o0, o1, o2); } -TEST_CASE("no warmth at full brightness is the identity, and is detected as such") { - CHECK(IsIdentity(BuildColorMatrix(0.0, 1.0))); - CHECK_FALSE(IsIdentity(BuildColorMatrix(0.0, 0.9))); - CHECK_FALSE(IsIdentity(BuildColorMatrix(0.1, 1.0))); +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("warm follows the blackbody curve from 6500 K to Night light's 1200 K") { +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; - Rgb(BuildColorMatrix(1.0, 1.0), 1, 1, 1, r, g, b); - CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(0.34).epsilon(0.05)); CHECK(b == doctest::Approx(0.0)); - Rgb(BuildColorMatrix(0.0, 1.0), 1, 1, 1, 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)); - // Monotonic: more strength is never less orange. - double pg = 2, pb = 2; + 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) { - Rgb(BuildColorMatrix(i / 20.0, 1.0), 1, 1, 1, r, g, b); - CHECK(r == doctest::Approx(1.0)); - CHECK(g <= pg + 1e-9); CHECK(b <= pb + 1e-9); CHECK(b <= g + 1e-9); - pg = g; pb = b; + 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("Kelvin gains are white at 6500 K and lose blue first") { - double r, g, b; - KelvinGains(6500, r, g, b); - CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(1.0)); CHECK(b == doctest::Approx(1.0)); - KelvinGains(2700, r, g, b); - CHECK(r == doctest::Approx(1.0)); CHECK(g == doctest::Approx(0.66).epsilon(0.05)); CHECK(b == doctest::Approx(0.35).epsilon(0.08)); - CHECK(WarmKelvin(0.0) == doctest::Approx(6500)); CHECK(WarmKelvin(1.0) == doctest::Approx(1200)); -} -TEST_CASE("brightness scales the warm result") { +TEST_CASE("brightness: encoded in SDR, decoded to linear in HDR, down to black") { double r, g, b; - Rgb(BuildColorMatrix(0.0, 0.5), 1, 0.5, 0, r, g, b); - CHECK(r == doctest::Approx(0.5)); CHECK(g == doctest::Approx(0.25)); CHECK(b == doctest::Approx(0)); - double wr0, wg0, wb0; - Rgb(BuildColorMatrix(1.0, 1.0), 1, 1, 1, wr0, wg0, wb0); - Rgb(BuildColorMatrix(1.0, 0.5), 1, 1, 1, r, g, b); - CHECK(r == doctest::Approx(wr0 * 0.5)); CHECK(g == doctest::Approx(wg0 * 0.5)); + 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(0.0)); + ApplyToRgb(BuildColorMatrix(0.0, -1.0, false), 1, 1, 1, r, g, b); + CHECK(r == doctest::Approx(0.0)); + CHECK(IsIdentity(BuildColorMatrix(-1.0, 2.0, true))); } -TEST_CASE("out-of-range inputs are clamped") { - double r, g, b; - Rgb(BuildColorMatrix(0.0, 0.0), 1, 1, 1, r, g, b); - CHECK(r == doctest::Approx(0.01)); // brightness floor 1% - CHECK(IsIdentity(BuildColorMatrix(-1.0, 2.0))); - Rgb(BuildColorMatrix(5.0, 1.0), 1, 1, 1, r, g, b); - CHECK(b == doctest::Approx(0.0)); // warmth caps at 1200 K +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 b68ef6b..660c353 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -508,8 +508,8 @@ TEST_CASE("colour keys: defaults, parse and clamps (#288)") { 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=1\n"); - CHECK(x.colorWarmPct == 0); CHECK(x.colorDimPct == 1); + Config x = ParseConfig("colorWarmPct=-4\ncolorDimPct=-5\n"); + CHECK(x.colorWarmPct == 0); CHECK(x.colorDimPct == 0); 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"); diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index d90ad52..b188b50 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -75,7 +75,7 @@ export const sections = [ // 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:'%' }, + { key:'colorDimPct', type:'slider', label:'Brightness', desc:'Darkens the picture, like turning down a TV. 100% is normal.', min:0, 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', From fc4340e7be1267747c3134a8b38d0c95a8011380 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:05:43 +0200 Subject: [PATCH 10/19] feat(settings): recover from a WebView2 crash and keep unsaved edits Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/config_ui/main.cpp | 179 +++++++++++++++++++++----------- src/config_ui/webview_recover.h | 44 ++++++++ tests/test_webview_recover.cpp | 33 ++++++ ui/src/Settings.svelte | 13 ++- ui/src/bridge.js | 3 + ui/tests/settings.spec.js | 15 +++ 6 files changed, 227 insertions(+), 60 deletions(-) create mode 100644 src/config_ui/webview_recover.h create mode 100644 tests/test_webview_recover.cpp diff --git a/src/config_ui/main.cpp b/src/config_ui/main.cpp index 1146d6a..ce64b92 100644 --- a/src/config_ui/main.cpp +++ b/src/config_ui/main.cpp @@ -1,3 +1,4 @@ +#include "webview_recover.h" #include #include #include @@ -28,6 +29,12 @@ using namespace Microsoft::WRL; static ComPtr g_controller; static ComPtr g_webview; +// Crash recovery (see webview_recover.h). The page's unsaved edits live here too, so they survive +// the engine dying: the page posts them on every change and gets them back after a recovery. +static std::string g_draftJson; +static bool g_recovered = false; +static const UINT WM_APP_WV_FAILED = WM_APP + 0x31; +static void CreateWebView(HWND hwnd); static HWND g_hwnd = nullptr; // Unsaved-changes guard (issue #164). The UI owns "dirty" (it knows what is staged vs saved), so it // mirrors the flag here and WM_CLOSE asks the UI to confirm instead of closing. Kept in the host @@ -229,6 +236,15 @@ static void HandleWebMessage(ICoreWebView2* wv, const std::wstring& jsonW) { out += "\"" + JsonEscape(kv.first) + "\":\"" + JsonEscape(kv.second) + "\""; } out += "}}"; wv->PostWebMessageAsJson(Widen(out).c_str()); + // After a crash recovery, hand the page back the edits it had not applied yet. It asks for + // the config first on every load, so this arrives right behind it. + if (g_recovered && wind::LooksLikeJsonObject(g_draftJson.c_str(), g_draftJson.size())) { + wv->PostWebMessageAsJson(Widen("{\"type\":\"restoreDraft\",\"values\":" + g_draftJson + "}").c_str()); + wind::Log(wind::LogLevel::Info, "config", "recovery: unsaved edits handed back to the page"); + } + g_recovered = false; + } else if (type == "draft") { + g_draftJson = JsonField(j, "json"); } else if (type == "setConfig") { std::string key = JsonField(j, "key"), value = JsonField(j, "value"); if (!key.empty()) { @@ -470,6 +486,32 @@ static LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) { if (pt.y < titleH && pt.x < rc.right - MulDiv(120, dpi, 96)) return HTCAPTION; return HTCLIENT; } + if (m == WM_APP_WV_FAILED) { + static wind::WvRecoverBudget budget; + const int kind = (int)w; + switch (wind::DecideWvRecovery(kind, budget, GetTickCount64())) { + case wind::WvRecovery::Reload: + wind::Log(wind::LogLevel::Warn, "config", "recovery: page process failed (kind=%d), reloading", kind); + g_recovered = true; + if (g_webview) g_webview->Reload(); + break; + case wind::WvRecovery::Recreate: + wind::Log(wind::LogLevel::Warn, "config", "recovery: WebView2 engine exited, recreating it"); + g_recovered = true; + if (g_controller) g_controller->Close(); + g_webview.Reset(); g_controller.Reset(); + CreateWebView(h); + break; + case wind::WvRecovery::GiveUp: + wind::Log(wind::LogLevel::Error, "config", "recovery: WebView2 keeps failing (kind=%d); giving up", kind); + MessageBoxW(h, L"Wind Settings keeps crashing (its web engine exits on start).\n\n" + L"Close it and open Settings again. If it keeps happening, an overlay tool " + L"such as RivaTuner may be interfering.", L"Wind", MB_ICONWARNING | MB_OK); + break; + default: break; + } + return 0; + } if (m == WM_SIZE && g_controller) { RECT r; GetClientRect(h, &r); g_controller->put_Bounds(r); return 0; } if (m == WM_GETMINMAXINFO) { // enforce a minimum window size (DPI-scaled) UINT dpi = GetDpiForWindow(h); if (!dpi) dpi = 96; @@ -512,65 +554,10 @@ static LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) { if (m == WM_DESTROY) { KillTimer(h, kWindWatchTimerId); PostQuitMessage(0); return 0; } return DefWindowProcW(h, m, w, l); } -int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR lpCmdLine, int) { - // Single-instance: opening Settings from the tray (or any second launch) focuses the existing - // window instead of stacking another WindConfig.exe with its own WebView2. - HANDLE mtx = CreateMutexW(nullptr, TRUE, L"WindConfig_SingleInstance"); - if (mtx && GetLastError() == ERROR_ALREADY_EXISTS) { - HWND existing = FindWindowW(L"WindConfigWnd", nullptr); - if (existing) { - if (IsIconic(existing)) ShowWindow(existing, SW_RESTORE); - SetForegroundWindow(existing); - } - CloseHandle(mtx); - return 0; - } - wind::LogInit(L"config"); - atexit(wind::LogShutdown); - wind::LogSystemSnapshot("config", ""); - - bool onboard = lpCmdLine && wcsstr(lpCmdLine, L"--onboard") != nullptr; - // Settings should never run without the magnifier, and never show the config page against a - // not-yet-set-up config. So, when launched as Settings (no --onboard): - // - NOT set up yet -> launch Wind.exe and exit. Wind sees onboarded==0 and runs the guided - // setup (re-spawning us with --onboard), so the user lands in onboarding, not the config page. - // If launching Wind.exe FAILS, show onboarding ourselves rather than the config page (never - // dead-end into the config UI against an unconfigured app). - // - set up, Wind not running -> launch Wind.exe, then show the config page. - // - set up, Wind already running -> just show the config page. - // The --onboard guard prevents a launch loop. - if (!onboard) { - auto vals = wind::ReadIniValues(ReadFileUtf8(IniPath())); - auto it = vals.find("onboarded"); - bool onboarded = (it != vals.end() && it->second == "1"); - if (!onboarded) { - if (LaunchWind()) { if (mtx) CloseHandle(mtx); return 0; } - onboard = true; // couldn't launch Wind - run onboarding in THIS window, not the config page - } else if (!WindRunning()) { - // Set up, but the magnifier isn't running: start it, then continue to the config page. - LaunchWind(); - } - } - // Per-monitor-V2 DPI awareness so WebView2 renders at native resolution (not bitmap-scaled, - // which looked low-res/blurry). Must be set before any window is created. - SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); - WNDCLASSW wc{}; wc.lpfnWndProc = WndProc; wc.hInstance = hInst; wc.lpszClassName = L"WindConfigWnd"; - wc.hIcon = LoadIconW(hInst, MAKEINTRESOURCEW(IDI_WIND)); // logo badge for taskbar/alt-tab - RegisterClassW(&wc); - HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"Wind Settings", - WS_POPUP | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_CLIPCHILDREN, - CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, nullptr, nullptr, hInst, nullptr); - SetTimer(hwnd, kWindWatchTimerId, kWindWatchPeriodMs, nullptr); - // Size to a sensible default (scaled for this monitor's DPI) and center on the work area. - UINT dpi = GetDpiForWindow(hwnd); if (!dpi) dpi = 96; - int ww = MulDiv(1040, dpi, 96), wh = MulDiv(740, dpi, 96); - RECT wa{}; SystemParametersInfoW(SPI_GETWORKAREA, 0, &wa, 0); - int wx = wa.left + ((wa.right - wa.left) - ww) / 2; - int wy = wa.top + ((wa.bottom - wa.top) - wh) / 2; - SetWindowPos(hwnd, nullptr, wx, wy, ww, wh, SWP_NOZORDER); - ShowWindow(hwnd, SW_SHOW); - g_hwnd = hwnd; - std::wstring uiDir = ExeDir() + L"\\ui\\dist"; +// Creates the WebView2 environment + controller and loads the UI. Called once at start and again +// by the crash recovery when the engine has exited (WM_APP_WV_FAILED). +static bool g_onboard = false; +static void CreateWebView(HWND hwnd) { // WebView2's user-data folder MUST be writable. The default sits next to the exe // (\WindConfig.exe.WebView2), which is fine in dev but read-only when the exe is // installed under Program Files - causing the environment to fail and the window to render @@ -584,6 +571,8 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR lpCmdLine, int) { CreateDirectoryW((std::wstring(buf) + L"\\Wind").c_str(), nullptr); CreateDirectoryW(userData.c_str(), nullptr); } + const std::wstring uiDir = ExeDir() + L"\\ui\\dist"; + const bool onboard = g_onboard; CreateCoreWebView2EnvironmentWithOptions(nullptr, userData.c_str(), nullptr, Callback( [hwnd, uiDir, onboard](HRESULT hr, ICoreWebView2Environment* env) -> HRESULT { @@ -620,6 +609,16 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR lpCmdLine, int) { wv3->SetVirtualHostNameToFolderMapping(L"wind.config", uiDir.c_str(), COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW); } EventRegistrationToken tok; + // A dead engine or page is recovered on the UI thread (WndProc), never from + // inside this callback: the recreate path releases the objects raising it. + g_webview->add_ProcessFailed( + Callback( + [](ICoreWebView2*, ICoreWebView2ProcessFailedEventArgs* args) -> HRESULT { + COREWEBVIEW2_PROCESS_FAILED_KIND k = COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED; + if (args) args->get_ProcessFailedKind(&k); + PostMessageW(g_hwnd, WM_APP_WV_FAILED, (WPARAM)k, 0); + return S_OK; + }).Get(), &tok); g_webview->add_WebMessageReceived( Callback( [](ICoreWebView2* wv, ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT { @@ -634,6 +633,68 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR lpCmdLine, int) { }).Get()); return S_OK; }).Get()); +} + +int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR lpCmdLine, int) { + // Single-instance: opening Settings from the tray (or any second launch) focuses the existing + // window instead of stacking another WindConfig.exe with its own WebView2. + HANDLE mtx = CreateMutexW(nullptr, TRUE, L"WindConfig_SingleInstance"); + if (mtx && GetLastError() == ERROR_ALREADY_EXISTS) { + HWND existing = FindWindowW(L"WindConfigWnd", nullptr); + if (existing) { + if (IsIconic(existing)) ShowWindow(existing, SW_RESTORE); + SetForegroundWindow(existing); + } + CloseHandle(mtx); + return 0; + } + wind::LogInit(L"config"); + atexit(wind::LogShutdown); + wind::LogSystemSnapshot("config", ""); + + bool onboard = lpCmdLine && wcsstr(lpCmdLine, L"--onboard") != nullptr; + // Settings should never run without the magnifier, and never show the config page against a + // not-yet-set-up config. So, when launched as Settings (no --onboard): + // - NOT set up yet -> launch Wind.exe and exit. Wind sees onboarded==0 and runs the guided + // setup (re-spawning us with --onboard), so the user lands in onboarding, not the config page. + // If launching Wind.exe FAILS, show onboarding ourselves rather than the config page (never + // dead-end into the config UI against an unconfigured app). + // - set up, Wind not running -> launch Wind.exe, then show the config page. + // - set up, Wind already running -> just show the config page. + // The --onboard guard prevents a launch loop. + if (!onboard) { + auto vals = wind::ReadIniValues(ReadFileUtf8(IniPath())); + auto it = vals.find("onboarded"); + bool onboarded = (it != vals.end() && it->second == "1"); + if (!onboarded) { + if (LaunchWind()) { if (mtx) CloseHandle(mtx); return 0; } + onboard = true; // couldn't launch Wind - run onboarding in THIS window, not the config page + } else if (!WindRunning()) { + // Set up, but the magnifier isn't running: start it, then continue to the config page. + LaunchWind(); + } + } + // Per-monitor-V2 DPI awareness so WebView2 renders at native resolution (not bitmap-scaled, + // which looked low-res/blurry). Must be set before any window is created. + SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + WNDCLASSW wc{}; wc.lpfnWndProc = WndProc; wc.hInstance = hInst; wc.lpszClassName = L"WindConfigWnd"; + wc.hIcon = LoadIconW(hInst, MAKEINTRESOURCEW(IDI_WIND)); // logo badge for taskbar/alt-tab + RegisterClassW(&wc); + HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"Wind Settings", + WS_POPUP | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_CLIPCHILDREN, + CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, nullptr, nullptr, hInst, nullptr); + SetTimer(hwnd, kWindWatchTimerId, kWindWatchPeriodMs, nullptr); + // Size to a sensible default (scaled for this monitor's DPI) and center on the work area. + UINT dpi = GetDpiForWindow(hwnd); if (!dpi) dpi = 96; + int ww = MulDiv(1040, dpi, 96), wh = MulDiv(740, dpi, 96); + RECT wa{}; SystemParametersInfoW(SPI_GETWORKAREA, 0, &wa, 0); + int wx = wa.left + ((wa.right - wa.left) - ww) / 2; + int wy = wa.top + ((wa.bottom - wa.top) - wh) / 2; + SetWindowPos(hwnd, nullptr, wx, wy, ww, wh, SWP_NOZORDER); + ShowWindow(hwnd, SW_SHOW); + g_hwnd = hwnd; + g_onboard = onboard; + CreateWebView(hwnd); MSG msg; while (GetMessageW(&msg, nullptr, 0, 0)) { TranslateMessage(&msg); DispatchMessageW(&msg); } wind::LogShutdown(); return 0; diff --git a/src/config_ui/webview_recover.h b/src/config_ui/webview_recover.h new file mode 100644 index 0000000..1b7e436 --- /dev/null +++ b/src/config_ui/webview_recover.h @@ -0,0 +1,44 @@ +#pragma once +// What Settings does when a WebView2 process dies (field 2026-09-29: RTSS's global hook crashes the +// WebView2 browser process at start-up, a use-after-unload of dxgi.dll inside RTSSHooks64, and the +// window went black). Pure; tests/test_webview_recover.cpp. +// +// The kinds are COREWEBVIEW2_PROCESS_FAILED_KIND values, kept as ints so this header needs no SDK: +// 0 BROWSER_PROCESS_EXITED the whole engine is gone: the controller is dead, recreate it +// 1 RENDER_PROCESS_EXITED the page's process died: reload +// 2 RENDER_PROCESS_UNRESPONSIVE +// others (GPU, utility, frame renderer): WebView2 restarts those itself. +namespace wind { + +enum class WvRecovery { None, Reload, Recreate, GiveUp }; + +// At most kWvMaxRecoveries in any kWvWindowMs: an engine that dies on every start must not loop. +inline constexpr int kWvMaxRecoveries = 3; +inline constexpr unsigned long long kWvWindowMs = 60000; + +struct WvRecoverBudget { unsigned long long at[kWvMaxRecoveries] = {}; int n = 0; }; + +inline WvRecovery DecideWvRecovery(int kind, WvRecoverBudget& b, unsigned long long nowMs) { + WvRecovery r = WvRecovery::None; + if (kind == 0) r = WvRecovery::Recreate; + else if (kind == 1 || kind == 2) r = WvRecovery::Reload; + if (r == WvRecovery::None) return r; + // Forget recoveries older than the window. + int keep = 0; + for (int i = 0; i < b.n; ++i) + if (nowMs - b.at[i] < kWvWindowMs) b.at[keep++] = b.at[i]; + b.n = keep; + if (b.n >= kWvMaxRecoveries) return WvRecovery::GiveUp; + b.at[b.n++] = nowMs; + return r; +} + +// The page's unsaved edits travel as a JSON object of string values; only something shaped like one +// is ever echoed back into the page. +inline bool LooksLikeJsonObject(const char* s, unsigned long long len) { + unsigned long long a = 0, z = len; + while (a < z && (s[a] == ' ' || s[a] == '\n' || s[a] == '\r' || s[a] == '\t')) ++a; + while (z > a && (s[z - 1] == ' ' || s[z - 1] == '\n' || s[z - 1] == '\r' || s[z - 1] == '\t')) --z; + return z - a >= 2 && s[a] == '{' && s[z - 1] == '}'; +} +} // namespace wind diff --git a/tests/test_webview_recover.cpp b/tests/test_webview_recover.cpp new file mode 100644 index 0000000..d697f31 --- /dev/null +++ b/tests/test_webview_recover.cpp @@ -0,0 +1,33 @@ +#include "doctest.h" +#include "../src/config_ui/webview_recover.h" +#include +using namespace wind; + +TEST_CASE("a dead browser engine is recreated, a dead page reloaded, the rest left to WebView2") { + WvRecoverBudget b; + CHECK(DecideWvRecovery(0, b, 1000) == WvRecovery::Recreate); + CHECK(DecideWvRecovery(1, b, 2000) == WvRecovery::Reload); + CHECK(DecideWvRecovery(2, b, 3000) == WvRecovery::Reload); + WvRecoverBudget c; + CHECK(DecideWvRecovery(3, c, 1000) == WvRecovery::None); // GPU process: WebView2 restarts it + CHECK(DecideWvRecovery(5, c, 1000) == WvRecovery::None); + CHECK(c.n == 0); // not counted against the budget +} +TEST_CASE("an engine that dies on every start gives up after three in a minute") { + WvRecoverBudget b; + CHECK(DecideWvRecovery(0, b, 0) == WvRecovery::Recreate); + CHECK(DecideWvRecovery(0, b, 1000) == WvRecovery::Recreate); + CHECK(DecideWvRecovery(0, b, 2000) == WvRecovery::Recreate); + CHECK(DecideWvRecovery(0, b, 3000) == WvRecovery::GiveUp); + CHECK(DecideWvRecovery(0, b, 59999) == WvRecovery::GiveUp); + CHECK(DecideWvRecovery(0, b, 60001) == WvRecovery::Recreate); // the first one aged out +} +TEST_CASE("only a JSON object is echoed back as the page's draft") { + auto ok = [](const char* s) { return LooksLikeJsonObject(s, std::strlen(s)); }; + CHECK(ok("{\"colorWarmPct\":\"80\"}")); + CHECK(ok(" {} ")); + CHECK_FALSE(ok("")); + CHECK_FALSE(ok("null")); + CHECK_FALSE(ok("[1]")); + CHECK_FALSE(ok("{")); +} diff --git a/ui/src/Settings.svelte b/ui/src/Settings.svelte index e61266c..b8135d6 100644 --- a/ui/src/Settings.svelte +++ b/ui/src/Settings.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { sections } from './settings-schema.js'; import { getConfig, setConfig, openIni, exportDiagnostics, windowControl, onMessage, - getMpoState, setMpoDisabled, rebootNow, setDirty, + getMpoState, setMpoDisabled, rebootNow, setDirty, postDraft, listProfiles, switchProfile, createProfile, renameProfile, duplicateProfile, deleteProfile } from './bridge.js'; import ProfileMenu from './lib/ProfileMenu.svelte'; @@ -255,6 +255,17 @@ function toggleTheme() { theme = nextTheme(theme); setTheme(theme); } $: dirty = Object.keys(values).some(k => String(values[k]) !== String(saved[k])) || mpoDirty; $: setDirty(dirty); // keep the host's WM_CLOSE guard in step with the staged state + // Crash recovery: the host keeps the unapplied edits and returns them after recreating the engine. + $: postDraft(Object.fromEntries(Object.keys(values).filter(k => String(values[k]) !== String(saved[k])) + .map(k => [k, values[k]]))); + onMessage(m => { + if (m && m.type === 'restoreDraft' && m.values && typeof m.values === 'object') { + const back = {}; + for (const k of Object.keys(m.values)) if (k in values) back[k] = String(m.values[k]); + values = { ...values, ...back }; + if (Object.keys(back).length) announce('Settings recovered after a crash; your unsaved changes are kept.'); + } + }); // Advanced rows (schema `advanced:true`) are hidden unless "Show advanced settings" is on. Driven // by the live `values` so toggling it reveals/hides rows immediately (before Apply). $: advancedOn = Number(values.showAdvanced) === 1; diff --git a/ui/src/bridge.js b/ui/src/bridge.js index 9b3946f..395a33b 100644 --- a/ui/src/bridge.js +++ b/ui/src/bridge.js @@ -28,6 +28,9 @@ export function windowControl(action, force = false) { // Mirror the staged/unsaved state to the host so its WM_CLOSE can put up the guard for Alt+F4 and // the system menu too, not just our own title-bar button. export function setDirty(v) { post({ type: 'dirty', value: v ? '1' : '0' }); } +// The staged (unapplied) edits, mirrored to the host on every change. If WebView2's engine dies, +// the host recreates it and hands them back ('restoreDraft'), so a crash costs no edits. +export function postDraft(changed) { post({ type: 'draft', json: JSON.stringify(changed || {}) }); } // MPO (Multi-Plane Overlay) lives in HKLM, so reading is free but writing needs elevation. // getMpoState is a plain read; setMpoDisabled raises a UAC prompt in the host and resolves with the // RE-READ state, so a cancelled prompt reverts the row rather than showing a change that never was. diff --git a/ui/tests/settings.spec.js b/ui/tests/settings.spec.js index 2dc3847..dd5b14f 100644 --- a/ui/tests/settings.spec.js +++ b/ui/tests/settings.spec.js @@ -19,6 +19,7 @@ test.beforeEach(async ({ page }) => { listeners.forEach(fn => fn({ data: { type: 'config', values: { zoomInSpeed: '1.2', smoothZoom: '0', uiTheme: 'auto', showAdvanced: '1', model: 'render', zoomInButton: '2', zoomInVk: '33', zoomOutButton: '1', zoomOutVk: '34', cursorLockVk: '113', txSamplingMode: window.__cfgSampling !== undefined ? window.__cfgSampling : '0' } } })); if (msg.type === 'setConfig') window.__sets.push(msg); if (msg.type === 'openRepo') window.__sets.push(msg); + if (msg.type === 'draft') window.__sets.push(msg); // crash-recovery mirror of unapplied edits // MPO lives in the registry, not the ini. __mpoDisabled drives what the "registry" reports; // __mpoOk drives whether the elevated write is accepted (false = UAC dismissed). if (msg.type === 'mpoState') @@ -543,3 +544,17 @@ test('Colour section: only the warmth and brightness sliders, neutral by default await expect(page.getByText(gone, { exact: true })).toHaveCount(0); } }); + +test('unapplied edits are mirrored to the host and restored after a crash recovery', async ({ page }) => { + await page.goto('/'); + const row = page.getByText('Brightness', { exact: true }).locator('xpath=../..'); + await row.locator('input[type=range]').fill('35'); + await expect.poll(async () => page.evaluate(() => { + const d = window.__sets.filter(m => m.type === 'draft').at(-1); + return d ? JSON.parse(d.json).colorDimPct : null; + })).toBe('35'); + // The host recreated the engine: a fresh page load gets its config, then the draft back. + await page.evaluate(() => window.__hostSend({ type: 'restoreDraft', values: { colorDimPct: '40', notARealKey: '1' } })); + await expect(row).toContainText('40'); + await expect(page.getByRole('button', { name: 'Apply' })).toBeEnabled(); +}); From 6134b6530a1ccf8ff4faa6bcc8e020626d2f617b Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:09:03 +0200 Subject: [PATCH 11/19] fix(settings): a fresh page can no longer overwrite the draft it is about to get back Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/config_ui/main.cpp | 4 +++- ui/src/Settings.svelte | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/config_ui/main.cpp b/src/config_ui/main.cpp index ce64b92..c7ecd76 100644 --- a/src/config_ui/main.cpp +++ b/src/config_ui/main.cpp @@ -244,7 +244,9 @@ static void HandleWebMessage(ICoreWebView2* wv, const std::wstring& jsonW) { } g_recovered = false; } else if (type == "draft") { - g_draftJson = JsonField(j, "json"); + // While a recovery is pending the draft held here is the one to hand back; nothing the new + // page reports before its config arrives may replace it. + if (!g_recovered) g_draftJson = JsonField(j, "json"); } else if (type == "setConfig") { std::string key = JsonField(j, "key"), value = JsonField(j, "value"); if (!key.empty()) { diff --git a/ui/src/Settings.svelte b/ui/src/Settings.svelte index b8135d6..a960b3b 100644 --- a/ui/src/Settings.svelte +++ b/ui/src/Settings.svelte @@ -47,6 +47,7 @@ } onMount(async () => { await loadValues(); + loaded = true; profiles = await listProfiles(); // MPO lives in the registry, not the ini, so it is fetched separately and staged separately. const s = await getMpoState(); @@ -256,8 +257,11 @@ $: dirty = Object.keys(values).some(k => String(values[k]) !== String(saved[k])) || mpoDirty; $: setDirty(dirty); // keep the host's WM_CLOSE guard in step with the staged state // Crash recovery: the host keeps the unapplied edits and returns them after recreating the engine. - $: postDraft(Object.fromEntries(Object.keys(values).filter(k => String(values[k]) !== String(saved[k])) - .map(k => [k, values[k]]))); + // Only once the settings have loaded: a fresh page would otherwise report an empty draft before the + // host has handed the old one back, and overwrite it (measured in the first crash test). + let loaded = false; + $: if (loaded) postDraft(Object.fromEntries(Object.keys(values).filter(k => String(values[k]) !== String(saved[k])) + .map(k => [k, values[k]]))); onMessage(m => { if (m && m.type === 'restoreDraft' && m.values && typeof m.values === 'object') { const back = {}; From be13d3baba07091bfcd0a3a8b67e600d7152d61b Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:12:11 +0200 Subject: [PATCH 12/19] docs(color): HDR linear-light finding, Night light scale, RTSS crash and recovery Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- docs/COLOUR-FILTER-FINDINGS.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/COLOUR-FILTER-FINDINGS.md b/docs/COLOUR-FILTER-FINDINGS.md index 087bf8a..6842766 100644 --- a/docs/COLOUR-FILTER-FINDINGS.md +++ b/docs/COLOUR-FILTER-FINDINGS.md @@ -51,3 +51,36 @@ the render engine's shader (zoomed, render engine), not at 1x or on the transfor 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 not tinted or dimmed**: Windows draws it on a hardware cursor plane after + composition, which the DWM colour effect never touches (Night light, in the display pipeline, does + reach it). While zoomed Wind draws the cursor itself, so it is filtered there. +- 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. From daa950894b37ff48f3eb3744aebed4716828b585 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:15:02 +0200 Subject: [PATCH 13/19] fix(color): brightness floor back to 1% (0 was completely black) (#288) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- .../specs/2026-09-29-colour-filters-design.md | 4 ++-- src/color_matrix.h | 9 +++++---- src/config.cpp | 4 ++-- src/config.h | 2 +- tests/test_color_matrix.cpp | 7 ++++--- tests/test_config.cpp | 2 +- ui/src/settings-schema.js | 2 +- 7 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-09-29-colour-filters-design.md b/docs/superpowers/specs/2026-09-29-colour-filters-design.md index 2ad160d..3a496df 100644 --- a/docs/superpowers/specs/2026-09-29-colour-filters-design.md +++ b/docs/superpowers/specs/2026-09-29-colour-filters-design.md @@ -115,11 +115,11 @@ owner may prefer a patch bump), release on merge. 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`, 0-100, default 100). Both neutral = no + 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 request; not 0, a black screen looks like a dead display). +- 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/src/color_matrix.h b/src/color_matrix.h index 16342df..09639a8 100644 --- a/src/color_matrix.h +++ b/src/color_matrix.h @@ -44,9 +44,10 @@ inline bool SameMatrix(const ColorMatrix& a, const ColorMatrix& b) { return true; } -// Lowest brightness the dim control reaches: 0, black (owner request 2026-09-29: 1% was still -// visible on an OLED). Quitting Wind (Ctrl+Alt+Q) always clears the effect; so does a crash. -inline constexpr double kMinDim01 = 0.0; +// 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 @@ -92,7 +93,7 @@ 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: 0..1 (1 = no dim, 0 = black). Out-of-range values clamp. +// 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 diff --git a/src/config.cpp b/src/config.cpp index abcf94f..35580f2 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -253,7 +253,7 @@ Config ParseConfig(const std::string& text) { 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, 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; @@ -444,7 +444,7 @@ 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 (0-100, 100 = normal, 0 = black)\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" diff --git a/src/config.h b/src/config.h index 9d9c23c..22acdae 100644 --- a/src/config.h +++ b/src/config.h @@ -498,7 +498,7 @@ struct Config { // 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 0..100 (100 = no dim, 0 = black; quitting Wind clears it) + 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/tests/test_color_matrix.cpp b/tests/test_color_matrix.cpp index 33b955a..5fdffb9 100644 --- a/tests/test_color_matrix.cpp +++ b/tests/test_color_matrix.cpp @@ -44,16 +44,17 @@ TEST_CASE("HDR (linear) and SDR (encoded) warmth are the same colour, not the sa pg = g; } } -TEST_CASE("brightness: encoded in SDR, decoded to linear in HDR, down to black") { +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(0.0)); + 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.0)); + CHECK(r == doctest::Approx(0.01)); // SDR: 1% CHECK(IsIdentity(BuildColorMatrix(-1.0, 2.0, true))); } TEST_CASE("sRGB encode and decode round-trip") { diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 660c353..d502f4c 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -509,7 +509,7 @@ TEST_CASE("colour keys: defaults, parse and clamps (#288)") { 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 == 0); + 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"); diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index b188b50..d90ad52 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -75,7 +75,7 @@ export const sections = [ // 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:0, max:100, step:1, def:100, 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', From 01db99a83069094bdb639408003adec4e8a5b3b6 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:23:32 +0200 Subject: [PATCH 14/19] feat(color): tint the system pointers at 1x while warmth/brightness is on (#288) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- .../plans/2026-09-30-cursor-tint.md | 14 ++ .../specs/2026-09-30-cursor-tint-design.md | 47 +++++++ src/cursor_tint.cpp | 131 ++++++++++++++++++ src/cursor_tint.h | 35 +++++ src/cursor_tint_pixels.h | 70 ++++++++++ src/main.cpp | 34 +++++ tests/test_cursor_tint.cpp | 53 +++++++ 7 files changed, 384 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-30-cursor-tint.md create mode 100644 docs/superpowers/specs/2026-09-30-cursor-tint-design.md create mode 100644 src/cursor_tint.cpp create mode 100644 src/cursor_tint.h create mode 100644 src/cursor_tint_pixels.h create mode 100644 tests/test_cursor_tint.cpp 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-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/cursor_tint.cpp b/src/cursor_tint.cpp new file mode 100644 index 0000000..8a291e7 --- /dev/null +++ b/src/cursor_tint.cpp @@ -0,0 +1,131 @@ +#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; +} + +// 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(); 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; + current_ = m; + wind::Log(wind::LogLevel::Info, "color", "pointer tint applied (%d pointers, %.1f ms)", done, MsSince(t0)); +} + +void CursorTint::restore() { + if (!applied_) return; + LARGE_INTEGER t0; QueryPerformanceCounter(&t0); + 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 (%.1f ms)", MsSince(t0)); +} + +} // namespace wind diff --git a/src/cursor_tint.h b/src/cursor_tint.h new file mode 100644 index 0000000..808b0c4 --- /dev/null +++ b/src/cursor_tint.h @@ -0,0 +1,35 @@ +#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 pristine pointers back with direct SetSystemCursor calls (no scheme reload). + void restore(); + // 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; + 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/main.cpp b/src/main.cpp index 5d28820..230cd34 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -28,6 +28,7 @@ #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" @@ -816,6 +817,28 @@ static void UpdateColorFilter(TickState& t, bool zoomedNow, bool renderSession, 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(); 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(); + else g_tint.apply(enc); +} + 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); @@ -1151,6 +1174,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(); 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 @@ -2165,6 +2192,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 @@ -2173,6 +2201,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; @@ -2346,6 +2375,9 @@ 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(wind::GetHdrEnabled(nullptr)); // 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; @@ -2687,6 +2719,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { g_input.setBallistics(ReadMouseBallistics()); Tray::Add(hwnd, hInst); + 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(); @@ -3076,6 +3109,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { 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(); // pristine pointers (the exit scheme reload below 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/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); +} From 99ccb09ec1562e9338192c28f14b088d25581927 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:26:15 +0200 Subject: [PATCH 15/19] fix(color): idle restores reload the real pointer scheme; only zoom-in swaps copies (#288) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/cursor_tint.cpp | 19 ++++++++++++------- src/cursor_tint.h | 9 +++++++-- src/main.cpp | 8 ++++---- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/cursor_tint.cpp b/src/cursor_tint.cpp index 8a291e7..8880eb8 100644 --- a/src/cursor_tint.cpp +++ b/src/cursor_tint.cpp @@ -101,7 +101,7 @@ static HCURSOR BuildTinted(HCURSOR src, const ColorMatrix& m) { } void CursorTint::apply(const ColorMatrix& m) { - if (IsIdentity(m)) { restore(); return; } + if (IsIdentity(m)) { restore(true); return; } if (applied_ && SameMatrix(m, current_)) return; LARGE_INTEGER t0; QueryPerformanceCounter(&t0); int done = 0; @@ -116,16 +116,21 @@ void CursorTint::apply(const ColorMatrix& m) { wind::Log(wind::LogLevel::Info, "color", "pointer tint applied (%d pointers, %.1f ms)", done, MsSince(t0)); } -void CursorTint::restore() { +void CursorTint::restore(bool reloadScheme) { if (!applied_) return; LARGE_INTEGER t0; QueryPerformanceCounter(&t0); - 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); + if (reloadScheme) { + SystemParametersInfoW(SPI_SETCURSORS, 0, nullptr, 0); // no broadcast: nothing else needs telling + } 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 (%.1f ms)", MsSince(t0)); + 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 index 808b0c4..ce45a03 100644 --- a/src/cursor_tint.h +++ b/src/cursor_tint.h @@ -16,8 +16,13 @@ class CursorTint { // 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 pristine pointers back with direct SetSystemCursor calls (no scheme reload). - void restore(); + // 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; } diff --git a/src/main.cpp b/src/main.cpp index 230cd34..90966d6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -826,7 +826,7 @@ 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(); return; } + 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(); @@ -835,7 +835,7 @@ static void UpdateCursorTint(TickState& t) { const HWND fg = GetForegroundWindow(); fsApp = ForegroundCoversMonitor(t.mon) && !IsShellDesktopFg(fg); } - if (fsApp) g_tint.restore(); + if (fsApp) g_tint.restore(true); else g_tint.apply(enc); } @@ -1177,7 +1177,7 @@ static void RunTick(TickState& t) { // 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(); + 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 @@ -3109,7 +3109,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { 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(); // pristine pointers (the exit scheme reload below heals them too) + 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. From b7276fb12036f59c2f3cb07c7ed2e96c5ece5696 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:28:09 +0200 Subject: [PATCH 16/19] docs(color): the tinted pointer, verified (#288) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- docs/COLOUR-FILTER-FINDINGS.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/COLOUR-FILTER-FINDINGS.md b/docs/COLOUR-FILTER-FINDINGS.md index 6842766..c848706 100644 --- a/docs/COLOUR-FILTER-FINDINGS.md +++ b/docs/COLOUR-FILTER-FINDINGS.md @@ -68,9 +68,15 @@ the matrix per colour and WCAG contrast in linear light). 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 not tinted or dimmed**: Windows draws it on a hardware cursor plane after - composition, which the DWM colour effect never touches (Night light, in the display pipeline, does - reach it). While zoomed Wind draws the cursor itself, so it is filtered there. +- **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. From 9ca59c1ac177cf5311160ededb63a7f133ac3464 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:35:38 +0200 Subject: [PATCH 17/19] docs(color): how f.lux, Iris and Night light apply their tint (research) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- docs/COLOUR-FILTER-FINDINGS.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/COLOUR-FILTER-FINDINGS.md b/docs/COLOUR-FILTER-FINDINGS.md index c848706..0599a76 100644 --- a/docs/COLOUR-FILTER-FINDINGS.md +++ b/docs/COLOUR-FILTER-FINDINGS.md @@ -90,3 +90,31 @@ creates that watcher unconditionally (`network_change_notifier_win.cc`), so ther 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. From 0ad06878b65d5464b7bab4c62fa18dc4fa33578d Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:34:37 +0200 Subject: [PATCH 18/19] fix(color): review fixes: render pointer/outline filtered, colour follows the visible engine, HDR re-read, tint scheme flag (#288) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- CLAUDE.md | 6 ++++++ docs/COLOUR-FILTER-FINDINGS.md | 16 ++++++++++++++++ src/cursor_tint.cpp | 5 ++++- src/cursor_tint.h | 5 +++++ src/main.cpp | 34 +++++++++++++++++++++++++++++++--- src/render_engine.cpp | 23 +++++++++++++++++++---- src/render_model.cpp | 1 + src/render_model.h | 5 +++++ src/render_shaders.h | 13 +++++++++++-- 9 files changed, 98 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d68c0b1..0e830c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -199,6 +199,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 index 0599a76..d6e01cd 100644 --- a/docs/COLOUR-FILTER-FINDINGS.md +++ b/docs/COLOUR-FILTER-FINDINGS.md @@ -118,3 +118,19 @@ handed back. Verified by killing the engine with a staged change: back in ~0.3 s 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/src/cursor_tint.cpp b/src/cursor_tint.cpp index 8880eb8..92202b3 100644 --- a/src/cursor_tint.cpp +++ b/src/cursor_tint.cpp @@ -36,6 +36,7 @@ void CursorTint::capture() { } } 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. @@ -112,15 +113,17 @@ void CursorTint::apply(const ColorMatrix& m) { 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 (!applied_) return; + 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; diff --git a/src/cursor_tint.h b/src/cursor_tint.h index ce45a03..aa1e9e2 100644 --- a/src/cursor_tint.h +++ b/src/cursor_tint.h @@ -33,6 +33,11 @@ class CursorTint { 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(); }; diff --git a/src/main.cpp b/src/main.cpp index 90966d6..d8ab94c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -804,10 +804,31 @@ static wind::ColorFilterController g_color; // 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); @@ -1804,6 +1825,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); @@ -1931,7 +1953,7 @@ static void RunTick(TickState& t) { } ex.suppressTransformWrite = hookWrite; ex.realPointer = panel; - UpdateColorFilter(t, lvl > 1.0, dynamic_cast(t.model) != nullptr, &ex); + 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). @@ -2096,6 +2118,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 @@ -2174,6 +2199,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) @@ -2374,7 +2402,7 @@ 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(wind::GetHdrEnabled(nullptr)); // colour space (#288); falls through + 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(); @@ -2636,7 +2664,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(wind::GetHdrEnabled(nullptr)); // colour filter space (#288), refreshed on WM_DISPLAYCHANGE + 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); diff --git a/src/render_engine.cpp b/src/render_engine.cpp index 801979d..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; } @@ -1152,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))) { @@ -1181,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_model.cpp b/src/render_model.cpp index 005b9a0..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 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 d961dae..d71d864 100644 --- a/src/render_shaders.h +++ b/src/render_shaders.h @@ -71,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) @@ -83,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 From 2f5c1962f38ac85859ce4e8a640294d8fb22f1fb Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:35:42 +0200 Subject: [PATCH 19/19] fix(settings): recovery review fixes: closable after give-up, slow pages left alone, no onboarding re-entry, controller failure recovered Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/config_ui/main.cpp | 34 +++++++++++++++++++++++++++++---- src/config_ui/webview_recover.h | 6 ++++-- tests/test_webview_recover.cpp | 2 +- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/config_ui/main.cpp b/src/config_ui/main.cpp index c7ecd76..805ab53 100644 --- a/src/config_ui/main.cpp +++ b/src/config_ui/main.cpp @@ -33,6 +33,7 @@ static ComPtr g_webview; // the engine dying: the page posts them on every change and gets them back after a recovery. static std::string g_draftJson; static bool g_recovered = false; +static bool g_onboard = false; // launched with --onboard; cleared once onboarding is done (ini) static const UINT WM_APP_WV_FAILED = WM_APP + 0x31; static void CreateWebView(HWND hwnd); static HWND g_hwnd = nullptr; @@ -490,12 +491,24 @@ static LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) { } if (m == WM_APP_WV_FAILED) { static wind::WvRecoverBudget budget; + static bool gaveUp = false; const int kind = (int)w; + // Onboarding finishing is an in-page switch, so the URL still says ?mode=onboard: a recovery + // would otherwise re-run the guided setup, whose X quits Wind (review 2026-09-30). + const bool wasOnboard = g_onboard; + if (g_onboard) { + auto v = wind::ReadIniValues(ReadFileUtf8(IniPath())); + auto it = v.find("onboarded"); + if (it != v.end() && it->second == "1") g_onboard = false; + } switch (wind::DecideWvRecovery(kind, budget, GetTickCount64())) { case wind::WvRecovery::Reload: wind::Log(wind::LogLevel::Warn, "config", "recovery: page process failed (kind=%d), reloading", kind); g_recovered = true; - if (g_webview) g_webview->Reload(); + if (g_webview) { + if (wasOnboard && !g_onboard) g_webview->Navigate(L"https://wind.config/index.html"); + else g_webview->Reload(); + } break; case wind::WvRecovery::Recreate: wind::Log(wind::LogLevel::Warn, "config", "recovery: WebView2 engine exited, recreating it"); @@ -505,6 +518,12 @@ static LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) { CreateWebView(h); break; case wind::WvRecovery::GiveUp: + // The page is gone for good: the unsaved-changes guard would post its question to a dead + // page and the window could never close (review 2026-09-30). One message, not one per + // queued failure event. + g_forceClose = true; g_dirty = false; + if (gaveUp) break; + gaveUp = true; wind::Log(wind::LogLevel::Error, "config", "recovery: WebView2 keeps failing (kind=%d); giving up", kind); MessageBoxW(h, L"Wind Settings keeps crashing (its web engine exits on start).\n\n" L"Close it and open Settings again. If it keeps happening, an overlay tool " @@ -558,7 +577,6 @@ static LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) { } // Creates the WebView2 environment + controller and loads the UI. Called once at start and again // by the crash recovery when the engine has exited (WM_APP_WV_FAILED). -static bool g_onboard = false; static void CreateWebView(HWND hwnd) { // WebView2's user-data folder MUST be writable. The default sits next to the exe // (\WindConfig.exe.WebView2), which is fine in dev but read-only when the exe is @@ -595,8 +613,16 @@ static void CreateWebView(HWND hwnd) { } env->CreateCoreWebView2Controller(hwnd, Callback( - [hwnd, uiDir, onboard](HRESULT, ICoreWebView2Controller* controller) -> HRESULT { - if (!controller) return S_OK; + [hwnd, uiDir, onboard](HRESULT chr, ICoreWebView2Controller* controller) -> HRESULT { + if (FAILED(chr) || !controller) { + // Silent before (review 2026-09-30): log it and go through the same + // budgeted recreate as an engine crash, so a transient failure heals and a + // persistent one ends in the give-up message instead of a blank window. + wind::Log(wind::LogLevel::Error, "config", "WebView2 controller creation failed hr=0x%08lX", + (unsigned long)chr); + PostMessageW(hwnd, WM_APP_WV_FAILED, 0, 0); + return S_OK; + } g_controller = controller; g_controller->get_CoreWebView2(&g_webview); { ComPtr s0; diff --git a/src/config_ui/webview_recover.h b/src/config_ui/webview_recover.h index 1b7e436..d0abbd5 100644 --- a/src/config_ui/webview_recover.h +++ b/src/config_ui/webview_recover.h @@ -6,7 +6,9 @@ // The kinds are COREWEBVIEW2_PROCESS_FAILED_KIND values, kept as ints so this header needs no SDK: // 0 BROWSER_PROCESS_EXITED the whole engine is gone: the controller is dead, recreate it // 1 RENDER_PROCESS_EXITED the page's process died: reload -// 2 RENDER_PROCESS_UNRESPONSIVE +// 2 RENDER_PROCESS_UNRESPONSIVE: NOT recovered. A slow page (heavy system load) is not a dead +// one, and reloading it would throw away its state (review 2026-09-30); WebView2 keeps +// raising the event while it stays hung, and a page that really dies raises kind 1. // others (GPU, utility, frame renderer): WebView2 restarts those itself. namespace wind { @@ -21,7 +23,7 @@ struct WvRecoverBudget { unsigned long long at[kWvMaxRecoveries] = {}; int n = 0 inline WvRecovery DecideWvRecovery(int kind, WvRecoverBudget& b, unsigned long long nowMs) { WvRecovery r = WvRecovery::None; if (kind == 0) r = WvRecovery::Recreate; - else if (kind == 1 || kind == 2) r = WvRecovery::Reload; + else if (kind == 1) r = WvRecovery::Reload; if (r == WvRecovery::None) return r; // Forget recoveries older than the window. int keep = 0; diff --git a/tests/test_webview_recover.cpp b/tests/test_webview_recover.cpp index d697f31..9e7a252 100644 --- a/tests/test_webview_recover.cpp +++ b/tests/test_webview_recover.cpp @@ -7,7 +7,7 @@ TEST_CASE("a dead browser engine is recreated, a dead page reloaded, the rest le WvRecoverBudget b; CHECK(DecideWvRecovery(0, b, 1000) == WvRecovery::Recreate); CHECK(DecideWvRecovery(1, b, 2000) == WvRecovery::Reload); - CHECK(DecideWvRecovery(2, b, 3000) == WvRecovery::Reload); + CHECK(DecideWvRecovery(2, b, 3000) == WvRecovery::None); // merely slow: left alone, not counted WvRecoverBudget c; CHECK(DecideWvRecovery(3, c, 1000) == WvRecovery::None); // GPU process: WebView2 restarts it CHECK(DecideWvRecovery(5, c, 1000) == WvRecovery::None);