diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 5f42042e..d428a0c3 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -240,6 +240,15 @@ DWM magnifies with nearest neighbour unless something calls resulting shift per level. While smooth, the applied level snaps to the nearest level predicting under 1 px (never backwards in a ramp; held once the zoom settles). Measured standalone at 3840x2160: 10-25x jitter 11 px -> 0.7 px p95, worst jump 42 px -> 2 px; 2-10x 4.7 px -> 0.8 px. +- The ladder's cost is uneven speed: above ~6x clean levels are ~0.9 % apart while a tick moves ~3 %, + so single ticks run 15-40 % fast or slow (measured 2026-10-10: tick-to-tick rate change 8-21 % + median above 6x, 1-5 % with the ladder off or nearest). Above 12x, where the tolerance relaxes, a + slow held zoom shows small image jumps (predicted 3.6-4.2 px p95 at 12-20x). Capped, rate-keeping + and stricter snaps were all simulated against the trace and the ladder's own model: each traded + the shake for uneven speed or lurches, so the ladder stays as it is. +- No release glide with the high resolution cursor (#427): the ladder could only cut a glide short + or let its slow tail cross rounding steps (which shook the image and showed it doubled, closed + #426), so `zoomEaseOutMs` is not applied while `txSamplingMode=1`; the zoom stops on release. - Nearest while the level moves and smooth at rest was tried: steady, but the switch from pixel to smooth is plainly visible, so it was rejected (2026-10-07). The older "swap shifted the image 1-2 px" verdict predates the working setter and is void. diff --git a/docs/architecture/09-settings-ui.md b/docs/architecture/09-settings-ui.md index df402f8b..170fee77 100644 --- a/docs/architecture/09-settings-ui.md +++ b/docs/architecture/09-settings-ui.md @@ -73,7 +73,10 @@ the reply invalid and the page waits forever. ## The schema-driven app -The page is generated from `groups` in `ui/src/settings-schema.js`. Six groups: Hotkeys, Zoom, +The page is generated from `groups` in `ui/src/settings-schema.js`. A row may carry `showIf` (hidden +unless another setting has a value), `offIf` (kept in place but dimmed and inert while another +setting has a value: Release glide while the high resolution cursor is on) and `tag` (a small label +after the name: High resolution cursor is tagged Experimental). Six groups: Hotkeys, Zoom, View, then below a divider Preferences (with the Screen light card), Tray menu and About. Each group has a label, an icon, a banner description and cards of rows; each row names its ini key, type, label, description and default. `Settings.svelte` is the shell (title bar, sidebar, banner, save capsule, search), and diff --git a/src/config.h b/src/config.h index ce86e946..c64c987f 100644 --- a/src/config.h +++ b/src/config.h @@ -85,6 +85,7 @@ struct Config { // Release ease-out (2026-08-28, hot): the applied zoom rate glides to a stop over roughly // 3x this time constant instead of freezing the instant the button lifts (the square-wave // stop read as harsh). ~45ms tau = ~150ms felt glide. 0 = off (the old dead stop). + // Not applied with the high resolution cursor (txSamplingMode=1), which stops on release (#427). int zoomEaseOutMs = 45; // Present sync while zoomed (render engine): 1 = vsync (Present sync-interval 1, locked to // the display refresh); 0 = no vsync (Present 0), with the loop paced by the timer instead. diff --git a/src/main.cpp b/src/main.cpp index 9ec8e996..82a157d4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1293,7 +1293,10 @@ static void RunTick(TickState& t) { // root-caused elsewhere - issue #148 - so the user's configured speed applies everywhere.) t.zoom.setProfile(t.cfg.zoomInSpeed, t.cfg.zoomOutSpeed, t.cfg.smoothZoom != 0, t.cfg.smoothZoomAccel, t.cfg.smoothZoomRamp); - t.zoom.setEaseOut(t.cfg.zoomEaseOutMs / 1000.0); + // No release glide with the high resolution cursor (#427): its ladder can only cut a glide short + // (EaseOutShouldStop) or let the slow tail cross DWM's rounding steps, which shook the image and + // showed it doubled (field 2026-10-10, closed #426). The zoom stops on release instead. + t.zoom.setEaseOut(t.cfg.txSamplingMode == 1 ? 0.0 : t.cfg.zoomEaseOutMs / 1000.0); // Quick-zoom trigger. Modifier mode (quickZoomHotkeyMode==0): hold the configured modifier // (Ctrl/Alt/Shift; "None" = off) and tap a zoom key. While the modifier is held it toggles quick // zoom (below) instead of hold-zooming, so suppress the hold-zoom direction (the toggle snaps the diff --git a/src/version.h b/src/version.h index 5378472d..50963081 100644 --- a/src/version.h +++ b/src/version.h @@ -3,8 +3,8 @@ #pragma once #define WIND_VER_MAJOR 0 -#define WIND_VER_MINOR 24 -#define WIND_VER_PATCH 20 +#define WIND_VER_MINOR 25 +#define WIND_VER_PATCH 0 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.24.20" +#define WIND_VERSION_STR "0.25.0" diff --git a/ui/src/Settings.svelte b/ui/src/Settings.svelte index 177a903d..3b92467b 100644 --- a/ui/src/Settings.svelte +++ b/ui/src/Settings.svelte @@ -215,7 +215,7 @@ function clearSearch() { query = ''; const i = searchInput(); if (i) i.value = ''; } // A result's control is the real one; a row hidden on its page by showIf (it only applies under another // setting) shows dimmed, like the rest of the app shows a gated control. - const isDisabled = (r) => !visible(r); + const isDisabled = (r) => !visible(r) || isOff(r); // Enter or Down in the search box moves into the best result's control (Tab then walks the rest). function focusFirstResult() { const c = main && main.querySelector('.res .hit button, .res .hit input, .res .hit select, .res .hit [tabindex="0"]'); @@ -244,6 +244,8 @@ else if (a === 'exportDiagnostics') exportDiagnostics(); else if (a === 'quitWind') requestQuit(); } + // offIf: the row stays in place but dimmed and inert while another setting has that value (#427). + const isOff = (r) => !!r.offIf && String(values[r.offIf.key]) === String(r.offIf.eq); const visible = (r) => !r.showIf || (r.showIf.ne !== undefined ? String(values[r.showIf.key]) !== String(r.showIf.ne) : String(values[r.showIf.key]) === String(r.showIf.eq)); // Advanced rows show on their page only while the global switch is on; search results show them always. const advOn = $derived(Number(values.showAdvanced) === 1); @@ -280,7 +282,7 @@ {#if rows.length} {#each rows as r (r.key)} - change(r.key, v)} onSet={change} /> {/each} diff --git a/ui/src/controls/SettingRow.svelte b/ui/src/controls/SettingRow.svelte index 9768f64a..3330b4dc 100644 --- a/ui/src/controls/SettingRow.svelte +++ b/ui/src/controls/SettingRow.svelte @@ -36,7 +36,7 @@ {:else}
- {#if row.label}
{row.label}
{#if tab}{tab}{/if}
{/if} + {#if row.label}
{row.label}
{#if row.tag}{row.tag}{/if}{#if tab}{tab}{/if}
{/if} {#if row.desc}
{row.desc}
{/if}
@@ -94,6 +94,8 @@ .meta { min-width: 0; } .lrow { display: flex; align-items: baseline; gap: 10px; } .tab { font: 11px var(--s); letter-spacing: .02em; color: var(--fg3); opacity: .8; } + .tag { font: 500 10.5px var(--s); letter-spacing: .03em; color: var(--fg3); padding: 1px 6px; + border: 1px solid var(--chipb); border-radius: 999px; } .label { font: 500 13.5px var(--s); color: var(--fg); } .desc { margin-top: 1px; font: 12.5px var(--s); color: var(--fg3); } .ctl { display: flex; align-items: center; justify-content: flex-end; gap: 6px; } diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index 5d9f7251..a316835b 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -10,7 +10,9 @@ // Row types: keybind, slider, toggle, select, applist, highres, engine (the engine select with the // inline "Restart Wind"), palette (the Theme picker, `wide`: its control sits under the // text), profiles (dropdown + New), button, about. A row may carry showIf -// { key, eq } to hide it unless another setting has that value ({ key, ne } hides it when it does). A keybind row may carry `max` (how +// { key, eq } to hide it unless another setting has that value ({ key, ne } hides it when it does), or +// offIf { key, eq } to keep it in place but dimmed and inert while that setting has the value; `tag` +// adds a small label after the row's name (Experimental). A keybind row may carry `max` (how // many bindings it takes, default 1) and `onKey` (the on/off switch key of an extra key). // // Every labelled row carries `keywords`: synonyms and related words people might type (never displayed, @@ -64,7 +66,8 @@ export const groups = [ { key: 'maxLevel', type: 'slider', label: 'Max zoom', keywords: ['maximum', 'limit', 'highest', 'magnification', 'level', 'cap', 'zoom level', 'factor', 'times', 'upper'], desc: 'The highest zoom level.', min: 2, max: 50, step: 1, def: 12.0, unit: 'times' }, { key: 'zoomInSpeed', type: 'slider', label: 'Zoom-in speed', keywords: ['faster', 'slower', 'rate', 'magnify speed', 'zoom rate', 'acceleration', 'velocity', 'how fast'], desc: 'How fast the view zooms in.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, { key: 'zoomOutSpeed', type: 'slider', label: 'Zoom-out speed', keywords: ['faster', 'slower', 'rate', 'zoom rate', 'velocity', 'how fast'], desc: 'How fast the view zooms out.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, - { key: 'zoomEaseOutMs', type: 'slider', label: 'Release glide', keywords: ['inertia', 'coast', 'momentum', 'ease out', 'easing', 'smooth stop', 'slow down', 'deceleration', 'stop', 'let go', 'release', 'delay', 'milliseconds'], desc: 'How long the zoom coasts after you let go.', min: 0, max: 300, step: 5, def: 45, unit: 'ms' }, + { key: 'zoomEaseOutMs', type: 'slider', label: 'Release glide', keywords: ['inertia', 'coast', 'momentum', 'ease out', 'easing', 'smooth stop', 'slow down', 'deceleration', 'stop', 'let go', 'release', 'delay', 'milliseconds'], desc: 'How long the zoom coasts after you let go. Off while the high resolution cursor is on.', min: 0, max: 300, step: 5, def: 45, unit: 'ms', + offIf: { key: 'txSamplingMode', eq: '1' } }, ] }, { caption: 'Easing', rows: [ // Smooth zoom is always on (core default 1); its shape sliders are advanced. @@ -108,7 +111,7 @@ export const groups = [ // High resolution cursor (#227). Ini key txSamplingMode (0 nearest / 1 smooth). No longer // coupled to MPO (#369): both looks keep apps off hardware planes while zoomed (smooth via the // resample layer, nearest via the MPO guard), so the toggle applies live with no restart. - { key: 'txSamplingMode', type: 'highres', label: 'High resolution cursor', keywords: ['sharp', 'crisp', 'smooth', 'blurry', 'pixelated', 'hidpi', 'sampling', 'quality', 'nearest', 'mpo', 'overlay', 'cursor', 'pointer', 'resolution', '4k', 'antialiasing', 'restart', 'registry'], + { key: 'txSamplingMode', type: 'highres', label: 'High resolution cursor', tag: 'Experimental', keywords: ['sharp', 'crisp', 'smooth', 'blurry', 'pixelated', 'hidpi', 'sampling', 'quality', 'nearest', 'mpo', 'overlay', 'cursor', 'pointer', 'resolution', '4k', 'antialiasing', 'restart', 'registry'], desc: 'A smoother image and pointer at high zoom. Off keeps them pixel-sharp and steady while zooming.', def: 0 }, // Zoom lock detection (#221): games like DOOM pin the mouse to the screen centre, which would // pin the zoom view there too. Listed apps get the view UNLOCKED from the pointer. diff --git a/ui/tests/settings.spec.js b/ui/tests/settings.spec.js index da41d699..121dd516 100644 --- a/ui/tests/settings.spec.js +++ b/ui/tests/settings.spec.js @@ -185,6 +185,19 @@ test('View: the edge margin shows only when the pointer is kept within the edges await expect(key(page, 'mouseMarginPct')).toBeVisible(); }); +test('High resolution cursor is tagged Experimental and turns the release glide off (#427)', async ({ page }) => { + await page.goto('/'); + await go(page, 'view'); + await expect(key(page, 'txSamplingMode').locator('.tag')).toHaveText('Experimental'); + await go(page, 'zoom'); + await expect(key(page, 'zoomEaseOutMs')).not.toHaveClass(/disabled/); + await go(page, 'view'); + await key(page, 'txSamplingMode').getByRole('switch').check({ force: true }); + await go(page, 'zoom'); + await expect(key(page, 'zoomEaseOutMs')).toHaveClass(/disabled/); + await expect(key(page, 'zoomEaseOutMs').locator('input[type=range]')).toBeDisabled(); +}); + test('Typing and focus: caret on, focus off by default; a toggle writes at once', async ({ page }) => { await page.goto('/'); await go(page, 'view');