From ce6336e227537cccab9274d83577796a8eb2f21b Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:03:42 +0200 Subject: [PATCH 1/2] fix(ui): one-line plain subtexts and a responsive settings layout; 0.26.2 Every settings subtext is one short plain line with no punctuation, symbols or references. Rows stack the control under the text in narrow cards, stacked sliders fill the row, the sidebar and banners adapt to narrow and short windows, and content width is capped on very wide ones. New responsive Playwright tests cover overflow and one-line subtexts. Closes #434 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/version.h | 4 +- ui/src/Onboarding.svelte | 12 ++-- ui/src/Settings.svelte | 16 +++-- ui/src/controls/About.svelte | 2 +- ui/src/controls/SettingRow.svelte | 15 +++-- ui/src/controls/Slider.svelte | 4 +- ui/src/lib/KeybindCapture.svelte | 2 +- ui/src/settings-schema.js | 88 ++++++++++++------------- ui/src/shell/Banner.svelte | 7 +- ui/src/shell/Card.svelte | 2 +- ui/src/shell/SaveCapsule.svelte | 4 +- ui/src/shell/Sidebar.svelte | 9 +++ ui/src/tray/TrayMenuPage.svelte | 4 +- ui/src/tray/trayModel.js | 24 +++---- ui/tests/prefs.spec.js | 4 +- ui/tests/responsive.spec.js | 106 ++++++++++++++++++++++++++++++ ui/tests/settings.spec.js | 6 +- ui/tests/tray.spec.js | 6 +- 18 files changed, 223 insertions(+), 92 deletions(-) create mode 100644 ui/tests/responsive.spec.js diff --git a/src/version.h b/src/version.h index fa8f0731..c33e7b8b 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 26 -#define WIND_VER_PATCH 1 +#define WIND_VER_PATCH 2 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.26.1" +#define WIND_VERSION_STR "0.26.2" diff --git a/ui/src/Onboarding.svelte b/ui/src/Onboarding.svelte index 82457b4f..a72413c7 100644 --- a/ui/src/Onboarding.svelte +++ b/ui/src/Onboarding.svelte @@ -32,8 +32,8 @@ } function back() { if (cur > 0) cur -= 1; } function skip() { setConfig('onboarded', '1'); onDone(); } - const zoomInRow = { label:'Zoom in', desc:'Hold to magnify', buttonKey:'zoomInButton', vkKey:'zoomInVk', modsKey:'zoomInMods', buttonModsKey:'zoomInButtonMods' }; - const zoomOutRow = { label:'Zoom out', desc:'Hold to zoom back', buttonKey:'zoomOutButton', vkKey:'zoomOutVk', modsKey:'zoomOutMods', buttonModsKey:'zoomOutButtonMods' }; + const zoomInRow = { label:'Zoom in', desc:'Hold to zoom in', buttonKey:'zoomInButton', vkKey:'zoomInVk', modsKey:'zoomInMods', buttonModsKey:'zoomInButtonMods' }; + const zoomOutRow = { label:'Zoom out', desc:'Hold to zoom out', buttonKey:'zoomOutButton', vkKey:'zoomOutVk', modsKey:'zoomOutMods', buttonModsKey:'zoomOutButtonMods' };
@@ -68,16 +68,16 @@

Welcome to Wind

-

Barely there. Everywhere. Let's set up the essentials.

+

A few quick steps to get started

Set your zoom keys

-

Pick the buttons you'll hold to zoom. Mouse side-buttons work great, or choose keyboard keys.

-
Zoom in
Hold to magnify
+

Pick the buttons or keys you hold to zoom

