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: 5 additions & 4 deletions docs/architecture/02-tick-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,11 @@ There is no settings IPC. `WindConfig.exe` writes `magnifier.ini` and the core n

**UI-only writes never reload.** A reload rebuilds `ZoomController` and `CursorMapper`, which is
wasted work and risks a visible hitch mid-zoom (the live level and mapper centre are carried over
since #234, so the view no longer collapses to 1x). `StripUiOnlyKeys` (`src/config.cpp`) drops `uiTheme`, `uiPalette`, `showAdvanced`,
`onboarded`, `trayPinned` and the five tray layout keys (`trayPerf`, `traySliders`, `traySliderOrder`,
`trayToggles`, `trayToggleOrder`), and the result is compared with the fingerprint of the last applied config
(`t.lastCoreIni`). An identical fingerprint skips the reload. The fingerprint is seeded at startup;
since #234, so the view no longer collapses to 1x). `StripUiOnlyKeys` (`src/config.cpp`) drops
`uiTheme`, `uiPalette`,
`showAdvanced`, `onboarded`, `trayPinned`, `uiHighResNoticeOff` and the five tray layout keys
(`trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`), and the result is compared with the fingerprint of the last applied
config (`t.lastCoreIni`). An identical fingerprint skips the reload. The fingerprint is seeded at startup;
an empty one would make the first Settings write of a session reload.

A real reload re-binds the hook's buttons and swallowed keys (`g_input.setButtonBinds`/`setKeys`),
Expand Down
12 changes: 8 additions & 4 deletions docs/architecture/08-config-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ as an initializer, so a missing or malformed key keeps the default; parsing neve
## Hot-reload and the UI-only fingerprint

The reload mechanics are in [02](02-tick-loop.md). A reload rebuilds `ZoomController`, so it
collapses an active zoom; therefore `StripUiOnlyKeys` removes the ten keys the core never reads
(`uiTheme`, `uiPalette`, `showAdvanced`, `onboarded`, `trayPinned` and the five tray layout keys `trayPerf`,
collapses an active zoom; therefore `StripUiOnlyKeys` removes the eleven keys the core never reads
(`uiTheme`, `uiPalette`, `showAdvanced`, `onboarded`, `trayPinned`, `uiHighResNoticeOff` and
the five tray layout keys `trayPerf`,
`traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`) and the reload is skipped when the stripped
text is unchanged. `profile` stays in the fingerprint, so a profile switch reloads.

Expand All @@ -99,7 +100,8 @@ A profile is a named full snapshot of the settings, keybinds included, stored as
in `src/profiles.*` (tested); I/O in `src/profiles_io.h`.

**Global keys never travel with a profile.** `IsGlobalProfileKey` covers `profile`, `onboarded`,
`uiTheme`, `uiPalette`, `showAdvanced`, `trayPinned` and the five tray layout keys (`trayPerf`, `traySliders`,
`uiTheme`, `uiPalette`, `showAdvanced`, `trayPinned`, `uiHighResNoticeOff` and the five tray
layout keys (`trayPerf`, `traySliders`,
`traySliderOrder`, `trayToggles`, `trayToggleOrder`). `MakeProfileText` strips them from profile
files; `MakeLiveText` carries them over from the old live text. Both work line by line and keep
comments and order.
Expand Down Expand Up @@ -203,7 +205,9 @@ Every key works in the ini whether or not Settings shows it. Keys hot-reload unl

**Global and UI.** `profile`, `onboarded`, `uiPalette` (`grey`, `ember`, `ocean`, `hicon`),
`showAdvanced`, `trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`,
`trayPinned` (1 = keep the tray icon on the taskbar, 0 = hidden-icons overflow; applied by WindTray).
`trayPinned` (1 = keep the tray icon on the taskbar, 0 = hidden-icons overflow; applied by WindTray),
`uiHighResNoticeOff` (1 = the High resolution cursor notice is never shown; set by its
"Don't show this again" box, #441).
`uiTheme` is a legacy key, ignored.

**Transform and diagnostics.** Hot unless noted; the full text is on the `Config` field.
Expand Down
5 changes: 5 additions & 0 deletions docs/architecture/09-settings-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ The live ini is the session and the profile file is the saved state ([08](08-con
- The ini is shared with the tray flyout and hand edits, so the page re-reads it when the window
regains focus and after a `configWriteFailed`, and reloads only if something differs.
- Global keys are written directly and never count as unsaved.
- **High resolution cursor notice** (#441): `change()` in `Settings.svelte` holds back
`txSamplingMode` 0 to 1 and shows a `Prompt` (with its optional `checkLabel`/`bind:checked`
checkbox) unless the global UI-only key `uiHighResNoticeOff=1` is set. Turn on writes the
opt-out (when ticked) and then `txSamplingMode=1`; Cancel and Esc write nothing, and
`HighRes.svelte` resets the switch to the prop. Search results use the same `change()`.
- **Prompts** (`ui/src/prompts/Prompt.svelte`, focus-trapped by `lib/dialog.js`): closing with
unsaved changes offers Save, Discard or Keep for this session; switching profile offers Save,
Discard or Cancel. Tray Quit decides from the files (`ConfirmQuit` in
Expand Down
5 changes: 4 additions & 1 deletion src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ std::string StripUiOnlyKeys(const std::string& iniText) {
// Keep in step with IsGlobalProfileKey (src/profiles.cpp) minus "profile": a test pins it.
for (const char* k : { "uiTheme=", "uiPalette=", "showAdvanced=", "onboarded=",
"trayPerf=", "traySliders=", "traySliderOrder=", "trayToggles=",
"trayToggleOrder=", "trayPinned=" }) {
"trayToggleOrder=", "trayPinned=", "uiHighResNoticeOff=" }) {
if (line.compare(b, strlen(k), k) == 0) { uiOnly = true; break; }
}
}
Expand Down Expand Up @@ -533,6 +533,9 @@ std::string DefaultIniText() {
"; to Windows, normally the hidden icons overflow (default). Applied by WindTray, never\n"
"; reloads the core\n"
"trayPinned=0\n"
"; uiHighResNoticeOff: 1=never show the High resolution cursor notice again (its Don't\n"
"; show this again box). UI only, never reloads the core\n"
"uiHighResNoticeOff=0\n"
"; cursorVisibility: auto=hide our cursor when the focused app hides its own (games);\n"
"; always=always draw it; never=never draw it\n"
"cursorVisibility=auto\n"
Expand Down
5 changes: 3 additions & 2 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -523,8 +523,9 @@ bool IsForbiddenBindVk(int vk);
// True when exeName (bare file name, any case) appears in a comma-separated list. Used for the
// Auto/hybrid transform exclusion (fullscreen browser video must stay on the render engine).
bool IsExeInList(const std::string& exeName, const std::string& list);
// The ini text with UI-ONLY lines removed (uiTheme, uiPalette, showAdvanced, onboarded and the five
// tray layout keys trayPerf, traySliders, traySliderOrder, trayToggles, trayToggleOrder): the
// The ini text with UI-ONLY lines removed (uiTheme, uiPalette, showAdvanced, onboarded, trayPinned,
// uiHighResNoticeOff and the five tray layout keys trayPerf, traySliders, traySliderOrder,
// trayToggles, trayToggleOrder): the
// settings app and the tray own those keys and the core never consumes them, yet every write
// hot-reloads the core - and the reload resets the ZoomController, so toggling the app theme or
// editing the tray layout while zoomed collapsed the zoom to 1x (field report). The core compares this stripped form across reloads and skips
Expand Down
5 changes: 3 additions & 2 deletions src/profiles.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ static std::string trim(const std::string& s) {
bool IsGlobalProfileKey(const std::string& key) {
return key == "profile" || key == "onboarded" || key == "uiTheme" || key == "uiPalette" || key == "showAdvanced" ||
key == "trayPerf" || key == "traySliders" || key == "traySliderOrder" ||
key == "trayToggles" || key == "trayToggleOrder" || key == "trayPinned";
key == "trayToggles" || key == "trayToggleOrder" || key == "trayPinned" ||
key == "uiHighResNoticeOff";
}
std::string ProfileNameError(const std::string& name) {
if (name.empty()) return "Name cannot be empty";
Expand Down Expand Up @@ -68,7 +69,7 @@ std::string MakeLiveText(const std::string& profileText, const std::string& oldL
std::string out = StripGlobalKeyLines(profileText);
auto oldVals = ReadIniValues(oldLiveText);
for (const char* k : {"onboarded", "uiTheme", "uiPalette", "showAdvanced", "trayPerf", "traySliders",
"traySliderOrder", "trayToggles", "trayToggleOrder", "trayPinned"}) {
"traySliderOrder", "trayToggles", "trayToggleOrder", "trayPinned", "uiHighResNoticeOff"}) {
auto it = oldVals.find(k);
if (it != oldVals.end()) out = UpdateIniText(out, k, it->second);
}
Expand Down
3 changes: 2 additions & 1 deletion src/profiles.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
#include <vector>
namespace wind {
// Machine/app state that never travels with a profile: the active-profile pointer itself,
// the onboarding flag, the UI-only theme/palette/advanced toggles (uiTheme, uiPalette, showAdvanced), and the five tray-menu keys (#313).
// the onboarding flag, the UI-only theme/palette/advanced toggles (uiTheme, uiPalette, showAdvanced), the five tray-menu keys (#313),
// trayPinned (#436) and uiHighResNoticeOff (#441).
bool IsGlobalProfileKey(const std::string& key);
// "" when the (already-trimmed) name is a valid profile name, else a short user-facing reason.
std::string ProfileNameError(const std::string& name);
Expand Down
4 changes: 2 additions & 2 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 28
#define WIND_VER_MINOR 29
#define WIND_VER_PATCH 0

// String form for logs/snapshot/UI. Keep in sync with the numeric parts above.
#define WIND_VERSION_STR "0.28.0"
#define WIND_VERSION_STR "0.29.0"
5 changes: 5 additions & 0 deletions tests/test_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,11 @@ TEST_CASE("ParseConfig clamps trackGlideMs and txMaxStepPct (review #72)") {
CHECK(ParseConfig("txMaxStepPct=-5\n").txMaxStepPct == 0);
CHECK(ParseConfig("txMaxStepPct=99999\n").txMaxStepPct == 1000);
}
TEST_CASE("uiHighResNoticeOff is UI-only: never reloads the core and never travels with a profile (#441)") {
CHECK(StripUiOnlyKeys("a=1\nuiHighResNoticeOff=0\nb=2\n") == "a=1\nb=2\n");
CHECK(StripUiOnlyKeys("a=1\nuiHighResNoticeOff=0\n") == StripUiOnlyKeys("a=1\nuiHighResNoticeOff=1\n"));
CHECK(StripUiOnlyKeys("uiHighResNoticeOffX=1\n") == "uiHighResNoticeOffX=1\n");
}
TEST_CASE("trayPinned ships off, clamps to 0..1 and is UI-only: never reloads the core (#436)") {
CHECK(ParseConfig("").trayPinned == 0);
CHECK(ParseConfig("trayPinned=0\n").trayPinned == 0);
Expand Down
9 changes: 8 additions & 1 deletion tests/test_profiles.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,16 @@ TEST_CASE("ParseIniTmpName recognises WriteTextFileAtomic's leftover temp names"
CHECK_FALSE(ParseIniTmpName(L".ini.1234.tmp", pid)); // empty stem
CHECK_FALSE(ParseIniTmpName(L"x.ini.99999999999.tmp", pid)); // pid out of range
}
TEST_CASE("uiHighResNoticeOff is global: stripped from profiles, carried into the live text (#441)") {
CHECK(IsGlobalProfileKey("uiHighResNoticeOff"));
CHECK(MakeProfileText("a=1\nuiHighResNoticeOff=1\n") == "a=1\n");
const std::string live = MakeLiveText("a=2\n", "a=1\nuiHighResNoticeOff=1\n", "P");
CHECK(live.find("uiHighResNoticeOff=1") != std::string::npos);
CHECK(live.find("a=2") != std::string::npos);
}
TEST_CASE("StripUiOnlyKeys strips every global key except profile (tray edits must not reload the core)") {
const char* globals[] = { "onboarded", "uiTheme", "uiPalette", "showAdvanced", "trayPerf", "traySliders",
"traySliderOrder", "trayToggles", "trayToggleOrder", "trayPinned" };
"traySliderOrder", "trayToggles", "trayToggleOrder", "trayPinned", "uiHighResNoticeOff" };
for (const char* k : globals) {
REQUIRE(IsGlobalProfileKey(k));
const std::string line = std::string(k) + "=1\n";
Expand Down
21 changes: 21 additions & 0 deletions ui/src/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,20 @@
}

// --- Changes --------------------------------------------------------------------------------
// High resolution cursor notice (#441): turning it ON asks first, unless the user opted out
// (uiHighResNoticeOff, a global UI-only key). Every row that renders the key (page and search) comes here.
let highResPrompt = $state(false);
let highResSkip = $state(false);
function acceptHighRes() {
if (highResSkip) { values = { ...values, uiHighResNoticeOff: '1' }; setConfig('uiHighResNoticeOff', '1'); }
highResPrompt = false; highResSkip = false;
values = { ...values, txSamplingMode: 1 };
setConfig('txSamplingMode', 1);
}
function cancelHighRes() { highResPrompt = false; highResSkip = false; }
function change(key, val) {
if (key === 'txSamplingMode' && Number(val) === 1 && Number(values.txSamplingMode) !== 1 &&
String(values.uiHighResNoticeOff) !== '1') { highResSkip = false; highResPrompt = true; return; }
if (key === 'model') announce('Magnifier model set to ' + val + '. Some display options changed.');
if (key === 'uiPalette') announce('Theme ' + (themes.find((t) => t.id === val)?.label ?? val));
values = { ...values, [key]: val };
Expand Down Expand Up @@ -301,6 +314,14 @@
<SaveCapsule {count} onSave={save} onDiscard={discard} />
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">{announcement}</div>

{#if highResPrompt}
<Prompt id="hires" title="High resolution cursor is experimental"
text="It makes the zoomed image and the pointer smoother. Windows smooths the image as it scales it, which can make the view and pointer shake slightly while you zoom. Wind compensates as well as it can, but this limits some features, such as the zoom glide after you let go, which stops a little early."
checkLabel="Don't show this again" bind:checked={highResSkip}
onEsc={cancelHighRes}
buttons={[{ label: 'Cancel', onClick: cancelHighRes },
{ label: 'Turn on', kind: 'primary', onClick: acceptHighRes }]} />
{/if}
{#if closePrompt}
<Prompt id="close" title="Unsaved changes"
text="You have changes that are not saved. Save them to the profile, discard them, or keep them for this session (they apply until Wind quits)."
Expand Down
12 changes: 10 additions & 2 deletions ui/src/controls/HighRes.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
<script>
// High resolution cursor: the toggle drives txSamplingMode. It applies live (#369), so no restart tag.
// Turning it on may be held back by the page (the experimental notice, #441), in which case `value`
// does not change and the switch must not stay drawn as on: reset it to the prop after each flip.
import { tick } from 'svelte';
import Toggle from './Toggle.svelte';
let { value = 0, onChange = () => {}, disabled = false, labelledby, describedby } = $props();
let root = $state();
function flip(v) {
onChange(v);
tick().then(() => { const i = root && root.querySelector('input'); if (i) i.checked = Number(value) === 1; });
}
</script>

<div class="hr">
<Toggle {value} {onChange} {disabled} {labelledby} {describedby} />
<div class="hr" bind:this={root}>
<Toggle {value} onChange={flip} {disabled} {labelledby} {describedby} />
</div>

<style>
Expand Down
15 changes: 12 additions & 3 deletions ui/src/prompts/Prompt.svelte
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
<script>
// One modal prompt. buttons: [{ label, kind: 'primary' | 'danger' | '', onClick }]. Esc calls onEsc (omit to
// make Esc do nothing). Focus moves into the box and Tab is held there by the dialog action.
// checkLabel (optional) adds a labelled checkbox between the text and the buttons; bind:checked reads it.
import { dialog } from '../lib/dialog.js';
let { id = 'p', title = '', text = '', buttons = [], onEsc = undefined } = $props();
let { id = 'p', title = '', text = '', buttons = [], onEsc = undefined, checkLabel = '', checked = $bindable(false) } = $props();
</script>

<div class="mbackdrop">
<div class="mbox" role="dialog" aria-modal="true" aria-labelledby="{id}-t" aria-describedby="{id}-d"
use:dialog={{ onClose: onEsc }}>
<h2 id="{id}-t">{title}</h2>
<p id="{id}-d">{text}</p>
<p id="{id}-d" class:tight={!!checkLabel}>{text}</p>
{#if checkLabel}
<label class="chk"><input type="checkbox" bind:checked /><span>{checkLabel}</span></label>
{/if}
<div class="mbtns">
{#each buttons as b (b.label)}
<button type="button" class:primary={b.kind === 'primary'} class:danger={b.kind === 'danger'} onclick={b.onClick}>{b.label}</button>
Expand All @@ -22,13 +26,18 @@
.mbackdrop { position: fixed; inset: 0; background: var(--scrim); display: flex;
align-items: center; justify-content: center; z-index: 50; }
.mbox { background: var(--card); color: var(--fg); border: 1px solid var(--line2); border-radius: var(--rc);
padding: 22px 24px; width: 440px; max-width: calc(100vw - 48px); box-shadow: var(--shadow); }
padding: 22px 24px; width: 440px; max-width: calc(100vw - 48px); box-shadow: var(--shadow);
max-height: calc(100vh - 32px); overflow-y: auto; }
.mbackdrop { animation: fade var(--dur) var(--ease); }
.mbox { animation: pop var(--dur) var(--ease); }
@keyframes fade { from { opacity: 0; } }
@keyframes pop { from { opacity: 0; transform: scale(.98); } }
h2 { margin: 0 0 8px; font: 600 15px var(--s); }
p { margin: 0 0 20px; font: 13px/1.5 var(--s); color: var(--fg2); }
p.tight { margin-bottom: 14px; }
.chk { display: flex; align-items: center; gap: 8px; margin: 0 0 20px; font: 13px var(--s); color: var(--fg); cursor: pointer; }
.chk input { margin: 0; width: 16px; height: 16px; flex: none; accent-color: var(--accent); cursor: pointer; }
.chk input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.mbtns { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
button { height: 32px; padding: 0 16px; border-radius: var(--rp); border: 1px solid var(--chipb);
background: var(--chip); color: var(--fg); font: 600 12.5px var(--s); }
Expand Down
3 changes: 2 additions & 1 deletion ui/src/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { groups, groupRows, bindKeys } from './settings-schema.js';

// Mirrors IsGlobalProfileKey in src/profiles.cpp: these are never part of a profile, so they never
// count as an unsaved change. The five tray keys (#313) live in the live ini only.
// uiHighResNoticeOff (#441) is the opt-out of the High resolution cursor notice.
export const GLOBAL_KEYS = new Set(['profile', 'onboarded', 'uiTheme', 'uiPalette', 'showAdvanced',
'trayPerf', 'traySliders', 'traySliderOrder', 'trayToggles', 'trayToggleOrder', 'trayPinned']);
'trayPerf', 'traySliders', 'traySliderOrder', 'trayToggles', 'trayToggleOrder', 'trayPinned', 'uiHighResNoticeOff']);

// Must match the core's shipped defaults (src/config.h): every keybind ships unbound except Quick
// zoom. Seeding anything else here would invent a binding the user never chose.
Expand Down
Loading
Loading