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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/architecture/05-transform-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion docs/architecture/09-settings-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/version.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 4 additions & 2 deletions ui/src/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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"]');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -280,7 +282,7 @@
{#if rows.length}
<Card caption={card.caption}>
{#each rows as r (r.key)}
<SettingRow row={r} value={values[r.key]} {values} {extra} {live}
<SettingRow row={r} value={values[r.key]} {values} {extra} {live} disabled={isOff(r)}
onChange={(v) => change(r.key, v)} onSet={change} />
{/each}
</Card>
Expand Down
4 changes: 3 additions & 1 deletion ui/src/controls/SettingRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
{:else}
<div class="row" class:disabled class:wide={row.wide} data-key={row.key}>
<div class="meta">
{#if row.label}<div class="lrow"><div class="label" id={labelId}>{row.label}</div>{#if tab}<span class="tab">{tab}</span>{/if}</div>{/if}
{#if row.label}<div class="lrow"><div class="label" id={labelId}>{row.label}</div>{#if row.tag}<span class="tag">{row.tag}</span>{/if}{#if tab}<span class="tab">{tab}</span>{/if}</div>{/if}
{#if row.desc}<div class="desc" id={descId}>{row.desc}</div>{/if}
</div>
<div class="ctl">
Expand Down Expand Up @@ -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; }
Expand Down
9 changes: 6 additions & 3 deletions ui/src/settings-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions ui/tests/settings.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down