+
Zoom in
Hold to zoom in
-
Zoom out
Hold to zoom back
+
Zoom out
Hold to zoom out
diff --git a/ui/src/Settings.svelte b/ui/src/Settings.svelte index 3b92467b..01be5fef 100644 --- a/ui/src/Settings.svelte +++ b/ui/src/Settings.svelte @@ -369,15 +369,23 @@ diff --git a/ui/src/shell/Card.svelte b/ui/src/shell/Card.svelte index c3226ec6..6babb117 100644 --- a/ui/src/shell/Card.svelte +++ b/ui/src/shell/Card.svelte @@ -9,6 +9,6 @@ diff --git a/ui/src/shell/SaveCapsule.svelte b/ui/src/shell/SaveCapsule.svelte index a27d8c20..e294f185 100644 --- a/ui/src/shell/SaveCapsule.svelte +++ b/ui/src/shell/SaveCapsule.svelte @@ -43,8 +43,8 @@ {/if} diff --git a/ui/src/tray/TrayMenuPage.svelte b/ui/src/tray/TrayMenuPage.svelte index abcef2e0..ee622bbd 100644 --- a/ui/src/tray/TrayMenuPage.svelte +++ b/ui/src/tray/TrayMenuPage.svelte @@ -25,7 +25,7 @@ const blocked = (kind, it) => kind === 'sliders' && !it.on && full; -
Checked items appear in the tray. Drag to reorder.
+
Check items to show them and drag to reorder
{#snippet itemRow(kind, it)} {@html iconSvg(it.icon, 24)} @@ -41,7 +41,7 @@
Sliders - {sliderCount} of {MAX_SLIDERS}{full ? ' · Uncheck one to add another' : ''} + {sliderCount} of {MAX_SLIDERS}{full ? ' full' : ''}
flip('sliders', sliders, it)} diff --git a/ui/src/tray/trayModel.js b/ui/src/tray/trayModel.js index d5a9925e..becc0cfe 100644 --- a/ui/src/tray/trayModel.js +++ b/ui/src/tray/trayModel.js @@ -7,22 +7,22 @@ export const MAX_SLIDERS = 4; // `icon` names live in design/icons.js. Order here is the default order. export const SLIDERS = [ - { key: 'colorWarmPct', icon: 'warm', name: 'Warmth', desc: 'Screen warmth.' }, - { key: 'colorDimPct', icon: 'bright', name: 'Brightness', desc: 'Screen brightness.' }, - { key: 'maxLevel', icon: 'maxz', name: 'Max zoom', desc: 'The highest magnification.' }, - { key: 'zoomInSpeed', icon: 'zin', name: 'Zoom-in speed', desc: 'How quickly the view magnifies.' }, - { key: 'zoomOutSpeed', icon: 'zout', name: 'Zoom-out speed', desc: 'How quickly the view returns.' }, - { key: 'panSpeed', icon: 'pan', name: 'Arrow key speed', desc: 'How fast the view pans.' }, - { key: 'panGlideMaxPx', icon: 'smooth', name: 'Pan glide', desc: 'How far the pointer eases on when you stop.' }, - { key: 'zoomEaseOutMs', icon: 'glide', name: 'Release glide', desc: 'How softly the zoom coasts to a stop.' }, + { key: 'colorWarmPct', icon: 'warm', name: 'Warmth', desc: 'Screen warmth' }, + { key: 'colorDimPct', icon: 'bright', name: 'Brightness', desc: 'Screen brightness' }, + { key: 'maxLevel', icon: 'maxz', name: 'Max zoom', desc: 'The highest zoom level' }, + { key: 'zoomInSpeed', icon: 'zin', name: 'Zoom-in speed', desc: 'How fast the view zooms in' }, + { key: 'zoomOutSpeed', icon: 'zout', name: 'Zoom-out speed', desc: 'How fast the view zooms out' }, + { key: 'panSpeed', icon: 'pan', name: 'Arrow key speed', desc: 'How fast the view pans' }, + { key: 'panGlideMaxPx', icon: 'smooth', name: 'Pan glide', desc: 'How far the pointer coasts after you stop' }, + { key: 'zoomEaseOutMs', icon: 'glide', name: 'Release glide', desc: 'How softly the zoom coasts to a stop' }, ]; export const TOGGLES = [ - { key: 'trackCaret', icon: 'ftc', name: 'Follow the text cursor', desc: 'Follow the caret while you type.' }, - { key: 'trackFocus', icon: 'ffk', name: 'Follow keyboard focus', desc: 'Follow keyboard focus around the screen.' }, + { key: 'trackCaret', icon: 'ftc', name: 'Follow the text cursor', desc: 'The view follows the text cursor' }, + { key: 'trackFocus', icon: 'ffk', name: 'Follow keyboard focus', desc: 'The view follows keyboard focus' }, // One segment for both alignment settings (mouseAlign + trackAlign); the flyout writes both. - { key: 'keepEdges', icon: 'edges', name: 'Keep cursor centred', desc: 'On: the view keeps the pointer, text cursor and focus centred. Off: they move freely and the view follows near the edges.' }, + { key: 'keepEdges', icon: 'edges', name: 'Keep cursor centred', desc: 'Keeps the pointer and text cursor centred' }, // #315. A full-width dropdown under the toggle group in the tray that picks the main engine; a change restarts Wind. - { key: 'engine', icon: 'engine', name: 'Engine', desc: 'A dropdown to switch the engine. Wind restarts to apply it.' }, + { key: 'engine', icon: 'engine', name: 'Engine', desc: 'Switch the engine from the tray' }, ]; const KINDS = { sliders: { items: SLIDERS, enabledKey: 'traySliders', orderKey: 'traySliderOrder', defaultOn: ['colorWarmPct', 'colorDimPct'], cap: MAX_SLIDERS }, diff --git a/ui/tests/prefs.spec.js b/ui/tests/prefs.spec.js index 917f2d09..2816b6de 100644 --- a/ui/tests/prefs.spec.js +++ b/ui/tests/prefs.spec.js @@ -100,10 +100,10 @@ test('the theme picker is one row of the four theme cards, no scrolling, no arro expect(await css(picker, 'overflow-x')).toBe('visible'); await expect(key(page, 'uiPalette').locator('button')).toHaveCount(4); // the four cards only expect(await css(picker, 'mask-image')).toBe('none'); - // Right-aligned like the other controls: the last card ends at the row's right edge. + // At this width the row stacks (control under the text): the last card stays inside the row. const rowBox = await key(page, 'uiPalette').boundingBox(); const lastBox = await picker.getByRole('radio').last().boundingBox(); - expect(rowBox.x + rowBox.width - (lastBox.x + lastBox.width)).toBeLessThan(40); + expect(lastBox.x + lastBox.width).toBeLessThanOrEqual(rowBox.x + rowBox.width); await expect(picker.getByRole('radio', { checked: true })).toHaveAccessibleName('Wind grey'); await expect(picker.locator('.nm')).toHaveCount(0); // cards only, no names (owner decision) // Each card is drawn in its own theme: the mini windows differ. diff --git a/ui/tests/responsive.spec.js b/ui/tests/responsive.spec.js new file mode 100644 index 00000000..a04a67c4 --- /dev/null +++ b/ui/tests/responsive.spec.js @@ -0,0 +1,106 @@ +// Responsive layout: no horizontal overflow of the page and no control sticking out of its card at +// window sizes from a narrow strip to a very wide monitor, on every page, search and onboarding. +import { test, expect } from '@playwright/test'; + +const SIZES = [[420, 700], [560, 800], [820, 560], [900, 360], [1000, 1400], [1280, 800], [2560, 1440]]; + +const boot = (extra = {}) => async (page) => { + await page.addInitScript((extra) => { + const listeners = new Set(); + const live = { maxLevel: '12', zoomInVk: '33', zoomInButton: '2', zoomOutVk: '114', cursorLockVk: '113', + model: 'hybrid', uiTheme: 'dark', showAdvanced: '1', ...extra }; + window.chrome = { webview: { + addEventListener: (_e, fn) => listeners.add(fn), + postMessage: (msg) => { + if (msg.type === 'getConfig') listeners.forEach((fn) => fn({ data: { type: 'config', values: { ...live }, saved: { ...live }, + profiles: { names: ['Default', 'Gaming'], active: 'Default' } } })); + }, + } }; + }, extra); +}; + +// Returns problems: page-level horizontal overflow, and any control or text wider than its card. +const problems = (page) => page.evaluate(() => { + const out = []; + const de = document.documentElement; + if (de.scrollWidth > de.clientWidth) out.push('document scrolls horizontally'); + const main = document.querySelector('main.main'); + if (main && main.scrollWidth > main.clientWidth) out.push('main scrolls horizontally'); + for (const card of document.querySelectorAll('.card')) { + const cr = card.getBoundingClientRect(); + for (const el of card.querySelectorAll('.row .meta, .row .ctl, .row .ctl *')) { + const r = el.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) continue; + if (r.right > cr.right + 1 || r.left < cr.left - 1) out.push((el.className || el.tagName) + ' leaves its card'); + } + } + return out; +}); + +for (const [w, h] of SIZES) { + test(`no overflow at ${w}x${h}`, async ({ page }) => { + await boot()(page); + await page.setViewportSize({ width: w, height: h }); + await page.goto('/'); + await expect(page.locator('h1')).toHaveText('Hotkeys'); + for (const g of ['hotkeys', 'zoom', 'view', 'prefs', 'tray', 'about']) { + await page.locator('.side .it[data-g="' + g + '"]').click(); + await page.waitForTimeout(150); + expect(await problems(page), g).toEqual([]); + } + await page.locator('.side .search input').fill('zoom'); + await page.waitForTimeout(150); + expect(await problems(page), 'search').toEqual([]); + }); +} + +test('wide window caps the content width', async ({ page }) => { + await boot()(page); + await page.setViewportSize({ width: 2560, height: 1440 }); + await page.goto('/'); + await page.locator('.side .it[data-g="zoom"]').click(); + const w = await page.locator('.card').first().evaluate((el) => el.getBoundingClientRect().width); + expect(w).toBeLessThanOrEqual(960); +}); + +test('narrow window moves the sidebar above the page', async ({ page }) => { + await boot()(page); + await page.setViewportSize({ width: 420, height: 700 }); + await page.goto('/'); + const side = await page.locator('.side').evaluate((el) => el.getBoundingClientRect().width); + expect(side).toBeGreaterThan(380); +}); + +for (const [w, h] of [[420, 700], [900, 360]]) { + test(`onboarding fits ${w}x${h}`, async ({ page }) => { + await boot({ uiTheme: 'auto' })(page); + await page.setViewportSize({ width: w, height: h }); + await page.goto('/?mode=onboard'); + await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible(); + for (const name of ['Get started', 'Next']) { + await page.getByRole('button', { name }).click(); + await page.waitForTimeout(300); + const fits = await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth); + expect(fits).toBe(true); + } + }); +} + +for (const [w, h] of [[820, 560], [900, 600]]) { + test(`subtexts stay on one line at ${w}x${h}`, async ({ page }) => { + await boot()(page); + await page.setViewportSize({ width: w, height: h }); + await page.goto('/'); + for (const g of ['hotkeys', 'zoom', 'view', 'prefs', 'tray', 'about']) { + await page.locator('.side .it[data-g="' + g + '"]').click(); + await page.waitForTimeout(150); + const wrapped = await page.evaluate(() => [...document.querySelectorAll('.desc, .bdesc, .thint')].filter((el) => { + const r = el.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) return false; + const lh = parseFloat(getComputedStyle(el).lineHeight) || parseFloat(getComputedStyle(el).fontSize) * 1.3; + return r.height > lh * 1.5; + }).map((el) => el.textContent)); + expect(wrapped, g).toEqual([]); + } + }); +} diff --git a/ui/tests/settings.spec.js b/ui/tests/settings.spec.js index 8ea146be..969afdc2 100644 --- a/ui/tests/settings.spec.js +++ b/ui/tests/settings.spec.js @@ -83,14 +83,14 @@ test('pages show their sections; a section with nothing to show is dropped', asy test('copy: the row names and descriptions follow the table, and "Never use Render for" is gone', async ({ page }) => { await page.goto('/'); await expect(page.locator('[data-key="__zoomIn"] .label')).toHaveText('Zoom in'); - await expect(page.locator('[data-key="__zoomIn"] .desc')).toHaveText('Hold or scroll to magnify the view.'); + await expect(page.locator('[data-key="__zoomIn"] .desc')).toHaveText('Hold a key or scroll to zoom in'); await expect(page.locator('[data-key="__hideCursor"] .label')).toHaveText('Hide pointer'); - await expect(page.locator('[data-key="__pan"] .desc')).toHaveText('Hold the modifiers and press an arrow key to move the view.'); + await expect(page.locator('[data-key="__pan"] .desc')).toHaveText('Hold modifier keys and an arrow to pan'); await advanced(page, true); await go(page, 'zoom'); await expect(page.locator('main .cap')).toHaveText(['Level and speed', 'Easing', 'Engine']); await expect(page.locator('[data-key="smoothZoomAccel"] .label')).toHaveText('Soft start'); - await expect(page.locator('[data-key="engineGame"] .desc')).toHaveText('Full-screen and borderless games.'); + await expect(page.locator('[data-key="engineGame"] .desc')).toHaveText('Full screen and borderless games'); await expect(page.getByText('Never use Render for')).toHaveCount(0); await expect(key(page, 'renderExclude')).toHaveCount(0); }); diff --git a/ui/tests/tray.spec.js b/ui/tests/tray.spec.js index a679f026..48acd640 100644 --- a/ui/tests/tray.spec.js +++ b/ui/tests/tray.spec.js @@ -81,7 +81,7 @@ test('tab: below the sidebar divider, banner, performance card and both lists', await expect(page.locator('.side nav:not(.bottom) .it[data-g="tray"]')).toHaveCount(0); await page.locator('.side .it[data-g="tray"]').click(); await expect(page.locator('h1')).toHaveText('Tray menu'); - await expect(page.locator('.bdesc')).toHaveText('Choose what the tray menu shows, and in what order.'); + await expect(page.locator('.bdesc')).toHaveText('What the tray menu shows and in what order'); await expect(page.getByRole('switch', { name: 'Performance in the tray' })).toBeChecked(); // on by default (#329) await expect(page.locator('#cap-sliders')).toHaveText('Sliders'); await expect(page.locator('#cap-toggles')).toHaveText('Toggles'); @@ -129,11 +129,11 @@ test('check and uncheck write the enabled list and the full order', async ({ pag await expect(page.locator('[data-cnt="toggles"]')).toHaveText('3 on'); }); -test('slider cap: at four the unchecked boxes are disabled with a note, unchecking frees them', async ({ page }) => { +test('slider cap: at four the unchecked boxes are disabled and the count says full', async ({ page }) => { await open(page); await chk(page, 'sliders', 'Max zoom').click(); await chk(page, 'sliders', 'Arrow key speed').click(); - await expect(page.locator('[data-cnt="sliders"]')).toHaveText('4 of 4 · Uncheck one to add another'); + await expect(page.locator('[data-cnt="sliders"]')).toHaveText('4 of 4 full'); await expect(chk(page, 'sliders', 'Zoom-in speed')).toBeDisabled(); await expect(chk(page, 'sliders', 'Release glide')).toBeDisabled(); await expect(chk(page, 'sliders', 'Warmth')).toBeEnabled(); From 66b93200a245f4101d05cdbfbb98e5a797db7e2c Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:10:47 +0200 Subject: [PATCH 2/2] feat(cursor): pan glide is one setting without the Experimental tag The separate ease time (panGlideMs) is gone: the ease follows the glide distance (40 px = 60 ms, up to 240 ms), so one slider sets how far and how gently the pointer coasts. The old key is ignored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/07-cursor.md | 6 +++--- docs/architecture/08-config-profiles.md | 2 +- src/config.cpp | 6 +----- src/config.h | 5 ++--- src/main.cpp | 2 +- src/pan_glide.h | 8 ++++++++ tests/test_config.cpp | 10 +++------- tests/test_pan_glide.cpp | 8 ++++++++ ui/src/settings-schema.js | 3 +-- ui/tests/schema.spec.js | 4 ++-- 10 files changed, 30 insertions(+), 24 deletions(-) diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index e34096d7..a32ed98a 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -131,10 +131,10 @@ slightly during zoom ramps, nearest keeps it pixelated and steady. Pure rules: session-end reset of `ixPubLevel_` keeps a quick zoom back to the same level from skipping the publish nudge, and the hide-cursor hotkey's show-again transition nudges after the blanker restore like the zoom-out does. -- **Pan glide (#430, `panGlideMaxPx`, experimental, 0 = off).** A soft stop: when a mouse movement +- **Pan glide (#430, `panGlideMaxPx`, 0 = off).** A soft stop: when a mouse movement stops, the POINTER eases on at the hand's speed and slows to rest within `panGlideMaxPx` SCREEN px - at any zoom (`src/pan_glide.h`); `panGlideMs` is the ease's time constant, shortened for a fast hand - so the cap holds. The view follows by DWM centring. Any hand movement, a mouse button, a game, + at any zoom (`src/pan_glide.h`). One setting since #434: the ease's time constant follows the + distance (`PanGlideTauS`, 40 px = 60 ms), shortened for a fast hand so the cap holds. The view follows by DWM centring. Any hand movement, a mouse button, a game, Inspect, a detached view or 1x ends it. Wind's own steps move the tick baseline (`lastSetVirtual`), so the lock detector and the gain learner only ever see hand motion. A first, uncapped version (momentum = speed x time) could throw the pointer across the screen and was replaced. It replaces diff --git a/docs/architecture/08-config-profiles.md b/docs/architecture/08-config-profiles.md index 9e0bf3ca..023334bb 100644 --- a/docs/architecture/08-config-profiles.md +++ b/docs/architecture/08-config-profiles.md @@ -173,7 +173,7 @@ Every key works in the ini whether or not Settings shows it. Keys hot-reload unl | Keys | Meaning | |---|---| | `maxLevel` (12), `zoomInSpeed`, `zoomOutSpeed`, `zoomEaseOutMs`, `smoothZoom*` | Range and feel of the zoom | -| `cursorSensitivity` (1.0), `panSpeed` (1.0), `panGlideMaxPx` (0), `panGlideMs` (60) | Mouse and arrow-key pan speed; the pan glide's distance cap and ease (#430) | +| `cursorSensitivity` (1.0), `panSpeed` (1.0), `panGlideMaxPx` (0) | Mouse and arrow-key pan speed; the pan glide's distance, which also sets its ease (#430, #434) | | `mouseAlign` (0 centred, 1 within the edges), `mouseMarginPct` | Where the pointer sits while the view moves | | `trackCaret` (1), `trackFocus` (0), `trackAlign`, `trackGlideMs` (200) | Follow the text caret and keyboard focus | | `lockApps`, `warpLock` (0) | Games whose sessions pan from raw mouse motion; heuristics for unlisted games | diff --git a/src/config.cpp b/src/config.cpp index 0442eef2..923132b3 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -210,7 +210,6 @@ Config ParseConfig(const std::string& text) { else if (key == "dwmFlush") c.dwmFlush = ParseIntStrict(val); else if (key == "diagnostics") c.diagnostics = ParseIntStrict(val); else if (key == "cursorSensitivity") c.cursorSensitivity = ParseDoubleStrict(val); - else if (key == "panGlideMs") c.panGlideMs = ParseIntStrict(val); else if (key == "panGlideMaxPx") c.panGlideMaxPx = ParseIntStrict(val); else if (key == "cursorConstantSize") c.cursorConstantSize = ParseIntStrict(val); else if (key == "cursorVisibility") c.cursorVisibility = val; @@ -296,8 +295,6 @@ Config ParseConfig(const std::string& text) { c.smoothZoomAccel = clampd(c.smoothZoomAccel, 1.0, 8.0); c.smoothZoomRamp = clampd(c.smoothZoomRamp, 0.1, 3.0); c.cursorSensitivity = clampd(c.cursorSensitivity, 0.25, 4.0); - if (c.panGlideMs < 10) c.panGlideMs = 10; - if (c.panGlideMs > 300) c.panGlideMs = 300; if (c.panGlideMaxPx < 0) c.panGlideMaxPx = 0; if (c.panGlideMaxPx > 400) c.panGlideMaxPx = 400; c.sharpness = clampd(c.sharpness, 0.0, 1.0); @@ -518,9 +515,8 @@ std::string DefaultIniText() { "; No effect in a free transform session (the pointer itself drives the view)\n" "cursorSensitivity=1.0\n" "; panGlideMaxPx: when a mouse movement stops the pointer eases on at most this many screen\n" - "; px before it rests (Transform engine, zoomed); 0=off. panGlideMs: the ease's time (ms)\n" + "; px before it rests (Transform engine, zoomed); 0=off. A longer glide also eases longer\n" "panGlideMaxPx=0\n" - "panGlideMs=60\n" "; cursorConstantSize: 0=the cursor grows with the zoom (default); 1=keep it at normal\n" "; desktop size at every zoom (render engine only). Replaces cursorScaleWithZoom (ignored).\n" "cursorConstantSize=0\n" diff --git a/src/config.h b/src/config.h index 6a74f63b..1eff2de3 100644 --- a/src/config.h +++ b/src/config.h @@ -380,10 +380,9 @@ struct Config { double cursorSensitivity = 1.0; // Pan glide (issue #430, src/pan_glide.h): when a mouse movement stops, the pointer eases on and // comes to rest instead of halting dead. panGlideMaxPx caps how far, in SCREEN px at any zoom - // (0 = off, the default); panGlideMs is the ease's time constant (a fast hand eases more steeply - // to stay inside the cap). Hot. Replaces cursorSmoothing (ignored). + // (0 = off, the default). The ease's time follows the distance (PanGlideTauS, one setting since + // #434; the old panGlideMs key is ignored). Hot. Replaces cursorSmoothing (ignored). int panGlideMaxPx = 0; - int panGlideMs = 60; // 0 (default) = the render engine's cursor grows with the zoom, matching the transform // engine (DWM magnifies its sprite); 1 = opt-in constant desktop-size pointer. Replaces // cursorScaleWithZoom (issue #253), which is IGNORED: the default template wrote it as an diff --git a/src/main.cpp b/src/main.cpp index ff3a6dac..8a4c88ec 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1869,7 +1869,7 @@ static void RunTick(TickState& t) { !t.viewDetached && !btn; if (t.glide.active && (!glideOk || curDx != 0 || curDy != 0)) wind::PanGlideCancel(t.glide); if (!t.glide.active) - wind::PanGlideObserve(t.glide, curDx, curDy, dt, cur.x, cur.y, t.cfg.panGlideMs / 1000.0, + wind::PanGlideObserve(t.glide, curDx, curDy, dt, cur.x, cur.y, wind::PanGlideTauS(t.cfg.panGlideMaxPx), t.cfg.panGlideMaxPx / lvl, glideOk); // the cap is in screen px double gx = 0.0, gy = 0.0; if (t.glide.active && wind::PanGlideStep(t.glide, dt, gx, gy)) { diff --git a/src/pan_glide.h b/src/pan_glide.h index f6379735..499f2b79 100644 --- a/src/pan_glide.h +++ b/src/pan_glide.h @@ -29,6 +29,14 @@ inline constexpr double kGlideVelTauS = 0.03; // velocity estimate: the inline constexpr double kGlideMinStartSpeed = 40.0; // px/s: below this the hand was resting anyway inline constexpr double kGlideStopDist = 0.5; // desktop px left to travel: the glide is over +// One setting drives the glide (#434): its ease time grows with its distance, so a longer glide also +// slows down more gently. 40 px gives 60 ms, the old separate default. +inline double PanGlideTauS(int maxPx) { + double ms = 40.0 + maxPx * 0.5; + if (ms > 240.0) ms = 240.0; + return ms / 1000.0; +} + // One tick of hand motion (dx, dy desktop px over dt s), with the pointer now at (curX, curY). // Starts a glide (returns true) on the first still tick after motion when the session allows it. // tauS is the ease's time constant; maxDist (desktop px, > 0) caps how far it travels: a hand fast diff --git a/tests/test_config.cpp b/tests/test_config.cpp index f944bb3f..08a610b7 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -58,7 +58,6 @@ TEST_CASE("renderer knobs have sane defaults") { CHECK(c.bilinear == 1); CHECK(c.sharpness == doctest::Approx(0.0)); // off by default CHECK(c.panGlideMaxPx == 0); - CHECK(c.panGlideMs == 60); CHECK(c.zorderBand == 0); // shipped unbanded (issue #162): band 16 sat UNDER the // Snipping Tool overlay, which cost the cursor entirely CHECK(c.brightness == doctest::Approx(1.0)); @@ -140,9 +139,9 @@ TEST_CASE("hdrTonemap can be disabled") { Config c = ParseConfig("hdrTonemap=0\n"); CHECK(c.hdrTonemap == 0); } -TEST_CASE("parses panGlideMs, z-order band, brightness; the old cursorSmoothing is ignored") { +TEST_CASE("parses z-order band, brightness; the old cursorSmoothing and panGlideMs are ignored") { Config c = ParseConfig("panGlideMs=150\ncursorSmoothing=0.7\nzorderBand=16\nbrightness=0.85\n"); - CHECK(c.panGlideMs == 150); + CHECK(c.panGlideMaxPx == 0); CHECK(c.zorderBand == 16); CHECK(c.brightness == doctest::Approx(0.85)); } @@ -178,14 +177,12 @@ TEST_CASE("numeric fields are clamped to documented ranges") { CHECK(ParseConfig("smoothZoomAccel=0\n").smoothZoomAccel == doctest::Approx(1.0)); CHECK(ParseConfig("smoothZoomRamp=-1\n").smoothZoomRamp == doctest::Approx(0.1)); CHECK(ParseConfig("cursorSensitivity=10\n").cursorSensitivity == doctest::Approx(4.0)); - CHECK(ParseConfig("panGlideMs=5000\n").panGlideMs == 300); - CHECK(ParseConfig("panGlideMs=-1\n").panGlideMs == 10); CHECK(ParseConfig("panGlideMaxPx=9999\n").panGlideMaxPx == 400); CHECK(ParseConfig("sharpness=9\n").sharpness == doctest::Approx(1.0)); CHECK(ParseConfig("brightness=0\n").brightness == doctest::Approx(0.5)); // In-range values pass through untouched. CHECK(ParseConfig("maxLevel=8\n").maxLevel == doctest::Approx(8.0)); - CHECK(ParseConfig("panGlideMs=150\n").panGlideMs == 150); + CHECK(ParseConfig("panGlideMaxPx=40\n").panGlideMaxPx == 40); } TEST_CASE("multiMonitor can be set") { CHECK(ParseConfig("multiMonitor=0\n").multiMonitor == 0); @@ -460,7 +457,6 @@ TEST_CASE("the first-run ini template parses to the struct defaults (issue #274) CHECK(t.dwmFlush == d.dwmFlush); CHECK(t.diagnostics == d.diagnostics); CHECK(t.cursorSensitivity == doctest::Approx(d.cursorSensitivity)); - CHECK(t.panGlideMs == d.panGlideMs); CHECK(t.panGlideMaxPx == d.panGlideMaxPx); CHECK(t.cursorConstantSize == d.cursorConstantSize); CHECK(t.cursorVisibility == d.cursorVisibility); diff --git a/tests/test_pan_glide.cpp b/tests/test_pan_glide.cpp index 4480cb29..231eb2de 100644 --- a/tests/test_pan_glide.cpp +++ b/tests/test_pan_glide.cpp @@ -37,6 +37,14 @@ TEST_CASE("the cap bounds the distance at any speed and keeps the hand's start s } } +TEST_CASE("one setting: the ease time grows with the distance (#434)") { + CHECK(PanGlideTauS(40) == doctest::Approx(0.060)); // the old separate default + CHECK(PanGlideTauS(0) == doctest::Approx(0.040)); + CHECK(PanGlideTauS(200) == doctest::Approx(0.140)); + CHECK(PanGlideTauS(400) == doctest::Approx(0.240)); + CHECK(PanGlideTauS(1000) == doctest::Approx(0.240)); +} + TEST_CASE("no glide when it is off or the session does not allow it") { PanGlide off; CHECK_FALSE(Flick(off, 20, 20.0, 0.0, 0.0)); // panGlideMs = 0 diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index ab4c8978..1623865a 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -102,8 +102,7 @@ export const groups = [ { caption: 'Speed', rows: [ { key: 'panSpeed', type: 'slider', label: 'Arrow key speed', keywords: ['pan', 'pan speed', 'arrows', 'move', 'scroll', 'keyboard', 'how fast', 'velocity', 'shift view', 'rate'], desc: 'How fast the arrow keys move the view', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, { key: 'cursorSensitivity', type: 'slider', label: 'Mouse speed', keywords: ['sensitivity', 'pointer speed', 'cursor speed', 'dpi', 'pan', 'follow', 'tracking speed', 'how fast', 'velocity', 'mouse sensitivity', 'rate'], desc: 'How fast the view moves with the mouse', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, - { key: 'panGlideMaxPx', type: 'slider', label: 'Pan glide', tag: 'Experimental', keywords: ['inertia', 'momentum', 'glide', 'coast', 'ease', 'ease out', 'soft stop', 'stop', 'slide', 'smooth', 'smoothing', 'mouse', 'pan', 'drift', 'deceleration'], desc: 'How far the pointer coasts after you stop', min: 0, max: 200, step: 5, def: 0, unit: 'px' }, - { key: 'panGlideMs', type: 'slider', adv: true, label: 'Pan glide ease', keywords: ['inertia', 'glide', 'ease', 'softness', 'time', 'duration', 'milliseconds', 'smooth', 'deceleration'], desc: 'How gently the pan glide slows down', min: 10, max: 300, step: 10, def: 60, unit: 'ms', showIf: { key: 'panGlideMaxPx', ne: '0' } }, + { key: 'panGlideMaxPx', type: 'slider', label: 'Pan glide', keywords: ['inertia', 'momentum', 'glide', 'coast', 'ease', 'ease out', 'soft stop', 'stop', 'slide', 'smooth', 'smoothing', 'mouse', 'pan', 'drift', 'deceleration'], desc: 'How far the pointer coasts after you stop', min: 0, max: 200, step: 5, def: 0, unit: 'px' }, ] }, { caption: 'Pointer', rows: [ { key: 'mouseAlign', type: 'select', label: 'Pointer position', keywords: ['mouse position', 'mouse', 'cursor', 'pointer', 'where', 'centred', 'centered', 'center', 'centre', 'middle', 'edge', 'edges', 'within the edges', 'align', 'alignment', 'lock to center', 'follow mode', 'placement'], desc: 'Where the pointer sits while the view moves', options: ['0', '1'], optionLabels: { '0': 'Centred', '1': 'Within the edges' }, def: '0' }, diff --git a/ui/tests/schema.spec.js b/ui/tests/schema.spec.js index a23d195d..9b275e05 100644 --- a/ui/tests/schema.spec.js +++ b/ui/tests/schema.spec.js @@ -10,7 +10,7 @@ const OLD_KEYS = [ '__zoomIn', '__zoomOut', '__zoomWheel', '__pan', // was four rows (Pan left/right/up/down); their ini keys live in OLD_BIND_KEYS 'noSwallowApps', 'maxLevel', 'zoomInSpeed', 'zoomOutSpeed', 'panSpeed', 'smoothZoomAccel', 'smoothZoomRamp', 'zoomEaseOutMs', 'txSamplingMode', '__hideCursor', '__cursorLock', 'lockApps', - 'cursorSensitivity', 'panGlideMaxPx', 'panGlideMs', 'trackCaret', 'trackFocus', 'trackAlign', 'mouseAlign', + 'cursorSensitivity', 'panGlideMaxPx', 'trackCaret', 'trackFocus', 'trackAlign', 'mouseAlign', 'mouseMarginPct', 'colorWarmPct', 'colorDimPct', 'model', 'engineGame', 'engineAcrylic', 'engineDesktop', 'engineOther', 'renderExclude', 'diagnostics', '__about', ]; @@ -57,7 +57,7 @@ test('a captioned section has two or more rows, and the advanced rows are the on if (base.length) expect(base.length, g.id + ' / ' + c.caption + ' (advanced off)').toBeGreaterThanOrEqual(2); } expect(allRows.filter((r) => r.adv).map((r) => r.key).sort()).toEqual( - ['engineAcrylic', 'engineDesktop', 'engineGame', 'engineOther', 'lockApps', 'model', 'noSwallowApps', 'panGlideMs', 'smoothZoomAccel', 'smoothZoomRamp'].sort()); + ['engineAcrylic', 'engineDesktop', 'engineGame', 'engineOther', 'lockApps', 'model', 'noSwallowApps', 'smoothZoomAccel', 'smoothZoomRamp'].sort()); // Troubleshooting is never advanced, and the extra keys carry their switch key. for (const k of ['diagnostics', '__diagnostics', '__openIni']) expect(allRows.find((r) => r.key === k).adv).toBeUndefined(); expect(Object.fromEntries(allRows.filter((r) => r.onKey).map((r) => [r.key, r.onKey]))).toEqual(